Skip to content
This repository was archived by the owner on Dec 27, 2022. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
**/.idea
**/*.iml

.vscode/
config.yml
development.env

Expand Down
14 changes: 8 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
FROM golang:alpine

# Create application directory
RUN mkdir /app
ADD . /app/
WORKDIR /app
RUN mkdir /src
ADD . /src/
WORKDIR /src

# Build the application
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o run .
RUN --mount=target=. \
--mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/run .

# Add the execution user
RUN adduser -S -D -H -h /app execuser
USER execuser

# Run the application
CMD ["./run"]
CMD ["/app/run"]
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ content:
channels:
- name: SomeChannel # This is the name of the Twitch channel that the bot should join.
gsiToken: xxx # This can be any random string, that needs to be present in your CSGO GSI config as well.
serverToken: xxx # Token string that will be registered in the server using sm_setprestrafetoken.
```

Next you will need to define some environment variables to configure the bots execution context. To do so, create a file
called `development.env` inside the source code directory and add the following content:

```properties
# This is required because of limitations of Docker for Windows, we cannot mount the config file.
BOT_CONFIGDIR=/app
BOT_CONFIGDIR=/src

# The API token the bot should used to authenticate against the Global API.
BOT_GLOBALAPITOKEN=xxx
Expand All @@ -26,6 +27,10 @@ BOT_GLOBALAPITOKEN=xxx
BOT_GSIADDR=localhost
BOT_GSIPORT=8080

# The address and port of the SourceMod backend service that should be used by the bot.
BOT_SMADDR=localhost
BOT_SMPORT=8080

# The Twitch.tv username and API token that should be used to talk to the Twitch Chat API.
BOT_TWITCHUSERNAME=xxx
BOT_TWITCHAPITOKEN=xxx
Expand All @@ -45,7 +50,7 @@ Of course, you need to run the GSI backend service before the bot will be able t

- `!bpb (bonus-number) (map-name)`: Displays the personal best time for the bonus stage.
- `!bwr (bonus-number) (map-name)`: Displays the world record time for the bonus stage.
- `!globalcheck`: Yes
- `!globalcheck`: Display global status of the server and player.
- `!prestrafe`: A list of supported commands of the Prestrafe bot.
- `!map (map-name)`: Displays information about the currently played map.
- `!mode`: Displays the currently played KZ timer mode.
Expand All @@ -54,6 +59,8 @@ Of course, you need to run the GSI backend service before the bot will be able t
- `!stats`: Displays a link to the GOKZ-Stats page.
- `!tier (map-name)`: Display the difficulty level for the map.
- `!wr (map-name)`: Displays the world record time for the main stage.
- `!server`: Displays server information (name, global status)
- `!run`: Displays current run information (Map name, course, checkpoints, teleports, time elapsed)

## Jumpstat Commands

Expand Down
5 changes: 3 additions & 2 deletions config/config_channel.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

type ChannelConfig struct {
Name string `yaml:"name"`
GsiToken string `yaml:"gsiToken"`
Name string `yaml:"name"`
GsiToken string `yaml:"gsiToken"`
ServerToken string `yaml:"serverToken"`
}
4 changes: 4 additions & 0 deletions deployment/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ spec:
value: prestrafe-gsi.prestrafe.svc.cluster.local
- name: BOT_GSIPORT
value: "8080"
- name: BOT_SMADDR
value: prestrafe-gsi.prestrafe.svc.cluster.local
- name: BOT_SMPORT
value: "8080"
- name: BOT_METRICPORT
value: "9080"
- name: BOT_TWITCHUSERNAME
Expand Down
2 changes: 1 addition & 1 deletion deployment/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ commonLabels:
images:
- name: prestrafe-bot
newName: jangraefen/prestrafe-bot
newTag: sha-cfe2f11
newTag: sha-b9c4cb7
2 changes: 1 addition & 1 deletion globalapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func (c *client) GetWithParameters(path string, queryParams QueryParameters, res

c.logger.Printf("%s -> Status: %d, Body: %s\n", response.Request.URL, response.StatusCode(), response.Body())
if response.StatusCode() != 200 {
return fmt.Errorf("Expected status '%d' but got '%d'", 200, response.StatusCode())
return fmt.Errorf("expected status '%d' but got '%d'", 200, response.StatusCode())
}

if jsonErr := json.Unmarshal(response.Body(), result); jsonErr != nil {
Expand Down
44 changes: 43 additions & 1 deletion globalapi/map_service.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package globalapi

import "strconv"
import (
"regexp"
"strconv"
)

type KzMap struct {
Id int `json:"id"`
Expand All @@ -15,6 +18,17 @@ type KzMap struct {
DownloadUrl string `json:"download_url"`
}

type RecordFilter struct {
Id int32 `json:"id"`
MapId int32 `json:"map_id"`
Stage int32 `json:"stage"`
Mode string `json:"mode"`
TickRate int32 `json:"tickrate"`
CreatedOn string `json:"created_on"`
UpdatedOn string `json:"updated_on"`
UpdatedBy int64 `json:"updated_by"`
}

type MapServiceClient struct {
Client
}
Expand All @@ -39,3 +53,31 @@ func (s *MapServiceClient) GetMapByName(mapName string) (result *KzMap, err erro

return
}

func (s *MapServiceClient) CheckRecordFilter(stage int, mapName string, modeId int) string {
globalMap, apiError := s.GetMapByName(mapName)
if apiError != nil {
return "cannot establish a connection to the API server."
}
mapId := globalMap.Id
if mapId != 0 {
result := []RecordFilter{}

apiError = s.GetWithParameters("record_filters", QueryParameters{
"stages": strconv.Itoa(stage),
"map_ids": strconv.Itoa(mapId),
"mode_ids": strconv.Itoa(modeId),
}, &result)
if apiError != nil {
match, _ := regexp.MatchString(`expected \d+, but got \d+ instead!`, apiError.Error())
if match {
return "cannot establish a connection to the API server."
}
} else if len(result) == 0 {
return "No (Filter does not exist for this course)"
} else {
return "Yes"
}
}
return "No (Map does not exist in API database)"
}
17 changes: 12 additions & 5 deletions gsiclient/gamestate.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import (
)

type GameState struct {
Auth *AuthState `json:"auth"`
Map *MapState `json:"map"`
Player *PlayerState `json:"player"`
Provider *ProviderState `json:"provider"`
Auth *AuthState `json:"auth"`
Map *MapState `json:"map"`
Player *PlayerState `json:"player"`
Provider *ProviderState `json:"provider"`
PreviousState *GameState `json:"previously"`
}

type AuthState struct {
Expand All @@ -25,7 +26,9 @@ type ProviderState struct {
}

type MapState struct {
Name string `json:"name"`
Name string `json:"name"`
TeamCT *TeamState `json:"team_ct"`
TeamT *TeamState `json:"team_t"`
}

type PlayerState struct {
Expand All @@ -43,6 +46,10 @@ type MatchStats struct {
Score int `json:"score"`
}

type TeamState struct {
Timeouts *int `json:"timeouts_remaining"`
}

func IsKZGameState(gameState *GameState) bool {
if gameState.Player == nil || gameState.Map == nil {
return false
Expand Down
2 changes: 1 addition & 1 deletion gsiclient/gsi_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (c *client) GetGameState() (*GameState, error) {
response, restErr := resty.New().
R().
SetHeader("Authorization", fmt.Sprintf("GSI %s", c.authToken)).
Get(fmt.Sprintf("http://%s:%d/get", c.host, c.port))
Get(fmt.Sprintf("http://%s:%d/gsi/get", c.host, c.port))
if restErr != nil {
log.Println(restErr)
return nil, restErr
Expand Down
51 changes: 51 additions & 0 deletions helper/helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package helper

import (
"gitlab.com/prestrafe/prestrafe-bot/gsiclient"
"gitlab.com/prestrafe/prestrafe-bot/smclient"
)

// Check for data received from sourcemod and gsi backends and see if they match
func CompareData(smData *smclient.FullPlayerInfo, gsiData *gsiclient.GameState) bool {

/* Team timeout check is to make sure that the server is doesn't send inaccurate player data.
These values will only match if the player is in the server, and change at frequent interval
to make sure the player is always in the server. */

// This can get out of sync if the server/client was slow to update, so we will also compare to previous data.
timeoutDelayed := (smData.TimeoutsCTPrev == *gsiData.Map.TeamCT.Timeouts) && (smData.TimeoutsTPrev == *gsiData.Map.TeamT.Timeouts)

timeoutAhead := gsiData.PreviousState != nil
// Make sure we don't have memory access violation
if timeoutAhead {
timeoutAhead = gsiData.PreviousState.Map != nil
}

if timeoutAhead {
timeoutAhead = (gsiData.PreviousState.Map.TeamCT != nil) && (gsiData.PreviousState.Map.TeamT != nil)
}

if timeoutAhead {
timeoutAhead = (gsiData.PreviousState.Map.TeamCT.Timeouts != nil) && (gsiData.PreviousState.Map.TeamT.Timeouts != nil)
}

if timeoutAhead {
timeoutAhead = (smData.TimeoutsCT == *gsiData.PreviousState.Map.TeamCT.Timeouts) && (smData.TimeoutsT == *gsiData.PreviousState.Map.TeamT.Timeouts)
}

timeoutInSync := (smData.TimeoutsCT == *gsiData.Map.TeamCT.Timeouts) && (smData.TimeoutsT == *gsiData.Map.TeamT.Timeouts)

timeoutCheck := timeoutDelayed || timeoutInSync || timeoutAhead

/* SM exclusive commands don't return anything if the player is spectating for now, because the three steamids don't match.
This is because currently GSI commands will return data about the spectated player and the SM backend might not have that data.
Therefore, it will be confusing if SM commands return the information of the original player.
However, it is likely possible to obtain and send the data of the spectated player instead. */

steamIDCheck := (smData.SteamId == gsiData.Player.SteamId) && (smData.SteamId == gsiData.Provider.SteamId)

// Server update is sent every 2 second. GSI update is sent every 2.5 seconds. A 6 seconds gap should be sufficient in case of packet loss.
timestampCheck := (smData.TimeStamp <= int(gsiData.Provider.Timestamp)+3) && (smData.TimeStamp >= int(gsiData.Provider.Timestamp)-3)

return timeoutCheck && steamIDCheck && timestampCheck
}
14 changes: 12 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import (
"gitlab.com/prestrafe/prestrafe-bot/config"
"gitlab.com/prestrafe/prestrafe-bot/globalapi"
"gitlab.com/prestrafe/prestrafe-bot/gsiclient"
"gitlab.com/prestrafe/prestrafe-bot/smclient"
"gitlab.com/prestrafe/prestrafe-bot/twitchbot"
)

type BotConfig struct {
GlobalApiToken string `required:"true"`
GsiAddr string `required:"true"`
GsiPort int `required:"true"`
SmAddr string `required:"true"`
SmPort int `required:"true"`
TwitchUsername string `required:"true"`
TwitchApiToken string `required:"true"`
ConfigDir string `default:""`
Expand Down Expand Up @@ -50,10 +53,17 @@ func main() {
func createCommands(botConfig *BotConfig, channelConfig *config.ChannelConfig) []twitchbot.ChatCommand {
apiClient := globalapi.NewClient(botConfig.GlobalApiToken)
gsiClient := gsiclient.New(botConfig.GsiAddr, botConfig.GsiPort, channelConfig.GsiToken)
smClient := smclient.New(botConfig.SmAddr, botConfig.SmPort, channelConfig.ServerToken)

commands := []twitchbot.ChatCommand{
// Troll commands
twitchbot.NewGlobalCheckCommand().Build(),
// Globalcheck command
twitchbot.NewGlobalCheckCommand(gsiClient, smClient, apiClient).Build(),

// Current run command
twitchbot.NewRunCommand(gsiClient, smClient).Build(),

// Server command
twitchbot.NewServerCommand(gsiClient, smClient).Build(),

// Map information commands
twitchbot.NewMapCommand(gsiClient, apiClient).Build(),
Expand Down
50 changes: 50 additions & 0 deletions smclient/sm_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package smclient

import (
"encoding/json"
"errors"
"fmt"
"log"

"github.com/go-resty/resty/v2"
)

type Client interface {
// Retrieves the game state for the player that this client connects to.
GetPlayerInfo() (*FullPlayerInfo, error)
}

type client struct {
host string
port int
serverToken string
}

func New(host string, port int, serverToken string) Client {
return &client{host, port, serverToken}
}

func (c *client) GetPlayerInfo() (*FullPlayerInfo, error) {
response, restErr := resty.New().
R().
SetHeader("Authorization", fmt.Sprintf("SM %s", c.serverToken)).
Get(fmt.Sprintf("http://%s:%d/sm/get", c.host, c.port))
if restErr != nil {
log.Println(restErr)
return nil, restErr
}

if response.StatusCode() != 200 {
errorMessage := fmt.Sprintf("Expected status '%d' but got '%d', with response: %s", 200, response.StatusCode(), response.Body())
log.Println(errorMessage)
return nil, errors.New(errorMessage)
}

result := new(FullPlayerInfo)
if jsonErr := json.Unmarshal(response.Body(), result); jsonErr != nil {
log.Println(jsonErr)
return nil, jsonErr
}

return result, nil
}
26 changes: 26 additions & 0 deletions smclient/sm_playerinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package smclient

type FullPlayerInfo struct {
TimeStamp int `json:"timestamp"`
AuthKey string `json:"authkey"`
TimeoutsCTPrev int `json:"timeoutsCTprev"`
TimeoutsTPrev int `json:"timeoutsTprev"`
TimeoutsCT int `json:"timeoutsCT"`
TimeoutsT int `json:"timeoutsT"`
ServerName string `json:"servername"`
MapName string `json:"mapname"`
ServerGlobal int `json:"serverglobal"`
SteamId int64 `json:"steamid,string"`
Clan string `json:"clan"`
Name string `json:"name"`
TimeInServer float64 `json:"timeinserver"` // Need a better name
KZData KZData `json:"KZData"`
}

type KZData struct {
Global bool `json:"global"`
Course int `json:"course"`
Time float64 `json:"time"`
Checkpoints int `json:"checkpoints"`
Teleports int `json:"teleports"`
}
Loading