GroupVerify/SteamApi.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

97 lines
4 KiB
C#

using System.Net;
using System.Text.Json;
namespace GroupVerify;
// What Steam said about one player and our group.
internal enum Membership
{
Member,
NotMember,
Unknown // Steam wouldn't say: private profile, bad key, rate limit, an outage
}
// Asks the Steam Web API (ISteamUser/GetUserGroupList) which groups a player is in. Runs off the
// game thread. Needs a Web API key, from GroupVerify.json.
internal static class SteamApi
{
private static readonly HttpClient Http = CreateClient();
private static HttpClient CreateClient()
{
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
client.DefaultRequestHeaders.UserAgent.ParseAdd("GroupVerify/1.0 (CounterStrikeSharp)");
return client;
}
// Whether the player is in the group. Problem says why when the answer is Unknown. Never
// includes the key: exception messages and the URL stay out of what's returned.
public static async Task<(Membership Result, string? Problem)> CheckAsync(string apiKey, ulong steamId64,
ulong groupId64, CancellationToken cancel)
{
var url = "https://api.steampowered.com/ISteamUser/GetUserGroupList/v1/" +
$"?key={Uri.EscapeDataString(apiKey)}&steamid={steamId64}";
HttpResponseMessage response;
try
{
response = await Http.GetAsync(url, cancel);
}
catch (HttpRequestException ex)
{
return (Membership.Unknown, $"couldn't reach Steam ({ex.HttpRequestError})");
}
catch (TaskCanceledException) when (!cancel.IsCancellationRequested)
{
return (Membership.Unknown, "Steam didn't answer in time");
}
using (response)
{
switch (response.StatusCode)
{
case HttpStatusCode.OK:
break;
case HttpStatusCode.Unauthorized:
case HttpStatusCode.Forbidden:
return (Membership.Unknown, $"Steam refused the request ({(int)response.StatusCode}): a bad API key, or a private profile");
case HttpStatusCode.TooManyRequests:
return (Membership.Unknown, "Steam is rate-limiting the API key (429)");
default:
return (Membership.Unknown, $"Steam answered {(int)response.StatusCode}");
}
JsonElement root;
try
{
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancel));
root = doc.RootElement.GetProperty("response").Clone();
}
catch (Exception ex) when (ex is JsonException or KeyNotFoundException or InvalidOperationException)
{
return (Membership.Unknown, "Steam's answer wasn't the expected JSON");
}
// A private profile (or group list) comes back as success: false.
if (!root.TryGetProperty("success", out var success) || success.ValueKind != JsonValueKind.True)
{
var error = root.TryGetProperty("error", out var e) ? e.ToString() : "no reason given";
return (Membership.Unknown, $"Steam wouldn't list their groups ({error}); their profile may be private");
}
// gid is the group's 32-bit account ID, the low half of its 64-bit ID. Accept the full
// 64-bit ID too, in case Steam ever sends that.
var account = groupId64 & 0xFFFFFFFF;
if (root.TryGetProperty("groups", out var groups) && groups.ValueKind == JsonValueKind.Array)
{
foreach (var group in groups.EnumerateArray())
{
if (!group.TryGetProperty("gid", out var gid))
continue;
if (ulong.TryParse(gid.ToString(), out var id) && (id == account || id == groupId64))
return (Membership.Member, null);
}
}
return (Membership.NotMember, null);
}
}
}