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:
parent
1f61958099
commit
10e5fe9f3c
11 changed files with 436 additions and 204 deletions
245
GroupVerify.cs
245
GroupVerify.cs
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue