From 9362448f221a50b55aa83dda3c8d00bfbf8006ed Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Tue, 17 Feb 2026 08:12:07 +1100 Subject: [PATCH] added slapapi --- go.mod | 1 + go.sum | 2 ++ internal/config/config.go | 41 +++++++++++++--------- internal/discord/config.go | 1 + internal/discord/ratelimit.go | 60 ++++++++++++++++---------------- internal/discord/steamid.go | 18 ++++++++++ pkg/slapshotapi/client.go | 37 ++++++++++++++++++++ pkg/slapshotapi/config.go | 23 +++++++++++++ pkg/slapshotapi/enums.go | 35 +++++++++++++++++++ pkg/slapshotapi/ezconf.go | 41 ++++++++++++++++++++++ pkg/slapshotapi/limiting.go | 59 ++++++++++++++++++++++++++++++++ pkg/slapshotapi/queuestatus.go | 62 ++++++++++++++++++++++++++++++++++ pkg/slapshotapi/request.go | 44 ++++++++++++++++++++++++ pkg/slapshotapi/slapid.go | 49 +++++++++++++++++++++++++++ 14 files changed, 427 insertions(+), 46 deletions(-) create mode 100644 internal/discord/steamid.go create mode 100644 pkg/slapshotapi/client.go create mode 100644 pkg/slapshotapi/config.go create mode 100644 pkg/slapshotapi/enums.go create mode 100644 pkg/slapshotapi/ezconf.go create mode 100644 pkg/slapshotapi/limiting.go create mode 100644 pkg/slapshotapi/queuestatus.go create mode 100644 pkg/slapshotapi/request.go create mode 100644 pkg/slapshotapi/slapid.go diff --git a/go.mod b/go.mod index 7db4d2a..b4d5e0a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/uptrace/bun v1.2.16 github.com/uptrace/bun/dialect/pgdialect v1.2.16 github.com/uptrace/bun/driver/pgdriver v1.2.16 + golang.org/x/time v0.14.0 ) require ( diff --git a/go.sum b/go.sum index 16f49fb..dcf3129 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 3cd6094..9643062 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,19 +10,21 @@ import ( "git.haelnorr.com/h/oslstats/internal/discord" "git.haelnorr.com/h/oslstats/internal/rbac" "git.haelnorr.com/h/oslstats/pkg/oauth" + "git.haelnorr.com/h/oslstats/pkg/slapshotapi" "github.com/joho/godotenv" "github.com/pkg/errors" ) type Config struct { - DB *db.Config - HWS *hws.Config - HWSAuth *hwsauth.Config - HLOG *hlog.Config - Discord *discord.Config - OAuth *oauth.Config - RBAC *rbac.Config - Flags *Flags + DB *db.Config + HWS *hws.Config + HWSAuth *hwsauth.Config + HLOG *hlog.Config + Discord *discord.Config + OAuth *oauth.Config + RBAC *rbac.Config + Slapshot *slapshotapi.Config + Flags *Flags } // GetConfig loads the application configuration and returns a pointer to the Config object @@ -42,6 +44,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { discord.NewEZConfIntegration(), oauth.NewEZConfIntegration(), rbac.NewEZConfIntegration(), + slapshotapi.NewEZConfIntegration(), ) if err != nil { return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations") @@ -93,15 +96,21 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { return nil, nil, errors.New("RBAC Config not loaded") } + slapcfg, ok := loader.GetConfig("slapshotapi") + if !ok { + return nil, nil, errors.New("SlapshotAPI Config not loaded") + } + config := &Config{ - DB: dbcfg.(*db.Config), - HWS: hwscfg.(*hws.Config), - HWSAuth: hwsauthcfg.(*hwsauth.Config), - HLOG: hlogcfg.(*hlog.Config), - Discord: discordcfg.(*discord.Config), - OAuth: oauthcfg.(*oauth.Config), - RBAC: rbaccfg.(*rbac.Config), - Flags: flags, + DB: dbcfg.(*db.Config), + HWS: hwscfg.(*hws.Config), + HWSAuth: hwsauthcfg.(*hwsauth.Config), + HLOG: hlogcfg.(*hlog.Config), + Discord: discordcfg.(*discord.Config), + OAuth: oauthcfg.(*oauth.Config), + RBAC: rbaccfg.(*rbac.Config), + Slapshot: slapcfg.(*slapshotapi.Config), + Flags: flags, } return config, loader, nil diff --git a/internal/discord/config.go b/internal/discord/config.go index 11af109..f8b96de 100644 --- a/internal/discord/config.go +++ b/internal/discord/config.go @@ -1,3 +1,4 @@ +// Package discord provides utilities for interacting with the discord API package discord import ( diff --git a/internal/discord/ratelimit.go b/internal/discord/ratelimit.go index 21f3888..6782c1c 100644 --- a/internal/discord/ratelimit.go +++ b/internal/discord/ratelimit.go @@ -19,19 +19,19 @@ type RateLimitState struct { // Do executes an HTTP request with automatic rate limit handling // It will wait if rate limits are about to be exceeded and retry once if a 429 is received -func (c *APIClient) Do(req *http.Request) (*http.Response, error) { +func (api *APIClient) Do(req *http.Request) (*http.Response, error) { if req == nil { return nil, errors.New("request cannot be nil") } // Step 1: Check if we need to wait before making request - bucket := c.getBucketFromRequest(req) - if err := c.waitIfNeeded(bucket); err != nil { + bucket := api.getBucketFromRequest(req) + if err := api.waitIfNeeded(bucket); err != nil { return nil, err } // Step 2: Execute request - resp, err := c.client.Do(req) + resp, err := api.client.Do(req) if err != nil { // Check if it's a network timeout if netErr, ok := err.(net.Error); ok && netErr.Timeout() { @@ -41,17 +41,17 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Step 3: Update rate limit state from response headers - c.updateRateLimit(resp.Header) + api.updateRateLimit(resp.Header) // Step 4: Handle 429 (rate limited) if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() // Close original response - retryAfter := c.parseRetryAfter(resp.Header) + retryAfter := api.parseRetryAfter(resp.Header) // No Retry-After header, can't retry safely if retryAfter == 0 { - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -61,7 +61,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { // Retry-After exceeds 30 second cap if retryAfter > 30*time.Second { - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -74,7 +74,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Wait and retry - c.logger.Warn(). + api.logger.Warn(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -84,7 +84,7 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { time.Sleep(retryAfter) // Retry the request - resp, err = c.client.Do(req) + resp, err = api.client.Do(req) if err != nil { if netErr, ok := err.(net.Error); ok && netErr.Timeout() { return nil, errors.Wrap(err, "retry request timed out") @@ -93,12 +93,12 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { } // Update rate limit again after retry - c.updateRateLimit(resp.Header) + api.updateRateLimit(resp.Header) // If STILL rate limited after retry, return error if resp.StatusCode == http.StatusTooManyRequests { resp.Body.Close() - c.logger.Error(). + api.logger.Error(). Str("bucket", bucket). Str("method", req.Method). Str("path", req.URL.Path). @@ -115,15 +115,15 @@ func (c *APIClient) Do(req *http.Request) (*http.Response, error) { // getBucketFromRequest extracts or generates bucket ID from request // For Discord, the bucket is typically METHOD:path until we get the actual bucket from headers -func (c *APIClient) getBucketFromRequest(req *http.Request) string { +func (api *APIClient) getBucketFromRequest(req *http.Request) string { return req.Method + ":" + req.URL.Path } // waitIfNeeded checks if we need to delay before request to avoid hitting rate limits -func (c *APIClient) waitIfNeeded(bucket string) error { - c.mu.RLock() - state, exists := c.buckets[bucket] - c.mu.RUnlock() +func (api *APIClient) waitIfNeeded(bucket string) error { + api.mu.RLock() + state, exists := api.buckets[bucket] + api.mu.RUnlock() if !exists { return nil // No state yet, proceed @@ -138,7 +138,7 @@ func (c *APIClient) waitIfNeeded(bucket string) error { waitDuration += 100 * time.Millisecond if waitDuration > 0 { - c.logger.Debug(). + api.logger.Debug(). Str("bucket", bucket). Dur("wait_duration", waitDuration). Msg("Proactively waiting for rate limit reset") @@ -150,16 +150,16 @@ func (c *APIClient) waitIfNeeded(bucket string) error { } // updateRateLimit parses response headers and updates bucket state -func (c *APIClient) updateRateLimit(headers http.Header) { +func (api *APIClient) updateRateLimit(headers http.Header) { bucket := headers.Get("X-RateLimit-Bucket") if bucket == "" { return // No bucket info, can't track } // Parse headers - limit := c.parseInt(headers.Get("X-RateLimit-Limit")) - remaining := c.parseInt(headers.Get("X-RateLimit-Remaining")) - resetAfter := c.parseFloat(headers.Get("X-RateLimit-Reset-After")) + limit := api.parseInt(headers.Get("X-RateLimit-Limit")) + remaining := api.parseInt(headers.Get("X-RateLimit-Remaining")) + resetAfter := api.parseFloat(headers.Get("X-RateLimit-Reset-After")) state := &RateLimitState{ Bucket: bucket, @@ -168,12 +168,12 @@ func (c *APIClient) updateRateLimit(headers http.Header) { Reset: time.Now().Add(time.Duration(resetAfter * float64(time.Second))), } - c.mu.Lock() - c.buckets[bucket] = state - c.mu.Unlock() + api.mu.Lock() + api.buckets[bucket] = state + api.mu.Unlock() // Log rate limit state for debugging - c.logger.Debug(). + api.logger.Debug(). Str("bucket", bucket). Int("remaining", remaining). Int("limit", limit). @@ -182,14 +182,14 @@ func (c *APIClient) updateRateLimit(headers http.Header) { } // parseRetryAfter extracts retry delay from Retry-After header -func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { +func (api *APIClient) parseRetryAfter(headers http.Header) time.Duration { retryAfter := headers.Get("Retry-After") if retryAfter == "" { return 0 } // Discord returns seconds as float - seconds := c.parseFloat(retryAfter) + seconds := api.parseFloat(retryAfter) if seconds <= 0 { return 0 } @@ -198,7 +198,7 @@ func (c *APIClient) parseRetryAfter(headers http.Header) time.Duration { } // parseInt parses an integer from a header value, returns 0 on error -func (c *APIClient) parseInt(s string) int { +func (api *APIClient) parseInt(s string) int { if s == "" { return 0 } @@ -207,7 +207,7 @@ func (c *APIClient) parseInt(s string) int { } // parseFloat parses a float from a header value, returns 0 on error -func (c *APIClient) parseFloat(s string) float64 { +func (api *APIClient) parseFloat(s string) float64 { if s == "" { return 0 } diff --git a/internal/discord/steamid.go b/internal/discord/steamid.go new file mode 100644 index 0000000..76f491c --- /dev/null +++ b/internal/discord/steamid.go @@ -0,0 +1,18 @@ +package discord + +import ( + "github.com/pkg/errors" +) + +func (s *OAuthSession) GetSteamID() (string, error) { + connections, err := s.UserConnections() + if err != nil { + return "", errors.Wrap(err, "s.UserConnections") + } + for _, conn := range connections { + if conn.Type == "steam" { + return conn.ID, nil + } + } + return "", errors.New("steam connection not found") +} diff --git a/pkg/slapshotapi/client.go b/pkg/slapshotapi/client.go new file mode 100644 index 0000000..b712845 --- /dev/null +++ b/pkg/slapshotapi/client.go @@ -0,0 +1,37 @@ +package slapshotapi + +import ( + "net/http" + "sync" + + "github.com/pkg/errors" + "golang.org/x/time/rate" +) + +type SlapAPI struct { + client *http.Client + ratelimiter *rate.Limiter + mu sync.Mutex + maxTokens int + key string + env string +} + +func NewSlapAPIClient(cfg *Config) (*SlapAPI, error) { + if cfg == nil { + return nil, errors.New("config cannot be nil") + } + if cfg.Environment != "api" && cfg.Environment != "staging" { + return nil, errors.New("invalid env specified, must be 'api' or 'staging'") + } + rl := rate.NewLimiter(rate.Inf, 10) + client := &SlapAPI{ + client: http.DefaultClient, + ratelimiter: rl, + mu: sync.Mutex{}, + maxTokens: 10, + key: cfg.Key, + env: cfg.Environment, + } + return client, nil +} diff --git a/pkg/slapshotapi/config.go b/pkg/slapshotapi/config.go new file mode 100644 index 0000000..0496f54 --- /dev/null +++ b/pkg/slapshotapi/config.go @@ -0,0 +1,23 @@ +// Package slapshotapi provides utilities for interacting with the slapshot public API +package slapshotapi + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +type Config struct { + Environment string // ENV SLAPSHOT_ENVIRONMENT: API environment to connect to (default: staging) + Key string // ENV SLAPSHOT_API_KEY: API Key for authorisation with the API (required) +} + +func ConfigFromEnv() (any, error) { + cfg := &Config{ + Environment: env.String("SLAPSHOT_ENVIRONMENT", "staging"), + Key: env.String("SLAPSHOT_API_KEY", ""), + } + if cfg.Key == "" { + return nil, errors.New("Envar not set: SLAPSHOT_API_KEY") + } + return cfg, nil +} diff --git a/pkg/slapshotapi/enums.go b/pkg/slapshotapi/enums.go new file mode 100644 index 0000000..04e2c05 --- /dev/null +++ b/pkg/slapshotapi/enums.go @@ -0,0 +1,35 @@ +package slapshotapi + +const ( + RegionEUWest = "eu-west" + RegionNAEast = "na-east" + RegionNACentral = "na-central" + RegionNAWest = "na-west" + RegionOCEEast = "oce-east" + + ArenaSlapstadium = "Slapstadium" + ArenaSlapville = "Slapville" + ArenaSlapstadiumMini = "Slapstadium_mini" + ArenaTableHockey = "Table_Hockey" + ArenaColosseum = "Colosseum" + ArenaSlapvilleJumbo = "Slapville_Jumbo" + ArenaSlapstation = "Slapstation" + ArenaSlapstadiumXL = "Slapstadium_XL" + ArenaIsland = "Island" + ArenaObstacles = "Obstacles" + ArenaObstaclesXL = "Obstacles_XL" + + EndReasonEndOfReg = "EndOfRegulation" + EndReasonOvertime = "Overtime" + EndReasonHomeTeamLeft = "HomeTeamLeft" + EndReasonAwayTeamLeft = "AwayTeamLeft" + EndReasonMercy = "MercyRule" + EndReasonTie = "Tie" + EndReasonForfeit = "Forfeit" + EndReasonCancelled = "Cancelled" + EndReasonUnknown = "Unknown" + + GameModeHockey = "hockey" + GameModeDodgePuck = "dodgepuck" + GameModeTag = "tag" +) diff --git a/pkg/slapshotapi/ezconf.go b/pkg/slapshotapi/ezconf.go new file mode 100644 index 0000000..b8eeb70 --- /dev/null +++ b/pkg/slapshotapi/ezconf.go @@ -0,0 +1,41 @@ +package slapshotapi + +import ( + "runtime" + "strings" +) + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct { + configFunc func() (any, error) + name string +} + +// PackagePath returns the path to the config package for source parsing +func (e EZConfIntegration) PackagePath() string { + _, filename, _, _ := runtime.Caller(0) + // Return directory of this file + return filename[:len(filename)-len("/ezconf.go")] +} + +// ConfigFunc returns the ConfigFromEnv function for ezconf +func (e EZConfIntegration) ConfigFunc() func() (any, error) { + return func() (any, error) { + return e.configFunc() + } +} + +// Name returns the name to use when registering with ezconf +func (e EZConfIntegration) Name() string { + return strings.ToLower(e.name) +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return e.name +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{name: "SlapshotAPI", configFunc: ConfigFromEnv} +} diff --git a/pkg/slapshotapi/limiting.go b/pkg/slapshotapi/limiting.go new file mode 100644 index 0000000..d4a67db --- /dev/null +++ b/pkg/slapshotapi/limiting.go @@ -0,0 +1,59 @@ +package slapshotapi + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/pkg/errors" + "golang.org/x/time/rate" +) + +func (c *SlapAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) { + for { + err := c.ratelimiter.Wait(ctx) + if err != nil { + return nil, errors.Wrap(err, "c.ratelimiter.Wait") + } + resp, err := c.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "c.client.Do") + } + if resp.StatusCode == http.StatusTooManyRequests { + resetAfter := 30 * time.Second + err := resp.Body.Close() + if err != nil { + return nil, errors.Wrap(err, "resp.Body.Close") + } + if resetAfter > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(resetAfter): + continue + } + } + } + c.updateLimiterFromHeaders(resp.Header) + return resp, nil + } +} + +func (c *SlapAPI) updateLimiterFromHeaders(h http.Header) { + c.mu.Lock() + defer c.mu.Unlock() + + limit, err1 := strconv.Atoi(h.Get("RateLimit-Limit")) + window, err2 := strconv.Atoi(h.Get("RateLimit-Window")) + + if err1 != nil || err2 != nil || limit <= 0 || window <= 0 { + return + } + + if limit != c.maxTokens || time.Duration(window) != time.Duration(float64(window)/float64(limit))*time.Second { + c.maxTokens = limit + c.ratelimiter.SetBurst(limit) + c.ratelimiter.SetLimit(rate.Every(time.Duration(window) / time.Duration(limit))) + } +} diff --git a/pkg/slapshotapi/queuestatus.go b/pkg/slapshotapi/queuestatus.go new file mode 100644 index 0000000..df579a1 --- /dev/null +++ b/pkg/slapshotapi/queuestatus.go @@ -0,0 +1,62 @@ +package slapshotapi + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +type endpointMatchmaking struct { + regions []string +} + +func getEndpointMatchmaking(regions []string) *endpointMatchmaking { + return &endpointMatchmaking{ + regions: regions, + } +} + +func (ep *endpointMatchmaking) path() string { + path := "/api/public/matchmaking%s" + filters := "" + if len(ep.regions) > 0 { + filters = "?regions=" + for i, region := range ep.regions { + filters = filters + region + if i+1 != len(ep.regions) { + filters = filters + "," + } + } + } + return fmt.Sprintf(path, filters) +} + +func (ep *endpointMatchmaking) method() string { + return "GET" +} + +type matchmakingresp struct { + Playlists PubsQueue `json:"playlists"` +} + +type PubsQueue struct { + InQueue uint16 `json:"in_queue"` + InMatch uint16 `json:"in_match"` +} + +// GetQueueStatus gets the number of players in public matchmaking +func (c *SlapAPI) GetQueueStatus( + ctx context.Context, + regions []string, +) (*PubsQueue, error) { + endpoint := getEndpointMatchmaking(regions) + data, err := c.request(ctx, endpoint) + if err != nil { + return nil, errors.Wrap(err, "slapapiReq") + } + resp := matchmakingresp{} + json.Unmarshal(data, &resp) + return &resp.Playlists, nil +} diff --git a/pkg/slapshotapi/request.go b/pkg/slapshotapi/request.go new file mode 100644 index 0000000..5e504cd --- /dev/null +++ b/pkg/slapshotapi/request.go @@ -0,0 +1,44 @@ +package slapshotapi + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/pkg/errors" +) + +type endpoint interface { + path() string + method() string +} + +func (c *SlapAPI) request( + ctx context.Context, + ep endpoint, +) ([]byte, error) { + baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path()) + req, err := http.NewRequest(ep.method(), baseurl, nil) + if err != nil { + return nil, errors.Wrap(err, "http.NewRequest") + } + req.Header.Add("accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key)) + res, err := c.do(ctx, req) + if err != nil { + return nil, errors.Wrap(err, "http.DefaultClient.Do") + } + if res.StatusCode != 200 { + return nil, errors.New(fmt.Sprintf("Error making request: %v", res.StatusCode)) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, errors.Wrap(err, "io.ReadAll") + } + err = res.Body.Close() + if err != nil { + return nil, errors.Wrap(err, "resp.Body.Close") + } + return body, nil +} diff --git a/pkg/slapshotapi/slapid.go b/pkg/slapshotapi/slapid.go new file mode 100644 index 0000000..1a45d2b --- /dev/null +++ b/pkg/slapshotapi/slapid.go @@ -0,0 +1,49 @@ +package slapshotapi + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pkg/errors" +) + +type endpointSteamID struct { + steamID string +} + +func getEndpointSteamID(steamID string) *endpointSteamID { + return &endpointSteamID{ + steamID: steamID, + } +} + +func (ep *endpointSteamID) path() string { + return fmt.Sprintf("/api/public/players/steam/%s", ep.steamID) +} + +func (ep *endpointSteamID) method() string { + return "GET" +} + +type idresp struct { + ID uint32 `json:"id"` +} + +// GetSlapID returns the slapshot ID of the steam user +func (c *SlapAPI) GetSlapID( + ctx context.Context, + steamid string, +) (uint32, error) { + endpoint := getEndpointSteamID(steamid) + data, err := c.request(ctx, endpoint) + if err != nil { + return 0, errors.Wrap(err, "slapapiReq") + } + resp := idresp{} + err = json.Unmarshal(data, &resp) + if err != nil { + return 0, errors.Wrap(err, "json.Unmarshal") + } + return resp.ID, nil +}