GroupVerify/GroupVerify.cs
Astra 1f61958099 GroupVerify 0.2.0: Steam group / override verification with an API for other plugins
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.
2026-10-02 15:37:47 +01:00

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