Whisper/Whisper.cs
Astra 63e07d12c8 0.2.1: don't read the player list in OnAllPluginsLoaded at server start
It isn't readable before the server has set up (Global Variables not
initialized yet). Online players' blocks are only read there on a hot
reload; at start everyone's load as they join.
2026-10-07 09:36:08 +01:00

735 lines
30 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. While History is on, every
// message is kept in CS2-SimpleAdmin's database (wh_messages): private ones, all and team chat, @
// staff chat and SimpleAdmin's say commands. Blocks are in wh_blocks.
//
// Chat is seen by a catch-all command listener added in Load. CSS runs catch-alls before any
// per-command listener, in the order they were added, and stops at the first that returns Stop.
// CS2-SimpleAdmin adds its own (which stops gagged players' chat and handles @) in
// OnAllPluginsLoaded, after every plugin's Load, so this one always sees a line first. It only
// looks, never 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.2.1";
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" };
// SimpleAdmin's commands that send a message, logged under their own name as the channel.
private static readonly HashSet<string> StaffCommands = new(StringComparer.OrdinalIgnoreCase)
{ "css_say", "css_asay", "css_psay", "css_csay", "css_hsay", "css_cssay" };
// 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));
// In Load, not OnAllPluginsLoaded: see the top of this file.
AddCommandListener(null, OnAnyCommand, HookMode.Pre);
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();
}
// Only on a hot reload: at server start nobody's connected yet, and the player list can't be
// read before the server has set up (it throws). Everyone's blocks load as they join.
if (hotReload)
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 -----------------------------------------------------------------------
// Logs chat and staff messages as they're sent. Never blocks anything.
private HookResult OnAnyCommand(CCSPlayerController? player, CommandInfo info)
{
if (!Config.History || _writer == null)
return HookResult.Continue;
var command = info.GetArg(0).ToLowerInvariant();
if (StaffCommands.Contains(command))
{
// Typed in console, RCON (the web panel too), or "!asay" in chat, which CSS runs as css_asay.
var sender = player is { IsValid: true, IsBot: false } ? StateOf(player) : player == null ? PlayerState.Console : null;
if (sender == null)
return HookResult.Continue;
var rest = info.ArgString;
var target = command == "css_psay" ? NextToken(ref rest) : null;
var message = rest.Trim();
if (message.Length > 0)
Log(command, sender, null, target, message, Result.Sent);
return HookResult.Continue;
}
if (command is not ("say" or "say_team") || player is not { IsValid: true, IsBot: false })
return HookResult.Continue;
var text = info.GetArg(1);
if (text.Length == 0)
return HookResult.Continue;
var team = command == "say_team";
var channel = team ? ChatChannel.Team : ChatChannel.All;
var trigger = ChatTrigger(text);
if (trigger != null)
{
var rest = text[trigger.Length..];
var word = NextToken(ref rest).ToLowerInvariant();
// Logged on their own: PMs where they're sent, "!asay" and the like as the command.
if (!ChatCommands.Contains(word) && !StaffCommands.Contains("css_" + word))
Log(channel, StateOf(player), null, null, text, Result.Command);
return HookResult.Continue;
}
if (text.StartsWith('@'))
{
var chatStaff = AdminManager.PlayerHasPermissions(player, "@css/chat");
// SimpleAdmin runs this as css_say, which is logged then.
if (!team && chatStaff)
return HookResult.Continue;
// @ team chat goes to staff, and gets through a gag.
if (team)
{
Log(chatStaff ? ChatChannel.Staff : ChatChannel.ToStaff, StateOf(player), null, null, text[1..].Trim(), Result.Sent);
return HookResult.Continue;
}
}
Log(channel, StateOf(player), null, null, text, IsGagged(player, out _) ? Result.Gagged : Result.Sent);
return HookResult.Continue;
}
private static string? ChatTrigger(string text) =>
CoreConfig.PublicChatTrigger.Concat(CoreConfig.SilentChatTrigger).FirstOrDefault(t => t.Length > 0 && text.StartsWith(t));
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 = ChatTrigger(text);
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) =>
Log(ChatChannel.Pm, StateOf(sender), recipient, target, message, result);
private void Log(string channel, PlayerState 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 ?? "",
channel, 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);
}
}