Files
csgowtfd/utils/utils.go

682 lines
17 KiB
Go

package utils
import (
"context"
"csgowtfd/ent"
"csgowtfd/ent/match"
"csgowtfd/ent/player"
"csgowtfd/ent/stats"
"csgowtfd/ent/weaponstats"
"encoding/json"
"entgo.io/ent/dialect/sql"
"errors"
"fmt"
"github.com/an0nfunc/go-steamapi"
log "github.com/sirupsen/logrus"
"go.uber.org/ratelimit"
"io"
"io/ioutil"
"net/http"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
type Conf struct {
Logging struct {
Level string
}
Db struct {
Driver string
ConnectTo string `yaml:"connect_to"`
}
Parser struct {
Worker int
}
Steam struct {
Username string
Password string
APIKey string `yaml:"api_key"`
RatePerSecond int `yaml:"rate_per_sec"`
Sentry string
LoginKey string `yaml:"login_key"`
ServerList string `yaml:"server_list"`
}
Redis struct {
Address string
Password string
}
Httpd struct {
CORSAllowDomains []string `yaml:"cors_allow_domains"`
Listen []struct {
Socket string
Host string
Port int
}
}
}
type CommunityXML struct {
SteamID64 uint64 `xml:"steamID64"`
AvatarURL string `xml:"avatarFull"`
VacBanned bool `xml:"vacBanned"`
ProfileName string `xml:"steamID"`
Error string `xml:"error"`
VanityURL string `xml:"customURL"`
}
type shareCodeResponse struct {
Result struct {
Code string `json:"nextcode"`
} `json:"result"`
}
type MatchStats struct {
Win int `json:"win,omitempty"`
Tie int `json:"tie,omitempty"`
Loss int `json:"loss,omitempty"`
}
type MultiKills struct {
Duo uint `json:"duo,omitempty"`
Triple uint `json:"triple,omitempty"`
Quad uint `json:"quad,omitempty"`
Pent uint `json:"pent,omitempty"`
}
type Rank struct {
Old int `json:"old,omitempty"`
New int `json:"new,omitempty"`
}
type Damage struct {
Enemy uint `json:"enemy,omitempty"`
Team uint `json:"team,omitempty"`
}
type SelfTeamEnemy struct {
Self interface{} `json:"self,omitempty"`
Team interface{} `json:"team,omitempty"`
Enemy interface{} `json:"enemy,omitempty"`
}
type Flash struct {
Duration *SelfTeamEnemy `json:"duration,omitempty"`
Total *SelfTeamEnemy `json:"total,omitempty"`
}
type StatsResponse struct {
TeamID int `json:"team_id"`
Kills int `json:"kills"`
Deaths int `json:"deaths"`
Assists int `json:"assists"`
Headshot int `json:"headshot"`
MVP uint `json:"mvp"`
Score int `json:"score"`
Player interface{} `json:"player,omitempty"`
Rank *Rank `json:"rank,omitempty"`
MultiKills *MultiKills `json:"multi_kills,omitempty"`
Dmg *Damage `json:"dmg,omitempty"`
Flash *Flash `json:"flash,omitempty"`
Crosshair string `json:"crosshair,omitempty"`
Color string `json:"color,omitempty"`
KAST int `json:"kast,omitempty"`
}
type PlayerResponse struct {
SteamID64 uint64 `json:"steamid64,string"`
Name string `json:"name,omitempty"`
Avatar string `json:"avatar,omitempty"`
VAC bool `json:"vac"`
VACDate int64 `json:"vac_date,omitempty"`
GameBan bool `json:"game_ban"`
GameBanDate int64 `json:"game_ban_date,omitempty"`
Tracked bool `json:"tracked"`
VanityURL string `json:"vanity_url,omitempty"`
MatchStats *MatchStats `json:"match_stats,omitempty"`
Matches []*MatchResponse `json:"matches,omitempty"`
}
type MateResponse struct {
Player *PlayerResponse `json:"player"`
WinRate float32 `json:"win_rate,omitempty"`
TieRate float32 `json:"tie_rate,omitempty"`
Total int `json:"total,omitempty"`
}
type WeaponDmg struct {
Eq int `json:"eq"`
Dmg uint `json:"dmg"`
}
type MetaStatsResponse struct {
Player *PlayerResponse `json:"player"`
BestMates []*MateResponse `json:"best_mates,omitempty"`
MostMates []*MateResponse `json:"most_mates,omitempty"`
EqMap map[int]string `json:"eq_map,omitempty"`
WeaponDmg []*WeaponDmg `json:"weapon_dmg,omitempty"`
WinMaps map[string]float32 `json:"win_maps,omitempty"`
TieMaps map[string]float32 `json:"tie_maps,omitempty"`
}
type MatchResponse struct {
MatchId uint64 `json:"match_id,string"`
ShareCode string `json:"share_code,omitempty"`
Map string `json:"map"`
Date int64 `json:"date"`
Score [2]int `json:"score"`
Duration int `json:"duration"`
MatchResult int `json:"match_result"`
MaxRounds int `json:"max_rounds,omitempty"`
Parsed bool `json:"parsed"`
ReplayURL string `json:"replay_url,omitempty"`
Stats interface{} `json:"stats,omitempty"`
}
type (
AuthcodeUnauthorizedError struct {
error
}
AuthcodeRateLimitError struct {
error
}
SharecodeNoMatchError struct {
error
}
)
const (
shareCodeURLEntry = "https://api.steampowered.com/ICSGOPlayers_730/GetNextMatchSharingCode/v1?key=%s&steamid=%d&steamidkey=%s&knowncode=%s"
SideMetaCacheKey = "csgowtfd_side_meta_%d"
MatchMetaCacheKey = "csgowtfd_match_meta_%d"
)
//goland:noinspection SpellCheckingInspection
var (
SteamId64RegEx = regexp.MustCompile(`^\d{17}$`)
ShareCodeRegEx = regexp.MustCompile(`^CSGO(?:-?[ABCDEFGHJKLMNOPQRSTUVWXYZabcdefhijkmnopqrstuvwxyz23456789]{5}){5}$`)
AuthCodeRegEx = regexp.MustCompile(`^[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{5}-[ABCDEFGHJKLMNOPQRSTUVWXYZ23456789]{4}$`)
)
func SendJSON(data interface{}, w http.ResponseWriter) error {
playerJson, err := json.Marshal(data)
if err != nil {
return err
}
w.WriteHeader(http.StatusOK)
_, err = w.Write(playerJson)
if err != nil {
return err
}
return nil
}
func GetMatchStats(dbPlayer *ent.Player) (int, int, int, error) {
wins, loss, ties, err := getWinLossTieFromPlayer(dbPlayer)
if err != nil {
return 0, 0, 0, err
}
return wins, ties, loss, nil
}
func GetMetaStats(dbPlayer *ent.Player) (*MetaStatsResponse, error) {
mResponse := new(MetaStatsResponse)
mResponse.Player = &PlayerResponse{SteamID64: dbPlayer.ID}
tPlayers, err := dbPlayer.QueryMatches().QueryPlayers().Select(player.FieldID).All(context.Background())
if err != nil {
return nil, err
}
matchIDs, err := dbPlayer.QueryMatches().IDs(context.Background())
if err != nil {
return nil, err
}
mapWins := map[string]int{}
mapTies := map[string]int{}
mapMatchTotal := map[string]int{}
matchSeen := map[uint64]bool{}
mResponse.EqMap = map[int]string{}
for _, s := range tPlayers {
if s.ID == dbPlayer.ID {
continue
}
mateRes := new(MateResponse)
mostRes := new(MateResponse)
playerRes := &PlayerResponse{
SteamID64: s.ID,
}
pMatches, err := s.QueryMatches().
Select(match.FieldID, match.FieldMatchResult, match.FieldMap).
Where(match.IDIn(matchIDs...)).
WithStats().
Where(match.HasStatsWith(stats.Or(stats.PlayerStats(dbPlayer.ID), stats.PlayerStats(s.ID)))).
All(context.Background())
if err != nil {
return nil, err
}
mostRes.Player = playerRes
var wins int
var ties int
for _, pm := range pMatches {
var subjectStats *ent.Stats
var currentStats *ent.Stats
for _, ps := range pm.Edges.Stats {
if ps.PlayerStats == dbPlayer.ID {
subjectStats = ps
} else if ps.PlayerStats == s.ID {
currentStats = ps
}
}
win := subjectStats.TeamID == pm.MatchResult
tie := pm.MatchResult == 0
if _, ok := matchSeen[pm.ID]; !ok {
if pm.Map != "" {
mapMatchTotal[pm.Map]++
if win {
mapWins[pm.Map]++
} else if tie {
mapTies[pm.Map]++
}
}
wSs, err := subjectStats.QueryWeaponStats().
Select(weaponstats.FieldEqType, weaponstats.FieldDmg).All(context.Background())
if err != nil {
return nil, err
}
for _, weaponStat := range wSs {
found := false
for _, dmgS := range mResponse.WeaponDmg {
if dmgS.Eq == weaponStat.EqType {
dmgS.Dmg += weaponStat.Dmg
found = true
}
}
if !found {
mResponse.WeaponDmg = append(mResponse.WeaponDmg, &WeaponDmg{
Eq: weaponStat.EqType,
Dmg: weaponStat.Dmg,
})
}
}
matchSeen[pm.ID] = true
}
// check if same team
if subjectStats.TeamID == currentStats.TeamID {
mostRes.Total++
if win {
wins++
} else if tie {
ties++
}
}
}
if mostRes.Total > 0 {
mResponse.MostMates = append(mResponse.MostMates, mostRes)
}
if mostRes.Total > 1 && (wins > 0 || ties > 0) {
mateRes.Player = playerRes
mateRes.TieRate = float32(ties) / float32(mostRes.Total)
mateRes.WinRate = float32(wins) / float32(mostRes.Total)
mateRes.Total = mostRes.Total
mResponse.BestMates = append(mResponse.BestMates, mateRes)
}
}
mResponse.TieMaps = map[string]float32{}
mResponse.WinMaps = map[string]float32{}
for tMap, wins := range mapWins {
mResponse.WinMaps[tMap] = float32(wins) / float32(mapMatchTotal[tMap])
}
for tMap, ties := range mapTies {
mResponse.TieMaps[tMap] = float32(ties) / float32(mapMatchTotal[tMap])
}
// sort all results
sort.Slice(mResponse.BestMates, func(i, j int) bool {
return mResponse.BestMates[i].WinRate > mResponse.BestMates[j].WinRate
})
sort.Slice(mResponse.MostMates, func(i, j int) bool {
return mResponse.MostMates[i].Total > mResponse.MostMates[j].Total
})
sort.Slice(mResponse.WeaponDmg, func(i, j int) bool {
return mResponse.WeaponDmg[i].Dmg > mResponse.WeaponDmg[j].Dmg
})
if len(mResponse.BestMates) > 10 {
mResponse.BestMates = mResponse.BestMates[:10]
}
if len(mResponse.MostMates) > 10 {
mResponse.MostMates = mResponse.MostMates[:10]
}
if len(mResponse.WeaponDmg) > 10 {
mResponse.WeaponDmg = mResponse.WeaponDmg[:10]
}
return mResponse, nil
}
func getWinLossTieFromPlayer(dbPlayer *ent.Player) (int, int, int, error) {
var res []struct {
MatchResult int `json:"match_result"`
Count int `json:"count"`
}
err := dbPlayer.QueryMatches().GroupBy(match.FieldMatchResult).Aggregate(func(s *sql.Selector) string {
sT := sql.Table(stats.Table)
s.Join(sT).On(s.C(match.FieldID), sT.C(stats.MatchesColumn))
s.Where(sql.And(
sql.Or(
sql.ColumnsEQ(match.FieldMatchResult, stats.FieldTeamID),
sql.EQ(s.C(match.FieldMatchResult), 0),
),
sql.EQ(sT.C(stats.PlayersColumn), dbPlayer.ID),
))
return sql.Count("*")
}).Scan(context.Background(), &res)
if err != nil {
return 0, 0, 0, err
}
total, err := dbPlayer.QueryMatches().Modify(func(s *sql.Selector) {
s.Select("COUNT(*)")
}).Int(context.Background())
if err != nil {
return 0, 0, 0, err
}
var (
wins int
ties int
)
for _, r := range res {
switch r.MatchResult {
case 0:
ties = r.Count
case 1, 2:
wins += r.Count
}
}
return wins, total - wins - ties, ties, nil
}
func IsAuthCodeValid(player *ent.Player, apiKey string, shareCode string, authCode string, rl ratelimit.Limiter) (bool, error) {
var tMatch *ent.Match
var err error
if shareCode == "" {
tMatch, err = player.QueryMatches().Order(ent.Asc(match.FieldDate)).First(context.Background())
if err != nil {
return false, err
}
_, err := getNextShareCode(tMatch.ShareCode, apiKey, authCode, player.ID, rl)
if err != nil {
return false, err
}
return true, nil
} else {
_, err := getNextShareCode(shareCode, apiKey, authCode, player.ID, rl)
if err != nil {
return false, err
}
return true, nil
}
}
func GetNewShareCodesForPlayer(player *ent.Player, apiKey string, rl ratelimit.Limiter) ([]string, error) {
latestMatch, err := player.QueryMatches().Order(ent.Desc(match.FieldDate)).First(context.Background())
if err != nil {
return nil, err
}
oldestMatch, err := player.QueryMatches().Order(ent.Asc(match.FieldDate)).First(context.Background())
if err != nil {
return nil, err
}
var newShareCode string
if oldestMatch.ShareCode == player.OldestSharecodeSeen {
newShareCode, err = getNextShareCode(latestMatch.ShareCode, apiKey, player.AuthCode, player.ID, rl)
} else {
newShareCode, err = getNextShareCode(oldestMatch.ShareCode, apiKey, player.AuthCode, player.ID, rl)
}
if err != nil {
return nil, err
}
var rCodes []string
for newShareCode != "n/a" {
rCodes = append(rCodes, newShareCode)
newShareCode, err = getNextShareCode(rCodes[len(rCodes)-1], apiKey, player.AuthCode, player.ID, rl)
if err != nil {
return nil, err
}
}
err = player.Update().SetSharecodeUpdated(time.Now().UTC()).SetOldestSharecodeSeen(oldestMatch.ShareCode).Exec(context.Background())
if err != nil {
return nil, err
}
return rCodes, nil
}
func getNextShareCode(lastCode string, apiKey string, authCode string, steamId uint64, rl ratelimit.Limiter) (string, error) {
if lastCode == "" || apiKey == "" || authCode == "" || steamId == 0 {
return "", fmt.Errorf("invalid arguments")
}
if rl != nil {
rl.Take()
}
log.Debugf("[SC] STEAMPI with %s", fmt.Sprintf(shareCodeURLEntry, "REDACTED", steamId, "REDACTED", lastCode))
r, err := http.Get(fmt.Sprintf(shareCodeURLEntry, apiKey, steamId, authCode, lastCode))
if err != nil {
return "", err
}
switch r.StatusCode {
case http.StatusAccepted:
return "n/a", nil
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
return "", AuthcodeRateLimitError{errors.New("api temp. ratelimited")}
case http.StatusPreconditionFailed:
return "", SharecodeNoMatchError{errors.New("sharecode not from player history")}
case http.StatusForbidden:
return "", AuthcodeUnauthorizedError{errors.New("authcode unauthorized")}
case http.StatusOK:
break
default:
return "", errors.New("temporary steamapi error")
}
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(r.Body)
bJson, err := ioutil.ReadAll(r.Body)
if err != nil {
return "", err
}
rJson := new(shareCodeResponse)
err = json.Unmarshal(bJson, rJson)
if err != nil {
return "", err
}
return rJson.Result.Code, nil
}
func GetPlayer(db *ent.Client, id interface{}, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) {
switch e := id.(type) {
case uint64:
return GetPlayerFromSteamID64(db, e, apiKey, rl)
case string:
if SteamId64RegEx.MatchString(e) {
steamID64, err := strconv.ParseUint(e, 10, 64)
if err != nil {
return nil, err
}
return GetPlayerFromSteamID64(db, steamID64, apiKey, rl)
}
return GetPlayerFromVanityURL(db, e, apiKey, rl)
default:
return nil, fmt.Errorf("invalid arguments")
}
}
func GetPlayerFromVanityURL(db *ent.Client, id string, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) {
if id == "" {
return nil, fmt.Errorf("invalid arguments")
}
tPlayer, err := db.Player.Query().Where(player.VanityURL(strings.ToLower(id))).Only(context.Background())
if err == nil {
return tPlayer, nil
} else {
if rl != nil {
rl.Take()
}
resp, err := steamapi.ResolveVanityURL(id, apiKey)
if err != nil {
return nil, err
}
if resp.Success != 1 {
return nil, fmt.Errorf("vanity url not found")
}
nPlayer, err := GetPlayerFromSteamID64(db, resp.SteamID, apiKey, rl)
if err != nil {
return nil, err
}
return nPlayer, nil
}
}
func GetPlayerFromSteamID64(db *ent.Client, steamID uint64, apiKey string, rl ratelimit.Limiter) (*ent.Player, error) {
tPlayer, err := db.Player.Get(context.Background(), steamID)
if err == nil {
return tPlayer, nil
} else {
nPlayer, err := db.Player.Create().SetID(steamID).Save(context.Background())
if err != nil {
return nil, err
}
uPlayer, err := UpdatePlayerFromSteam([]*ent.Player{nPlayer}, db, apiKey, rl)
if err != nil {
return nil, err
}
if len(uPlayer) > 0 {
return uPlayer[0], nil
} else {
return nil, nil
}
}
}
func UpdatePlayerFromSteam(players []*ent.Player, db *ent.Client, apiKey string, rl ratelimit.Limiter) ([]*ent.Player, error) {
var idsToUpdate []uint64
for _, updatePlayer := range players {
idsToUpdate = append(idsToUpdate, updatePlayer.ID)
}
if rl != nil {
rl.Take()
}
playerSum, err := steamapi.GetPlayerSummaries(idsToUpdate, apiKey)
if err != nil {
return nil, err
}
var nPlayers []*ent.Player
for _, pS := range playerSum {
// TODO: what happens if a player deleted their profile?
// check for vanityURL
if SteamId64RegEx.MatchString(path.Base(pS.ProfileURL)) {
pS.ProfileURL = ""
} else {
pS.ProfileURL = path.Base(pS.ProfileURL)
}
tPlayer, err := db.Player.UpdateOneID(pS.SteamID).
SetName(pS.PersonaName).
SetAvatar(pS.AvatarHash).
SetSteamUpdated(time.Now().UTC()).
SetVanityURL(strings.ToLower(pS.ProfileURL)).
SetVanityURLReal(pS.ProfileURL).
SetSteamUpdated(time.Now().UTC()).
SetProfileCreated(time.Unix(pS.TimeCreated, 0).UTC()).
Save(context.Background())
if err != nil {
return nil, err
}
nPlayers = append(nPlayers, tPlayer)
}
if rl != nil {
rl.Take()
}
bans, err := steamapi.GetPlayerBans(idsToUpdate, apiKey)
if err != nil {
return nil, err
}
for _, ban := range bans {
if ban.NumberOfVACBans > 0 {
banDate := time.Now().UTC().AddDate(0, 0, -1*int(ban.DaysSinceLastBan))
err := db.Player.UpdateOneID(ban.SteamID).SetVacCount(int(ban.NumberOfVACBans)).SetVacDate(banDate).Exec(context.Background())
if err != nil {
return nil, err
}
}
if ban.NumberOfGameBans > 0 {
banDate := time.Now().UTC().AddDate(0, 0, -1*int(ban.DaysSinceLastBan))
err := db.Player.UpdateOneID(ban.SteamID).SetGameBanCount(int(ban.NumberOfGameBans)).SetGameBanDate(banDate).Exec(context.Background())
if err != nil {
return nil, err
}
}
}
return nPlayers, nil
}