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.
This commit is contained in:
Astra 2026-10-02 18:42:41 +01:00
parent 1f61958099
commit 10e5fe9f3c
11 changed files with 436 additions and 204 deletions

View file

@ -17,14 +17,15 @@ 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.
// 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
public class GroupVerify : BasePlugin, IGroupVerifyBackend, IPluginConfig<GroupVerifyConfig>
{
public override string ModuleName => "GroupVerify";
public override string ModuleVersion => "0.2.0";
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";
@ -46,12 +47,21 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
// 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
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 = [];
@ -63,7 +73,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
// 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.
@ -71,6 +80,12 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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);
@ -142,7 +157,6 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
{
GroupVerifyBridge.Instance.Detach(this);
_unloading.Cancel();
_groupTimer?.Kill();
_dbTimer?.Kill();
_evaluated.Clear();
}
@ -151,11 +165,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
public bool IsActive => _ready && _settings.Active;
public string ClanTag => _settings.ClanTag;
public VerifyStatus GetStatus(ulong steamId64)
{
if (!IsActive)
return default;
var inGroup = _groupMembers.Contains(steamId64);
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.
@ -196,31 +212,46 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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, snapshot.GroupRows);
ApplySettings(version == _settingsVersion ? snapshot.Settings : _settings);
});
});
}
// 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)
// 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 = old.GroupId64 != next.GroupId64;
if (first || groupChanged)
var groupChanged = !first && old.GroupId64 != next.GroupId64;
if (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;
// 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)
@ -232,69 +263,92 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
next.Enabled ? "no Steam group set, css_gv_group" : "disabled, css_gv_enable");
}
if (first || old.RefreshMinutes != next.RefreshMinutes || old.DbReloadSeconds != next.DbReloadSeconds)
RestartTimers();
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))
RefreshGroup();
CheckAll();
}
private void RestartTimers()
private void CheckAll()
{
_groupTimer?.Kill();
_dbTimer?.Kill();
_groupTimer = AddTimer(_settings.RefreshMinutes * 60f, () => RefreshGroup(), TimerFlags.REPEAT);
_dbTimer = AddTimer(_settings.DbReloadSeconds, () => ReloadDatabase(), TimerFlags.REPEAT);
foreach (var player in Utilities.GetPlayers())
{
if (player is { IsValid: true, IsBot: false, IsHLTV: false } && player.AuthorizedSteamID is { } id)
CheckMembership(id.SteamId64);
}
}
// 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()
// 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 (_groupRefresh != null)
return _groupRefresh;
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 groupId = settings.GroupId64;
var key = Config.SteamWebApiKey;
var cancel = _unloading.Token;
_groupAttemptAt = DateTime.UtcNow;
return _groupRefresh = Task.Run(async () =>
_checkedAt[steamId] = DateTime.UtcNow;
var task = Task.Run(async () =>
{
HashSet<ulong>? members = null;
var (result, problem) = (Membership.Unknown, (string?)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;
}
(result, problem) = await SteamApi.CheckAsync(key, steamId, settings.GroupId64, cancel);
}
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)
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 (members != null)
if (result == Membership.Unknown)
{
Logger.LogWarning("Couldn't check {SteamId} with Steam, keeping what the database has: {Problem}", steamId, problem);
}
else
{
try
{
await db.SaveGroupAsync(members, settings.Now());
await db.SetGroupMemberAsync(steamId, result == Membership.Member, 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);
// 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);
}
}
@ -302,15 +356,26 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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)
_checks.Remove(steamId);
// Dropped if the group changed or verification was turned off while asking.
if (settings.GroupId64 != _settings.GroupId64 || !IsActive)
return;
_groupMembers = members;
_groupFetchedAt = DateTime.UtcNow;
EvaluateAll();
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) ----------------------------------------------------------
@ -355,9 +420,13 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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)
{
Evaluate(Utilities.GetPlayerFromSlot(playerSlot));
var player = Utilities.GetPlayerFromSlot(playerSlot);
Evaluate(player);
if (player is { IsValid: true, IsBot: false, IsHLTV: false })
CheckMembership(steamId.SteamId64);
}
private void OnClientDisconnect(int playerSlot)
@ -442,18 +511,15 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
});
}
// Brings the group list and database up to date if they're older than
// 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 now = DateTime.UtcNow;
var pending = new List<Task>();
if (_groupAttemptAt == null || now - _groupAttemptAt > maxAge)
pending.Add(RefreshGroup());
if (_dbAttemptAt == null || now - _dbAttemptAt > maxAge)
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))
@ -493,7 +559,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
{
var name = online?.PlayerName ?? target.Name;
var where = online == null ? " (offline)" : "";
lines.Add($"{ChatColors.Gold}{name}{ChatColors.Default} [{target.SteamId}]{where}: {Describe(status)}");
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_}");
}
}
@ -531,11 +600,9 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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 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}.";
@ -558,7 +625,10 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
private string DescribeState()
{
if (_settings.Active)
return $"{ChatColors.Green}On{ChatColors.Default}, Steam group {_settings.GroupId64}.";
{
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}";
@ -649,6 +719,29 @@ public class GroupVerify : BasePlugin, IGroupVerifyBackend
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)
{