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
}