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