package steamweb import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "sort" "strings" ) const ( GetPlayerBansURL = "/ISteamUser/GetPlayerBans/v1?key=%s&steamids=%s" GetServerListURL = "/IGameServersService/GetServerList/v1?key=%s&limit=%d&filter=%s" ) var ( ErrWrongStatusCode = errors.New("wrong status code") ErrEmptyResponse = errors.New("empty response") ) // Client is http client for getting requests to ISteamUser api. type Client struct { config *Config http *http.Client } // NewClient creates and returns a new Client instance initialized with the provided configuration. func NewClient(cfg *Config) *Client { cfg.SetDefaults() return &Client{ config: cfg, http: &http.Client{ Timeout: cfg.Timeout, Transport: &http.Transport{ DialContext: (&net.Dialer{ Timeout: cfg.Transport.Dialer.Timeout, Deadline: cfg.Transport.Dialer.Deadline, FallbackDelay: cfg.Transport.Dialer.FallbackDelay, KeepAlive: cfg.Transport.Dialer.KeepAlive, }).DialContext, TLSHandshakeTimeout: cfg.Transport.TLSHandshakeTimeout, }, }, } } // GetPlayerBans returns Community, VAC, and Economy ban statuses for given players. // Example URL: http://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key=XXXXXXXXXXXXXXXXX&steamids=XXXXXXXX,YYYYY func (c *Client) GetPlayerBans(steamIDs ...string) ([]PlayerBans, error) { response := GetPlayerBansResponse{} // Return empty ban history with disabled client. if c.config.Disabled { return response.Players, nil } uri := c.config.URL + fmt.Sprintf(GetPlayerBansURL, c.config.Key, strings.Join(steamIDs, ",")) body, err := c.sendRequest(context.Background(), http.MethodGet, uri, http.NoBody) if err != nil { return nil, err } if err := json.Unmarshal(body, &response); err != nil { return nil, err } return response.Players, nil } // GetServerList returns Steam servers from filter query. // Example URL: http://api.steampowered.com/IGameServersService/GetServerList/v1/?key=XXXXXXXXXXXXXXXXX&limit=X&filter=F func (c *Client) GetServerList(filter *GetServerListFilter) ([]Server, error) { response := GetServerListResponse{} // Return empty servers list with disabled client. if c.config.Disabled { return response.Response.Servers, nil } limit := filter.Limit if limit == 0 { limit = DefaultLimit } uri := c.config.URL + fmt.Sprintf(GetServerListURL, c.config.Key, limit, filter.String()) body, err := c.sendRequest(context.Background(), http.MethodGet, uri, http.NoBody) if err != nil { return nil, err } if err := json.Unmarshal(body, &response); err != nil { return nil, err } return c.filterServers(response.Response.Servers, filter), nil } func (c *Client) sendRequest(ctx context.Context, method, uri string, body io.Reader) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, method, uri, body) if err != nil { return nil, err } res, err := c.http.Do(req) if err != nil { return nil, err } if res != nil { defer res.Body.Close() } else { return nil, ErrEmptyResponse } if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("%w: %d %s", ErrWrongStatusCode, res.StatusCode, res.Status) } resBody, err := io.ReadAll(res.Body) if err != nil { return nil, err } return resBody, nil } func (c *Client) filterServers(servers []Server, filter *GetServerListFilter) []Server { if filter.NoHidden || filter.NoDefaultServers { removeAddrs := make(map[string]bool) for i := range servers { server := &servers[i] if filter.NoHidden && strings.Contains(server.GameType, "hidden") { removeAddrs[server.Addr] = true continue } if filter.NoDefaultServers { for _, name := range c.config.DefaultServerNames { if server.Name == name { removeAddrs[server.Addr] = true continue } } } } servers = c.removeFilteredServers(servers, removeAddrs) } sort.Slice(servers, func(i, j int) bool { return servers[i].Players > servers[j].Players }) return servers } func (c *Client) removeFilteredServers(servers []Server, addrs map[string]bool) []Server { result := make([]Server, 0, len(servers)) for i := range servers { if _, ok := addrs[servers[i].Addr]; !ok { result = append(result, servers[i]) } } return result }
package steamweb import ( "errors" "fmt" "time" ) var ErrConfigUndefinedParam = errors.New("config param is not defined") const ( DefaultSteamURL = "https://api.steampowered.com" DefaultTimeout = 10 * time.Second DefaultTLSHandshakeTimeout = 5 * time.Second DefaultDialerTimeout = 5 * time.Second DefaultLimit = 50000 ) type ( Config struct { Disabled bool `json:"disabled" yaml:"disabled"` // Key is access api key for Steam requests. Key string `json:"key" yaml:"key"` // URL is a Steam Web API url string. URL string `json:"url" yaml:"url"` // Timeout specifies a time limit for requests made by this // Client. The timeout includes connection time, any // redirects, and reading the response body. The timer remains // running after Get, Head, Post, or Do return and will // interrupt reading of the Response.Body. // // The default is 10 seconds. Timeout time.Duration `json:"timeout" yaml:"timeout"` // Transport is a configuration settings for the implementation // of RoundTripper that supports Openapi, HTTPS, and Openapi proxies. Transport struct { // A Dialer contains options for connecting to an address. Dialer Dialer `json:"dialer" yaml:"dialer"` // TLSHandshakeTimeout specifies the maximum amount of time waiting to // wait for a TLS handshake. Zero means no timeout. TLSHandshakeTimeout time.Duration `json:"tls_handshake_timeout" yaml:"tls_handshake_timeout"` } `json:"transport" yaml:"transport"` Limit int `json:"limit" yaml:"limit"` DefaultServerNames []string `json:"default_server_names" yaml:"default_server_names"` } Dialer struct { // Timeout is the maximum amount of time a dial will wait for // a connect to complete. If Deadline is also set, it may fail // earlier. // // The default is 5 seconds. // // When using TCP and dialing a host name with multiple IP // addresses, the timeout may be divided between them. // // With or without a timeout, the operating system may impose // its own earlier timeout. For instance, TCP timeouts are // often around 3 minutes. Timeout time.Duration `json:"timeout" yaml:"timeout"` // Deadline is the absolute point in time after which dials // will fail. If Timeout is set, it may fail earlier. // Zero means no deadline, or dependent on the operating system // as with the Timeout option. Deadline time.Time `json:"deadline" yaml:"deadline"` // FallbackDelay specifies the length of time to wait before // spawning a fallback connection, when DualStack is enabled. // If zero, a default delay of 300ms is used. FallbackDelay time.Duration `json:"fallback_delay" yaml:"fallback_delay"` // KeepAlive specifies the keep-alive period for an active // network connection. // If zero, keep-alives are not enabled. Network protocols // that do not support keep-alives ignore this field. KeepAlive time.Duration `json:"keep_alive" yaml:"keep_alive"` } ) func (cfg *Config) Validate() error { // Do not validate config for disabled client. if cfg.Disabled { return nil } if cfg.Key == "" { return fmt.Errorf("%w: %s", ErrConfigUndefinedParam, "key") } if cfg.URL == "" { return fmt.Errorf("%w: %s", ErrConfigUndefinedParam, "url") } return nil } func (cfg *Config) SetDefaults() { if cfg.URL == "" { cfg.URL = DefaultSteamURL } if cfg.Timeout == 0 { cfg.Timeout = DefaultTimeout } if cfg.Transport.TLSHandshakeTimeout == 0 { cfg.Transport.TLSHandshakeTimeout = DefaultTLSHandshakeTimeout } if cfg.Transport.Dialer.Timeout == 0 { cfg.Transport.Dialer.Timeout = DefaultDialerTimeout } if cfg.Limit == 0 { cfg.Limit = DefaultLimit } }
package steamweb import ( "errors" "fmt" "strconv" "strings" ) var ErrRequiredParam = errors.New("param is required") // GetServerListFilter represents the filter parameters used when querying game servers // from the Steam server browser. Each field corresponds to a specific filter that can // be applied to narrow down the server list results. // The JSON tags match the actual query parameters used in the Steam server browser protocol. // // See: https://developer.valvesoftware.com/wiki/Master_Server_Query_Protocol. type GetServerListFilter struct { // NotOr is a special filter, specifies that servers matching any of the following [x] // conditions should not be returned. // Usage: \nor\[x]. NotOr []string `json:"nor,omitempty"` // NotAnd is a special filter, specifies that servers matching all of the following [x] // conditions should not be returned. // Usage: \nand\[x]. NotAnd []string `json:"nand,omitempty"` // Dedicated is a filter for servers running dedicated. // Usage: \dedicated\1. Dedicated bool `json:"dedicated,omitempty"` // Secure is a filter for servers using anti-cheat technology (VAC, but potentially others as well). // Usage: \secure\1. Secure bool `json:"secure,omitempty"` // GameDir is a filter for servers running the specified modification (ex. cstrike). // Usage: \gamedir\[mod]. GameDir string `json:"gamedir,omitempty"` // Map is a filter for servers running the specified map (ex. cs_italy). // Usage: \map\[map]. Map string `json:"map,omitempty"` // Linux is a filter for servers running on a Linux platform. // Usage: \linux\1. Linux bool `json:"linux,omitempty"` // NoPassword is a filer for servers that are not password protected. // Usage: \password\0. NoPassword bool `json:"password,omitempty"` // NotEmpty is a filter for servers that are not empty. // Usage: \empty\1. NotEmpty bool `json:"empty,omitempty"` // NotFull is a filter for servers that are not full. // Usage: \full\1. NotFull bool `json:"full,omitempty"` // Proxy is a filter for servers that are spectator proxies. // Usage: \proxy\1. Proxy bool `json:"proxy,omitempty"` // AppID is a filer for servers that are running game [appid]. // Usage: \appid\[appid]. AppID int `json:"appid,omitempty"` // NotAppID is a filter for servers that are NOT running game [appid]. // This was introduced to block Left 4 Dead games from the Steam Server Browser. // Usage: \napp\[appid]. NotAppID int `json:"napp,omitempty"` // NoPlayers is a filer for servers that are empty. // Usage: \noplayers\1. NoPlayers bool `json:"noplayers,omitempty"` // Whitelisted is a filter for servers that are whitelisted. // Usage: \white\1. Whitelisted bool `json:"white,omitempty"` // GameTypeTags is a filer for servers with all of the given tag(s) in sv_tags. // Usage: \gametype\[tag,…]. GameTypeTags []string `json:"gametype,omitempty"` // GameDataTags is a filer for servers with all of the given tag(s) in their ‘hidden’ tags (L4D2). // Usage: \gamedata\[tag,…]. GameDataTags []string `json:"gamedata,omitempty"` // GameDataOrTags is a filer for servers with any of the given tag(s) in their ‘hidden’ tags (L4D2). // Usage: \gamedataor\[tag,…] GameDataOrTags []string `json:"gamedataor,omitempty"` // NameMatch is a filer for servers with their hostname matching [hostname] (can use * as a wildcard). // Usage: \name_match\[hostname]. NameMatch string `json:"name_match,omitempty"` // VersionMatch is a filer for servers running version [version] (can use * as a wildcard). // Usage: \version_match\[version]. VersionMatch string `json:"version_match,omitempty"` // CollapseAddrHash is a filer for that returns only one server for each unique IP address matched. // Usage: \collapse_addr_hash\1. CollapseAddrHash bool `json:"collapse_addr_hash,omitempty"` // GameAddr is a filer for that returns only servers on the specified IP address (port supported and optional). // Usage: \gameaddr\[ip]. GameAddr string `json:"gameaddr,omitempty"` // Custom filters - not provided by Steam. // NoHidden is a custom filer for servers that are not hidden. NoHidden bool `json:"nohidden,omitempty"` // NoDefaultServers is a custom filer for servers that has a name that differs from the default name. NoDefaultServers bool `json:"no_default_servers,omitempty"` // Limit limits response. Limit int `json:"limit,omitempty"` } // String converts fields to url part with params. func (g *GetServerListFilter) String() string { //nolint:funlen,cyclop // I don't care query := `\appid\` + strconv.Itoa(g.AppID) if g.Dedicated { query += `\dedicated\1` } if g.Secure { query += `\secure\1` } if g.GameDir != "" { query += `\gamedir\` + g.GameDir } // Not working in PZ. if g.Map != "" { query += `\map\` + g.Map } if g.Linux { query += `\linux\1` } // Not working in PZ. if g.NoPassword { query += `\password\0` } if g.NotEmpty { query += `\empty\1` } if g.NotFull { query += `\full\1` } // Not working in PZ. if g.Proxy { query += `\proxy\1` } // Not working in PZ. if g.NotAppID != 0 { query += `\napp\` + strconv.Itoa(g.NotAppID) } if g.NoPlayers { query += `\noplayers\1` } // Not working in PZ. if g.Whitelisted { query += `\white\1` } if len(g.GameTypeTags) != 0 { query += `\gametype\` + strings.Join(g.GameTypeTags, `;`) } // Not working in PZ. if len(g.GameDataTags) != 0 { query += `\gamedata\` + strings.Join(g.GameDataTags, `,`) } // Not working in PZ. if len(g.GameDataOrTags) != 0 { query += `\gamedataor\` + strings.Join(g.GameDataOrTags, `,`) } if g.NameMatch != "" { query += `\name_match\*` + g.NameMatch + `*` } return query } func (g *GetServerListFilter) Validate() error { if g.AppID == 0 { return fmt.Errorf("%w: %s", ErrRequiredParam, "appid") } return nil }