/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).
656 lines
26 KiB
C#
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);
|
|
}
|
|
}
|