Players are verified when they're in the configured Steam group (public memberslistxml, refetched every 10 minutes, kept on failure) or have an unexpired override row in gv_verified, written by the web panel. Settings and data live in CS2-SimpleAdmin's MySQL database (gv_verified, gv_settings, gv_migrations), reached through its API. - css_gv_group <groupId64|0>, css_gv_enable, css_gv_disable (@css/root): verification runs only when enabled with a group set. - css_verify <target>: why a player is or isn't verified. Admin and above (by immunity from SimpleAdmin's groups) plus Guardian, unless admin_overrides.json says otherwise. - groupverify:api (shared/GroupVerifyApi): IsVerified, GetStatus, IsActive and OnVerifiedChanged. The API object lives in the shared assembly, so it survives hot reloads of GroupVerify.
766 lines
30 KiB
C#
766 lines
30 KiB
C#
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.Entities.Constants;
|
|
using CounterStrikeSharp.API.Modules.Timers;
|
|
using CounterStrikeSharp.API.Modules.Utils;
|
|
using CS2_SimpleAdminApi;
|
|
using GroupVerifyApi;
|
|
using Microsoft.Extensions.Logging;
|
|
using Timer = CounterStrikeSharp.API.Modules.Timers.Timer;
|
|
|
|
namespace GroupVerify;
|
|
|
|
// Marks players as verified when they're in a Steam group, or have a manual override in the
|
|
// gv_verified table (written by the web panel), and tells other plugins through GroupVerifyApi.
|
|
// Everything is kept in CS2-SimpleAdmin's database, over its connection string.
|
|
//
|
|
// Threads: every field below is only touched on the game thread. HTTP and database work runs in
|
|
// Task.Run and hands its result back with Server.NextFrame(Async).
|
|
public class GroupVerify : BasePlugin, IGroupVerifyBackend
|
|
{
|
|
public override string ModuleName => "GroupVerify";
|
|
public override string ModuleVersion => "0.2.0";
|
|
public override string ModuleAuthor => "astra";
|
|
public override string ModuleDescription => "Verifies players by Steam group membership or a manual override";
|
|
|
|
private const string VerifyCommand = "css_verify";
|
|
// Changing settings is for the owner, unless admin_overrides.json says otherwise.
|
|
private const string SettingsPermission = "@css/root";
|
|
// css_verify answers per player up to this many targets, with a summary above it.
|
|
private const int PerPlayerReplyLimit = 5;
|
|
|
|
private static readonly PluginCapability<ICS2_SimpleAdminApi> SimpleAdminCapability = new("simpleadmin:api");
|
|
private ICS2_SimpleAdminApi? _simpleAdmin;
|
|
private Database? _db;
|
|
private readonly CancellationTokenSource _unloading = new();
|
|
|
|
private Settings _settings = Settings.Default;
|
|
// Set once the first database load has applied; nothing is evaluated before that.
|
|
private bool _ready;
|
|
// Bumped when a command changes a setting, so a reload that read the table before the change
|
|
// doesn't put the old value back.
|
|
private int _settingsVersion;
|
|
|
|
// The group's members. Seeded from gv_verified's 'group' rows at load, so a Steam outage at
|
|
// startup doesn't unverify everyone, then replaced by each successful fetch.
|
|
private HashSet<ulong> _groupMembers = [];
|
|
private DateTime? _groupFetchedAt; // UTC, the last successful fetch this session
|
|
private DateTime? _groupAttemptAt; // UTC, the last fetch started
|
|
private Task? _groupRefresh; // in flight, shared by everyone who asks
|
|
|
|
// SteamID64 -> override end (null: never), from gv_verified.
|
|
private Dictionary<ulong, DateTime?> _overrides = [];
|
|
private DateTime? _dbLoadedAt; // UTC
|
|
private DateTime? _dbAttemptAt; // UTC
|
|
private Task? _dbReload;
|
|
|
|
// Slot -> the SteamID64 evaluated there and whether they were verified, to tell other plugins
|
|
// only about changes.
|
|
private readonly Dictionary<int, (ulong SteamId, bool Verified)> _evaluated = [];
|
|
|
|
private Timer? _groupTimer;
|
|
private Timer? _dbTimer;
|
|
|
|
// A css_verify target: an online player (Slot set) or a SteamID looked up offline.
|
|
private sealed record Target(ulong SteamId, string Name, int? Slot);
|
|
|
|
private sealed record Caller(int Slot, ulong SteamId, string Name);
|
|
|
|
public override void Load(bool hotReload)
|
|
{
|
|
RegisterEventHandler<EventPlayerConnectFull>(OnPlayerConnectFull);
|
|
RegisterListener<Listeners.OnClientAuthorized>(OnClientAuthorized);
|
|
RegisterListener<Listeners.OnClientDisconnect>(OnClientDisconnect);
|
|
|
|
// The bridge lives in the shared GroupVerifyApi assembly, so it's the same object across
|
|
// reloads of this plugin. CSS keeps every supplier ever registered and always uses the
|
|
// first, so register it once per server run, whichever load gets here first.
|
|
GroupVerifyBridge.Instance.Attach(this);
|
|
if (!ApiRegistered())
|
|
Capabilities.RegisterPluginCapability(IGroupVerifyApi.Capability, () => GroupVerifyBridge.Instance);
|
|
}
|
|
|
|
private static bool ApiRegistered()
|
|
{
|
|
try
|
|
{
|
|
return IGroupVerifyApi.Capability.Get() != null;
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public override void OnAllPluginsLoaded(bool hotReload)
|
|
{
|
|
try
|
|
{
|
|
_simpleAdmin = SimpleAdminCapability.Get();
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
}
|
|
|
|
if (_simpleAdmin == null)
|
|
{
|
|
Logger.LogError("CS2-SimpleAdmin's API isn't available, so there's no database: verification is off");
|
|
return;
|
|
}
|
|
|
|
var connectionString = _simpleAdmin.GetConnectionString();
|
|
if (string.IsNullOrEmpty(connectionString))
|
|
{
|
|
Logger.LogError("CS2-SimpleAdmin has no MySQL connection string (is it on SQLite?): verification is off");
|
|
return;
|
|
}
|
|
|
|
_db = new Database(connectionString);
|
|
var db = _db;
|
|
Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
foreach (var version in await db.MigrateAsync())
|
|
Logger.LogInformation("Migration \"{Version}\" applied", version);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Couldn't create GroupVerify's tables: verification is off");
|
|
return;
|
|
}
|
|
await Server.NextFrameAsync(() => { ReloadDatabase(); });
|
|
});
|
|
}
|
|
|
|
public override void Unload(bool hotReload)
|
|
{
|
|
GroupVerifyBridge.Instance.Detach(this);
|
|
_unloading.Cancel();
|
|
_groupTimer?.Kill();
|
|
_dbTimer?.Kill();
|
|
_evaluated.Clear();
|
|
}
|
|
|
|
// --- API (game thread) -------------------------------------------------------------------
|
|
|
|
public bool IsActive => _ready && _settings.Active;
|
|
|
|
public VerifyStatus GetStatus(ulong steamId64)
|
|
{
|
|
if (!IsActive)
|
|
return default;
|
|
var inGroup = _groupMembers.Contains(steamId64);
|
|
if (!_overrides.TryGetValue(steamId64, out var ends))
|
|
return new VerifyStatus(inGroup, false, null);
|
|
// Overrides can run out between reloads.
|
|
var active = ends == null || ends > _settings.Now();
|
|
return new VerifyStatus(inGroup, active, active ? ends : null);
|
|
}
|
|
|
|
// --- Refreshing (game thread) ------------------------------------------------------------
|
|
|
|
// Rereads gv_settings and gv_verified, then re-evaluates everyone. Concurrent calls share one.
|
|
private Task ReloadDatabase()
|
|
{
|
|
if (_db == null)
|
|
return Task.CompletedTask;
|
|
if (_dbReload != null)
|
|
return _dbReload;
|
|
|
|
var db = _db;
|
|
var version = _settingsVersion;
|
|
_dbAttemptAt = DateTime.UtcNow;
|
|
return _dbReload = Task.Run(async () =>
|
|
{
|
|
Database.Snapshot? snapshot = null;
|
|
try
|
|
{
|
|
snapshot = await db.LoadAsync(message => Logger.LogWarning("{Message}", message));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError("Couldn't load gv_settings/gv_verified, keeping what's loaded: {Error}", ex.Message);
|
|
}
|
|
|
|
if (_unloading.IsCancellationRequested)
|
|
return;
|
|
await Server.NextFrameAsync(() =>
|
|
{
|
|
_dbReload = null;
|
|
if (snapshot == null)
|
|
return;
|
|
_overrides = snapshot.Overrides;
|
|
_dbLoadedAt = DateTime.UtcNow;
|
|
if (snapshot.Expired > 0)
|
|
Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired);
|
|
ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings, snapshot.GroupRows);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Switches to new settings (or just re-evaluates, if they're the same). savedGroupRows is the
|
|
// group list saved in the database, used only at the first load.
|
|
private void ApplySettings(Settings next, HashSet<ulong>? savedGroupRows = null)
|
|
{
|
|
var old = _settings;
|
|
var first = !_ready;
|
|
_settings = next;
|
|
_ready = true;
|
|
|
|
var groupChanged = old.GroupId64 != next.GroupId64;
|
|
if (first || groupChanged)
|
|
{
|
|
// At load, the saved rows stand in for the list until the first fetch below. They're
|
|
// whichever group was last fetched, so after a change of group they're not used.
|
|
_groupMembers = first && savedGroupRows != null ? savedGroupRows : [];
|
|
_groupFetchedAt = null;
|
|
_groupAttemptAt = null;
|
|
}
|
|
|
|
if (first || old.Active != next.Active)
|
|
{
|
|
if (next.Active)
|
|
Logger.LogInformation("Verification is on, Steam group {Group}", next.GroupId64);
|
|
else
|
|
Logger.LogWarning("Verification is off ({Why}): nobody is verified",
|
|
next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable");
|
|
}
|
|
|
|
if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds)
|
|
RestartTimers();
|
|
|
|
EvaluateAll();
|
|
|
|
if (next.Active && (first || groupChanged || !old.Active))
|
|
RefreshGroup();
|
|
}
|
|
|
|
private void RestartTimers()
|
|
{
|
|
_groupTimer?.Kill();
|
|
_dbTimer?.Kill();
|
|
_groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT);
|
|
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT);
|
|
}
|
|
|
|
// Fetches the group's member list and saves it to gv_verified. A failed or empty fetch keeps the
|
|
// old list. Does nothing while verification is off. Concurrent calls share one.
|
|
private Task RefreshGroup()
|
|
{
|
|
if (_db == null || !IsActive)
|
|
return Task.CompletedTask;
|
|
if (_groupRefresh != null)
|
|
return _groupRefresh;
|
|
|
|
var db = _db;
|
|
var settings = _settings;
|
|
var groupId = settings.GroupId64;
|
|
var cancel = _unloading.Token;
|
|
_groupAttemptAt = DateTime.UtcNow;
|
|
return _groupRefresh = Task.Run(async () =>
|
|
{
|
|
HashSet<ulong>? members = null;
|
|
try
|
|
{
|
|
members = await SteamGroup.FetchMembersAsync(groupId, cancel);
|
|
if (members.Count == 0)
|
|
{
|
|
Logger.LogWarning("Steam group {Group}'s member list came back empty, keeping the old one", groupId);
|
|
members = null;
|
|
}
|
|
}
|
|
catch (Exception ex) when (!cancel.IsCancellationRequested)
|
|
{
|
|
Logger.LogWarning("Couldn't fetch Steam group {Group}'s member list, keeping the old one: {Error}",
|
|
groupId, ex.Message);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (members != null)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveGroupAsync(members, settings.Now());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// The fetched list is still used; it just won't survive a restart.
|
|
Logger.LogError("Couldn't save the Steam group list to gv_verified: {Error}", ex.Message);
|
|
}
|
|
}
|
|
|
|
if (cancel.IsCancellationRequested)
|
|
return;
|
|
await Server.NextFrameAsync(() =>
|
|
{
|
|
_groupRefresh = null;
|
|
// Dropped if the group changed or verification was turned off while fetching.
|
|
if (members == null || groupId != _settings.GroupId64 || !IsActive)
|
|
return;
|
|
_groupMembers = members;
|
|
_groupFetchedAt = DateTime.UtcNow;
|
|
EvaluateAll();
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Verification (game thread) ----------------------------------------------------------
|
|
|
|
private void EvaluateAll()
|
|
{
|
|
foreach (var player in Utilities.GetPlayers())
|
|
Evaluate(player);
|
|
}
|
|
|
|
// Tells other plugins (OnVerifiedChanged) when the player's verified status has changed.
|
|
private void Evaluate(CCSPlayerController? player)
|
|
{
|
|
if (!_ready || player is not { IsValid: true, IsBot: false, IsHLTV: false })
|
|
return;
|
|
// Only Steam-authorized IDs count; OnClientAuthorized evaluates again once it's known.
|
|
var steamId = player.AuthorizedSteamID?.SteamId64;
|
|
if (steamId == null)
|
|
return;
|
|
|
|
var status = GetStatus(steamId.Value);
|
|
var had = _evaluated.TryGetValue(player.Slot, out var last) && last.SteamId == steamId && last.Verified;
|
|
_evaluated[player.Slot] = (steamId.Value, status.Verified);
|
|
if (status.Verified == had)
|
|
return;
|
|
|
|
Logger.LogInformation("{Name} ({SteamId}) is {Status}", player.PlayerName, steamId,
|
|
status.Verified ? "verified" : "no longer verified");
|
|
foreach (var ex in GroupVerifyBridge.Instance.RaiseVerifiedChanged(player, status))
|
|
Logger.LogError(ex, "An OnVerifiedChanged handler threw for {Name}", player.PlayerName);
|
|
}
|
|
|
|
// The player is leaving: other plugins get no event, just forget them.
|
|
private void ForgetPlayer(int slot)
|
|
{
|
|
_evaluated.Remove(slot);
|
|
}
|
|
|
|
private HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info)
|
|
{
|
|
Evaluate(@event.Userid);
|
|
return HookResult.Continue;
|
|
}
|
|
|
|
private void OnClientAuthorized(int playerSlot, SteamID steamId)
|
|
{
|
|
Evaluate(Utilities.GetPlayerFromSlot(playerSlot));
|
|
}
|
|
|
|
private void OnClientDisconnect(int playerSlot)
|
|
{
|
|
ForgetPlayer(playerSlot);
|
|
}
|
|
|
|
// --- css_verify --------------------------------------------------------------------------
|
|
|
|
[ConsoleCommand(VerifyCommand, "Shows whether players are verified (Steam group or manual override)")]
|
|
[CommandHelper(usage: "<@all|@t|@ct|@spec|@alive|@dead|@me|#userid|name|steamid>")]
|
|
public void OnVerify(CCSPlayerController? player, CommandInfo command)
|
|
{
|
|
if (player != null && !player.IsValid)
|
|
return;
|
|
|
|
var arg = command.ArgCount > 1 ? command.ArgString.Trim().Trim('"').Trim() : "";
|
|
if (arg.Length == 0)
|
|
{
|
|
if (player == null)
|
|
{
|
|
command.ReplyToCommand($"Usage: {VerifyCommand} <@all|@t|@ct|@spec|@alive|@dead|#userid|name|steamid>");
|
|
return;
|
|
}
|
|
arg = "@me";
|
|
}
|
|
|
|
if (_db == null || !_ready)
|
|
{
|
|
command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log).");
|
|
return;
|
|
}
|
|
|
|
var commandString = command.GetCommandString;
|
|
var targets = ResolveTargets(player, arg, out var error);
|
|
if (targets == null)
|
|
{
|
|
command.ReplyToCommand($"[Verify] {error}");
|
|
return;
|
|
}
|
|
|
|
if (!TryGetCaller(player, command, out var caller))
|
|
return;
|
|
|
|
// The server console always may. An admin_overrides.json entry for css_verify is enforced
|
|
// by CSS before this runs, and then replaces the rank check.
|
|
if (caller == null || AdminManager.CommandIsOverriden(VerifyCommand))
|
|
{
|
|
Run(caller, commandString, targets);
|
|
return;
|
|
}
|
|
|
|
var db = _db;
|
|
var settings = _settings;
|
|
var serverId = _simpleAdmin?.GetServerId();
|
|
Task.Run(async () =>
|
|
{
|
|
bool allowed;
|
|
string? problem;
|
|
try
|
|
{
|
|
(allowed, problem) = await db.CanVerifyAsync(caller!.SteamId, settings.VerifyMinRank,
|
|
settings.VerifyExtraRanks, serverId, settings.Now());
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
(allowed, problem) = (false, $"rank check failed: {ex.Message}");
|
|
}
|
|
|
|
Server.NextFrame(() =>
|
|
{
|
|
if (problem != null)
|
|
Logger.LogWarning("css_verify: {Problem}", problem);
|
|
if (allowed)
|
|
{
|
|
Run(caller, commandString, targets);
|
|
return;
|
|
}
|
|
Logger.LogInformation("{Name} ({SteamId}) was refused {Command}", caller!.Name, caller.SteamId, commandString);
|
|
Reply(caller, $"{ChatColors.Red}You don't have access to this command.");
|
|
});
|
|
});
|
|
}
|
|
|
|
// Brings the group list and database up to date if they're older than
|
|
// min_forced_refresh_seconds, then answers.
|
|
private void Run(Caller? caller, string commandString, List<Target> targets)
|
|
{
|
|
LogCommand(caller, commandString);
|
|
|
|
var maxAge = TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds);
|
|
var now = DateTime.UtcNow;
|
|
var pending = new List<Task>();
|
|
if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge)
|
|
pending.Add(RefreshGroup());
|
|
if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge)
|
|
pending.Add(ReloadDatabase());
|
|
|
|
if (pending.All(t => t.IsCompleted))
|
|
{
|
|
Answer(caller, targets);
|
|
return;
|
|
}
|
|
Task.WhenAll(pending).ContinueWith(_ => Server.NextFrame(() => Answer(caller, targets)));
|
|
}
|
|
|
|
private void Answer(Caller? caller, List<Target> targets)
|
|
{
|
|
if (caller != null && CurrentPlayer(caller) == null)
|
|
return;
|
|
|
|
int verified = 0, byGroup = 0, byOverrideOnly = 0;
|
|
var lines = new List<string>();
|
|
foreach (var target in targets)
|
|
{
|
|
// An online target who left since is looked up by SteamID instead.
|
|
var online = target.Slot is { } slot && Utilities.GetPlayerFromSlot(slot) is { IsValid: true } p &&
|
|
p.AuthorizedSteamID?.SteamId64 == target.SteamId
|
|
? p
|
|
: null;
|
|
if (online != null)
|
|
Evaluate(online);
|
|
|
|
var status = GetStatus(target.SteamId);
|
|
if (status.Verified)
|
|
verified++;
|
|
if (status.InGroup)
|
|
byGroup++;
|
|
else if (status.HasOverride)
|
|
byOverrideOnly++;
|
|
|
|
if (targets.Count <= PerPlayerReplyLimit)
|
|
{
|
|
var name = online?.PlayerName ?? target.Name;
|
|
var where = online == null ? " (offline)" : "";
|
|
lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}");
|
|
}
|
|
}
|
|
|
|
if (targets.Count > PerPlayerReplyLimit)
|
|
{
|
|
lines.Add($"{targets.Count} players: {ChatColors.Green}{verified} verified{ChatColors.Default} " +
|
|
$"({byGroup} Steam group, {byOverrideOnly} override only), " +
|
|
$"{ChatColors.Red}{targets.Count - verified} not verified{ChatColors.Default}.");
|
|
}
|
|
lines.Add($"{ChatColors.Grey}{DescribeSources()}");
|
|
|
|
foreach (var line in lines)
|
|
Reply(caller, line);
|
|
}
|
|
|
|
private string Describe(VerifyStatus status)
|
|
{
|
|
if (!status.Verified)
|
|
return $"{ChatColors.Red}not verified{ChatColors.Default}";
|
|
|
|
var reasons = new List<string>();
|
|
if (status.InGroup)
|
|
reasons.Add("Steam group");
|
|
if (status.HasOverride)
|
|
{
|
|
reasons.Add(status.OverrideExpires is { } ends
|
|
? $"override until {ends:yyyy-MM-dd HH:mm} {_settings.TimeZone.Id} (in {FormatSpan(ends - _settings.Now())})"
|
|
: "override, no expiry");
|
|
}
|
|
return $"{ChatColors.Green}verified{ChatColors.Default} ({string.Join(" + ", reasons)})";
|
|
}
|
|
|
|
private string DescribeSources()
|
|
{
|
|
if (!_settings.Active)
|
|
return DescribeState();
|
|
|
|
string group;
|
|
if (_groupFetchedAt is { } fetched)
|
|
group = $"group list {_groupMembers.Count} members, {FormatSpan(DateTime.UtcNow - fetched)} old";
|
|
else
|
|
group = $"group list {_groupMembers.Count} members from the database (Steam not reached yet)";
|
|
|
|
var db = _dbLoadedAt is { } loaded ? $"overrides {FormatSpan(DateTime.UtcNow - loaded)} old" : "overrides not loaded";
|
|
return $"{group}; {db}.";
|
|
}
|
|
|
|
private static string FormatSpan(TimeSpan span)
|
|
{
|
|
if (span < TimeSpan.Zero)
|
|
span = TimeSpan.Zero;
|
|
if (span.TotalMinutes < 1)
|
|
return $"{(int)span.TotalSeconds}s";
|
|
if (span.TotalHours < 1)
|
|
return $"{(int)span.TotalMinutes}m";
|
|
if (span.TotalDays < 1)
|
|
return $"{(int)span.TotalHours}h {span.Minutes}m";
|
|
return $"{(int)span.TotalDays}d {span.Hours}h";
|
|
}
|
|
|
|
// On or off, and why.
|
|
private string DescribeState()
|
|
{
|
|
if (_settings.Active)
|
|
return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}.";
|
|
if (!_settings.Enabled)
|
|
{
|
|
var group = _settings.GroupId64 == 0 ? "no Steam group set" : $"Steam group {_settings.GroupId64}";
|
|
return $"{ChatColors.Red}Off{ChatColors.Default}: disabled (css_gv_enable), {group}.";
|
|
}
|
|
return $"{ChatColors.Red}Off{ChatColors.Default}: no Steam group set (css_gv_group <groupId64>).";
|
|
}
|
|
|
|
// The player who ran a command, or null for the server console. False, after telling them, if
|
|
// Steam hasn't authorized them yet.
|
|
private static bool TryGetCaller(CCSPlayerController? player, CommandInfo command, out Caller? caller)
|
|
{
|
|
caller = null;
|
|
if (player == null)
|
|
return true;
|
|
var steamId = player.AuthorizedSteamID?.SteamId64;
|
|
if (steamId == null)
|
|
{
|
|
command.ReplyToCommand("[Verify] You aren't authorized with Steam yet, try again in a moment.");
|
|
return false;
|
|
}
|
|
caller = new Caller(player.Slot, steamId.Value, player.PlayerName);
|
|
return true;
|
|
}
|
|
|
|
// Logged here and through SimpleAdmin (server log and its Discord log), like its own commands.
|
|
private void LogCommand(Caller? caller, string commandString)
|
|
{
|
|
Logger.LogInformation("{Name} ({SteamId}) ran {Command}", caller?.Name ?? "Console", caller?.SteamId ?? 0, commandString);
|
|
var player = caller == null ? null : CurrentPlayer(caller);
|
|
if (caller == null || player != null)
|
|
_simpleAdmin?.LogCommand(player, commandString);
|
|
}
|
|
|
|
// The caller if they're still on the server in the same slot, else null.
|
|
private static CCSPlayerController? CurrentPlayer(Caller caller)
|
|
{
|
|
var player = Utilities.GetPlayerFromSlot(caller.Slot);
|
|
return player is { IsValid: true } && player.AuthorizedSteamID?.SteamId64 == caller.SteamId ? player : null;
|
|
}
|
|
|
|
// Answers in chat, or on the server console. Not through CommandInfo: by now it's gone.
|
|
private void Reply(Caller? caller, string message)
|
|
{
|
|
if (caller == null)
|
|
{
|
|
Server.PrintToConsole($"[Verify] {StripColors(message)}");
|
|
return;
|
|
}
|
|
CurrentPlayer(caller)?.PrintToChat($" {ChatColors.Green}[Verify]{ChatColors.Default} {message}");
|
|
}
|
|
|
|
private static string StripColors(string message) =>
|
|
new(message.Where(c => c > '\u0010' || c == '\n').ToArray());
|
|
|
|
// --- Settings commands -------------------------------------------------------------------
|
|
// Saved to gv_settings and applied at once. @css/root, or what admin_overrides.json says.
|
|
|
|
[ConsoleCommand("css_gv_enable", "Turns GroupVerify on (it also needs a Steam group, css_gv_group)")]
|
|
[RequiresPermissions(SettingsPermission)]
|
|
public void OnEnable(CCSPlayerController? player, CommandInfo command) =>
|
|
ChangeSetting(player, command, "enabled", "1", s => s with { Enabled = true });
|
|
|
|
[ConsoleCommand("css_gv_disable", "Turns GroupVerify off: nobody is verified")]
|
|
[RequiresPermissions(SettingsPermission)]
|
|
public void OnDisable(CCSPlayerController? player, CommandInfo command) =>
|
|
ChangeSetting(player, command, "enabled", "0", s => s with { Enabled = false });
|
|
|
|
[ConsoleCommand("css_gv_group", "Shows or sets the Steam group (its 64-bit ID); 0 turns GroupVerify off")]
|
|
[CommandHelper(usage: "[groupId64|0]")]
|
|
[RequiresPermissions(SettingsPermission)]
|
|
public void OnGroup(CCSPlayerController? player, CommandInfo command)
|
|
{
|
|
if (command.ArgCount < 2)
|
|
{
|
|
command.ReplyToCommand($"[Verify] {StripColors(DescribeState())} {StripColors(DescribeSources())}");
|
|
return;
|
|
}
|
|
|
|
var text = command.GetArg(1).Trim();
|
|
if (!ulong.TryParse(text, out var groupId) ||
|
|
(groupId != 0 && new SteamID(groupId) is var id && (id.AccountType != SteamAccountType.Clan || !id.IsValid())))
|
|
{
|
|
command.ReplyToCommand($"[Verify] \"{text}\" isn't a Steam group ID64 (103582791...). It's <groupID64> on " +
|
|
"https://steamcommunity.com/groups/<name>/memberslistxml/?xml=1. 0 turns verification off.");
|
|
return;
|
|
}
|
|
ChangeSetting(player, command, "group_id64", groupId.ToString(), s => s with { GroupId64 = groupId });
|
|
}
|
|
|
|
private void ChangeSetting(CCSPlayerController? player, CommandInfo command, string name, string value,
|
|
Func<Settings, Settings> change)
|
|
{
|
|
if (_db == null || !_ready)
|
|
{
|
|
command.ReplyToCommand("[Verify] Not ready: the database hasn't loaded (see the server log).");
|
|
return;
|
|
}
|
|
if (!TryGetCaller(player, command, out var caller))
|
|
return;
|
|
|
|
var commandString = command.GetCommandString;
|
|
var db = _db;
|
|
Task.Run(async () =>
|
|
{
|
|
string? error = null;
|
|
try
|
|
{
|
|
await db.SetSettingAsync(name, value);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
error = ex.Message;
|
|
}
|
|
|
|
Server.NextFrame(() =>
|
|
{
|
|
if (error != null)
|
|
{
|
|
Logger.LogError("Couldn't save gv_settings {Name}: {Error}", name, error);
|
|
Reply(caller, $"{ChatColors.Red}Couldn't save the setting: {error}");
|
|
return;
|
|
}
|
|
LogCommand(caller, commandString);
|
|
_settingsVersion++;
|
|
ApplySettings(change(_settings));
|
|
Reply(caller, DescribeState());
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Targets -----------------------------------------------------------------------------
|
|
|
|
// @all, @t, @ct, @spec, @alive, @dead, @me, #userid, a SteamID (64, STEAM_X:Y:Z or [U:1:N],
|
|
// online or not), or a name: exact first, then partial, refusing ambiguous matches.
|
|
private static List<Target>? ResolveTargets(CCSPlayerController? caller, string arg, out string? error)
|
|
{
|
|
error = null;
|
|
var players = Utilities.GetPlayers()
|
|
.Where(p => p is { IsValid: true, IsBot: false, IsHLTV: false, Connected: PlayerConnectedState.Connected })
|
|
.Where(p => p.AuthorizedSteamID != null)
|
|
.ToList();
|
|
|
|
static List<Target> Online(IEnumerable<CCSPlayerController> matches) =>
|
|
matches.Select(p => new Target(p.AuthorizedSteamID!.SteamId64, p.PlayerName, p.Slot)).ToList();
|
|
|
|
List<Target>? group = arg.ToLowerInvariant() switch
|
|
{
|
|
"@all" => Online(players),
|
|
"@t" => Online(players.Where(p => p.Team == CsTeam.Terrorist)),
|
|
"@ct" => Online(players.Where(p => p.Team == CsTeam.CounterTerrorist)),
|
|
"@spec" => Online(players.Where(p => p.Team == CsTeam.Spectator)),
|
|
"@alive" => Online(players.Where(p => p.PawnIsAlive)),
|
|
"@dead" => Online(players.Where(p => !p.PawnIsAlive)),
|
|
_ => null
|
|
};
|
|
if (group != null)
|
|
{
|
|
if (group.Count == 0)
|
|
error = $"No players match {arg}.";
|
|
return group.Count == 0 ? null : group;
|
|
}
|
|
|
|
if (arg.Equals("@me", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (caller?.AuthorizedSteamID == null)
|
|
{
|
|
error = "@me only works for players.";
|
|
return null;
|
|
}
|
|
return Online([caller]);
|
|
}
|
|
|
|
if (arg.StartsWith('#') && int.TryParse(arg[1..], out var userId))
|
|
{
|
|
var byUserId = players.FirstOrDefault(p => p.UserId == userId);
|
|
if (byUserId == null)
|
|
error = $"No player with userid {userId}.";
|
|
return byUserId == null ? null : Online([byUserId]);
|
|
}
|
|
|
|
if (SteamID.TryParse(arg, out var parsed) && parsed != null && parsed.IsValid())
|
|
{
|
|
var bySteamId = players.FirstOrDefault(p => p.AuthorizedSteamID!.SteamId64 == parsed.SteamId64);
|
|
return bySteamId != null ? Online([bySteamId]) : [new Target(parsed.SteamId64, "offline player", null)];
|
|
}
|
|
|
|
var exact = players.Where(p => p.PlayerName.Equals(arg, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
var matches = exact.Count > 0
|
|
? exact
|
|
: players.Where(p => p.PlayerName.Contains(arg, StringComparison.OrdinalIgnoreCase)).ToList();
|
|
switch (matches.Count)
|
|
{
|
|
case 0:
|
|
error = $"No player matches \"{arg}\".";
|
|
return null;
|
|
case 1:
|
|
return Online(matches);
|
|
default:
|
|
var names = string.Join(", ", matches.Take(5).Select(p => $"{p.PlayerName} (#{p.UserId})"));
|
|
error = $"\"{arg}\" matches {matches.Count} players: {names}. Use #userid.";
|
|
return null;
|
|
}
|
|
}
|
|
}
|