GroupVerify/GroupVerify.cs
Astra 10e5fe9f3c GroupVerify 0.3.0: check players with the Steam Web API as they join, clan tag for other plugins
Steam rate-limits its community member lists by IP (429), so the whole-list download is gone.
Each player is checked as they join with ISteamUser/GetUserGroupList: a member's 'group' row is
added or refreshed, a non-member's deleted, and when Steam won't say (private profile, bad key,
outage) the database stands. A player is asked about at most once per
min_forced_refresh_seconds; css_verify rechecks its targets, offline SteamIDs too. Changing the
group clears the old group's rows. refresh_minutes is gone.

The Web API key goes in configs/plugins/GroupVerify/GroupVerify.json (SteamWebApiKey); every
other setting stays in the database.

New clan_tag setting and css_gv_clantag <tag|-> (@css/root): the scoreboard tag for verified
players without a rank, applied by CS2-Tags. The API gains ClanTag and OnClanTagChanged, so
shared/GroupVerifyApi changes: needs a server restart.
2026-10-02 18:42:41 +01:00

859 lines
34 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.
// Group membership is checked per player with the Steam Web API when they join, and kept in
// CS2-SimpleAdmin's database, over its connection string, like everything except the API key.
//
// 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, IPluginConfig<GroupVerifyConfig>
{
public override string ModuleName => "GroupVerify";
public override string ModuleVersion => "0.3.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;
public GroupVerifyConfig Config { get; set; } = new();
// Group members as of the database: gv_verified's 'group' rows, reread with every reload.
private HashSet<ulong> _groupRows = [];
// This session's Steam checks, which win over the database until the next restart: SteamID64 ->
// whether they're a member. Players Steam wouldn't answer for aren't in here.
private readonly Dictionary<ulong, bool> _membership = [];
// SteamID64 -> when they were last asked about (UTC), answered or not, so rejoining or
// css_verify doesn't ask more often than min_forced_refresh_seconds.
private readonly Dictionary<ulong, DateTime> _checkedAt = [];
// SteamID64 -> why Steam couldn't say, for css_verify.
private readonly Dictionary<ulong, string> _checkProblem = [];
// Checks in flight, shared by everyone who asks about the same player.
private readonly Dictionary<ulong, Task> _checks = [];
private bool _noKeyWarned;
// 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? _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 void OnConfigParsed(GroupVerifyConfig config)
{
config.SteamWebApiKey = config.SteamWebApiKey.Trim();
Config = config;
}
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();
_dbTimer?.Kill();
_evaluated.Clear();
}
// --- API (game thread) -------------------------------------------------------------------
public bool IsActive => _ready && _settings.Active;
public string ClanTag => _settings.ClanTag;
public VerifyStatus GetStatus(ulong steamId64)
{
if (!IsActive)
return default;
var inGroup = _membership.TryGetValue(steamId64, out var member) ? member : _groupRows.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;
_groupRows = snapshot.GroupRows;
_dbLoadedAt = DateTime.UtcNow;
if (snapshot.Expired > 0)
Logger.LogInformation("Removed {Count} expired gv_verified rows", snapshot.Expired);
ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings);
});
});
}
// Switches to new settings (or just re-evaluates, if they're the same).
private void ApplySettings(Settings next)
{
var old = _settings;
var first = !_ready;
_settings = next;
_ready = true;
var groupChanged = !first && old.GroupId64 != next.GroupId64;
if (groupChanged)
{
// Everything known so far is about the old group.
_groupRows = [];
_membership.Clear();
_checkedAt.Clear();
_checkProblem.Clear();
var db = _db;
if (db != null)
{
Task.Run(async () =>
{
try
{
await db.ClearGroupRowsAsync();
}
catch (Exception ex)
{
Logger.LogError("Couldn't clear the old group's rows from gv_verified: {Error}", ex.Message);
}
});
}
}
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.DbReloadSeconds != next.DbReloadSeconds)
{
_dbTimer?.Kill();
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT);
}
EvaluateAll();
if ((first ? "" : old.EffectiveClanTag) != next.EffectiveClanTag)
{
Logger.LogInformation("Clan tag for verified players: {Tag}", next.EffectiveClanTag.Length > 0 ? next.EffectiveClanTag : "(none)");
foreach (var ex in GroupVerifyBridge.Instance.RaiseClanTagChanged())
Logger.LogError(ex, "An OnClanTagChanged handler threw");
}
// Ask Steam about everyone on the server when there's something new to ask about.
if (next.Active && (first || groupChanged || !old.Active))
CheckAll();
}
private void CheckAll()
{
foreach (var player in Utilities.GetPlayers())
{
if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id)
CheckMembership(id.SteamId64);
}
}
// Asks Steam whether the player is in the group, then records the answer in gv_verified (a
// member's row is added or refreshed, a non-member's removed) and re-evaluates them. When Steam
// won't say, nothing changes and what the database has stands. Skipped while verification is
// off, without an API key, or if they were asked about within min_forced_refresh_seconds.
private Task CheckMembership(ulong steamId)
{
if (_db == null || !IsActive)
return Task.CompletedTask;
if (Config.SteamWebApiKey.Length == 0)
{
if (!_noKeyWarned)
Logger.LogWarning("No SteamWebApiKey in configs/plugins/GroupVerify/GroupVerify.json, so nobody's group membership is checked");
_noKeyWarned = true;
return Task.CompletedTask;
}
if (_checks.TryGetValue(steamId, out var running))
return running;
if (_checkedAt.TryGetValue(steamId, out var at) &&
DateTime.UtcNow - at < TimeSpan.FromSeconds(_settings.MinForcedRefreshSeconds))
return Task.CompletedTask;
var db = _db;
var settings = _settings;
var key = Config.SteamWebApiKey;
var cancel = _unloading.Token;
_checkedAt[steamId] = DateTime.UtcNow;
var task = Task.Run(async () =>
{
var (result, problem) = (Membership.Unknown, (string?)null);
try
{
(result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel);
}
catch (OperationCanceledException) when (cancel.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
// Only the type: messages could quote the request, key and all.
(result, problem) = (Membership.Unknown, $"unexpected {ex.GetType().Name}");
}
if (result == Membership.Unknown)
{
Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem);
}
else
{
try
{
await db.SetGroupMemberAsync(steamId, result == Membership.Member, settings.Now());
}
catch (Exception ex)
{
// The answer still counts until a restart; it just isn't saved.
Logger.LogError("Couldn't save {SteamId}'s group membership to gv_verified: {Error}", steamId, ex.Message);
}
}
if (cancel.IsCancellationRequested)
return;
await Server.NextFrameAsync(() =>
{
_checks.Remove(steamId);
// Dropped if the group changed or verification was turned off while asking.
if (settings.GroupId64 != _settings.GroupId64 || !IsActive)
return;
if (result == Membership.Unknown)
{
_checkProblem[steamId] = problem ?? "no reason given";
return;
}
_checkProblem.Remove(steamId);
_membership[steamId] = result == Membership.Member;
foreach (var player in Utilities.GetPlayers())
{
if (player.AuthorizedSteamID?.SteamId64 == steamId)
Evaluate(player);
}
});
});
_checks[steamId] = task;
return task;
}
// --- 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;
}
// Evaluated at once from what the database knows, then checked with Steam, which may change it.
private void OnClientAuthorized(int playerSlot, SteamID steamId)
{
var player = Utilities.GetPlayerFromSlot(playerSlot);
Evaluate(player);
if (player is { IsValid: true, IsBot: false, IsHLTV: false })
CheckMembership(steamId.SteamId64);
}
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.");
});
});
}
// Rechecks each target with Steam and rereads the database, where those are 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 pending = targets.Select(t => CheckMembership(t.SteamId)).ToList();
if (_dbAttemptAt == null || DateTime.UtcNow - _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)" : "";
var unchecked_ = _checkProblem.TryGetValue(target.SteamId, out var problem)
? $" {ChatColors.Grey}(couldn't check with Steam: {problem}; using the database){ChatColors.Default}"
: "";
lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}{unchecked_}");
}
}
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();
var group = Config.SteamWebApiKey.Length == 0
? "no Steam Web API key (GroupVerify.json), so group membership isn't being checked"
: $"group membership checked with Steam on join; {_groupRows.Count} known members";
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)
{
var tag = _settings.ClanTag.Length > 0 ? $"clan tag \"{_settings.ClanTag}\"" : "no clan tag";
return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}, {tag}.";
}
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 });
}
[ConsoleCommand("css_gv_clantag", "Shows or sets the scoreboard tag verified players without a rank get; - clears it")]
[CommandHelper(usage: "[tag|-]")]
[RequiresPermissions(SettingsPermission)]
public void OnClanTag(CCSPlayerController? player, CommandInfo command)
{
if (command.ArgCount < 2)
{
var current = _settings.ClanTag.Length > 0 ? $"\"{_settings.ClanTag}\"" : "not set";
command.ReplyToCommand($"[Verify] Clan tag for verified players: {current}. {StripColors(DescribeState())}");
return;
}
var tag = command.ArgString.Trim().Trim('"');
if (tag == "-")
tag = "";
if (!Settings.ValidClanTag(tag))
{
command.ReplyToCommand($"[Verify] A clan tag is at most {Settings.ClanTagMaxLength} characters, without quotes, ; or control characters. - clears it.");
return;
}
ChangeSetting(player, command, "clan_tag", tag, s => s with { ClanTag = tag });
}
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;
}
}
}