Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
rkfg committed Nov 4, 2020
0 parents commit 4b0cad7
Show file tree
Hide file tree
Showing 13 changed files with 448 additions and 0 deletions.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}",
"env": {},
"args": []
}
]
}
17 changes: 17 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "go build",
"type": "shell",
"command": "go build",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
}
]
}
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2020 rkfg

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# NS2 Query Bot

This is a simple bot that queries the Natural Selection 2 servers and sends messages to the specified Discord channel
if there are enough players to start the round.

# Compiling

Grab a [Golang](https://golang.org/dl/) installer or install it from your repository, then run `go get -u github.com/rkfg/ns2query`.
You should find the binary in your `$GOPATH/bin` directory. Alternatively, clone the repository and run `go build` inside.

# Configuration

Copy the provided `config_sample.json` file to `config.json` and change it to your needs. You should put your Discord bot token to the
`token` parameter, put the channel ID to the `channel_id` parameter (it's the last long number in the Discord URL:
`https://discord.com/channels/AAAAAAAAAAAAAAA/BBBBBBBBBBBBBB`, you need to copy the `BBBBBBBBBBBBBB` part). `query_interval` specifies
the interval (in seconds) between querying the same server. All servers are queried in parallel.

Then setup the servers you want to watch. `name` can be anything, the bot will use it for announcing, address should be in the `ip:port`
form (where port is `the game port + 1`, i.e. if you see 27015 in the Steam server browser use 27016 here). `player_slots` is the number of
slots for players and `spec_slots` is spectator slots. The bot uses those to post "last minute" notifications.

The `seeding` section defines the player number boundaries. Inside that section there are two most important parameters, `seeding` (the bot
will announce that the server is getting seeded when at least this many players have connected) and `almost_full` (it will say that the
server is getting filled but there are still slots if you want to play). The `cooldown` parameter is used when the number of players
fluctuates between two adjacent states. For example, if the `seeding` parameter is `4` and some players join and leave so the number of
players changes back and forth between 3 and 4, this cooldown parameter is used to temporarily mute the new messages about seeding. It's
the number of seconds after the last promotion (getting a higher status) during which demotions (lowering the status) are ignored. If the
server empties normally, then after this cooldown period the seeding announcements will be restored.
40 changes: 40 additions & 0 deletions bot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package main

import (
"fmt"
"os"
"os/signal"
"syscall"
"time"

"github.com/bwmarrin/discordgo"
)

func sendMsg(c chan string, s *discordgo.Session) {
for {
s.ChannelMessageSend(config.ChannelID, <-c)
time.Sleep(time.Second)
}
}

func bot() error {
dg, err := discordgo.New("Bot " + config.Token)
if err != nil {
return err
}
defer dg.Close()
err = dg.Open()
if err != nil {
return err
}
sendChan := make(chan string, 10)
go sendMsg(sendChan, dg)
for i := range config.Servers {
go query(&config.Servers[i], sendChan)
}
fmt.Println("Bot is now running. Press CTRL-C to exit.")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
return nil
}
96 changes: 96 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package main

import (
"encoding/json"
"fmt"
"os"
"time"
)

type state int

const (
empty state = iota
seedingstarted
almostfull
specsonly
full
)

type ns2server struct {
Name string
Address string
SpecSlots int `json:"spec_slots"`
PlayerSlots int `json:"player_slots"`
players []string
serverState state
maxStateToMessage state
lastStatePromotion time.Time
}

func (s *ns2server) playersString() string {
unknowns := 0
result := ""
for _, p := range s.players {
if p == "Unknown" {
unknowns++
} else {
if result == "" {
result = p
} else {
result += ", " + p
}
}
}
if unknowns > 0 {
suffix := ""
if unknowns > 1 {
suffix = "s"
}
if result == "" {
return fmt.Sprintf("%d connecting player%s", unknowns, suffix)
}
return fmt.Sprintf("%s and %d connecting player%s", result, unknowns, suffix)
}
return result
}

type seeding struct {
Seeding int
AlmostFull int `json:"almost_full"`
Cooldown int
}

var config struct {
Token string
ChannelID string `json:"channel_id"`
QueryInterval time.Duration `json:"query_interval"`
Servers []ns2server
Seeding seeding
}

func loadConfig() error {
return loadConfigFilename("config.json")
}

func loadConfigFilename(filename string) error {
if file, err := os.Open(filename); err == nil {
defer file.Close()
json.NewDecoder(file).Decode(&config)
} else {
return err
}
for i := range config.Servers {
config.Servers[i].maxStateToMessage = full
}
if config.QueryInterval < 1 {
return fmt.Errorf("invalid query interval in config.json: %d", config.QueryInterval)
}
if config.ChannelID == "" {
return fmt.Errorf("specify channel_id in config.json")
}
if config.Token == "" {
return fmt.Errorf("specify token in config.json")
}
return nil
}
24 changes: 24 additions & 0 deletions config_sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"token": "your.discordbot.token",
"channel_id": "channel identifier",
"query_interval": 10,
"servers": [
{
"name": "Server 1",
"address": "192.168.0.1:27016",
"player_slots": 20,
"spec_slots": 6
},
{
"name": "Server 2",
"address": "192.168.0.2:27018",
"player_slots": 16,
"spec_slots": 4
}
],
"seeding": {
"seeding": 4,
"almost_full": 12,
"cooldown": 600
}
}
15 changes: 15 additions & 0 deletions config_test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"servers": [
{
"name": "TTO [Backup]",
"address": "127.0.0.1:8080"
}
],
"seeding": {
"seeding": 4,
"almost_full": 12,
"specs_only": 20,
"full": 26,
"cooldown": 60
}
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module rkfg.me/ns2query

go 1.15

require (
github.com/bwmarrin/discordgo v0.22.0
github.com/rumblefrog/go-a2s v1.0.0
)
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/bwmarrin/discordgo v0.22.0 h1:uBxY1HmlVCsW1IuaPjpCGT6A2DBwRn0nvOguQIxDdFM=
github.com/bwmarrin/discordgo v0.22.0/go.mod h1:c1WtWUGN6nREDmzIpyTp/iD3VYt4Fpx+bVyfBG7JE+M=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/multiplay/go-source v0.0.0-20171201124051-cb29ef5320bb h1:n3wijII160M3fIQqHeLWULRK36kXmEWfrtSFMXNExk0=
github.com/multiplay/go-source v0.0.0-20171201124051-cb29ef5320bb/go.mod h1:o/iuncLTF1DDwozJ6BFN+Q00cvguIvUyRMSlHKIc/FM=
github.com/rumblefrog/go-a2s v1.0.0 h1:9IIVIOQ1bXZJeTilmzkJDeGa/9W1c089VciTbp+Wp1Y=
github.com/rumblefrog/go-a2s v1.0.0/go.mod h1:JwbTgMTRGZcWzr3T2MUfDusrJU5Bdg8biEeZzPtN0So=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16 h1:y6ce7gCWtnH+m3dCjzQ1PCuwl28DDIc3VNnvY29DlIA=
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
15 changes: 15 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package main

import (
"log"
)

func main() {
if err := loadConfig(); err != nil {
log.Fatal(err)
}
err := bot()
if err != nil {
log.Fatal(err)
}
}
67 changes: 67 additions & 0 deletions query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package main

import (
"fmt"
"log"
"time"

"github.com/rumblefrog/go-a2s"
)

func maybeNotify(srv *ns2server, sendChan chan string) {
playersCount := len(srv.players)
newState := empty
if playersCount < config.Seeding.Seeding {
newState = empty
} else if playersCount < config.Seeding.AlmostFull {
newState = seedingstarted
} else if playersCount < srv.PlayerSlots {
newState = almostfull
} else if playersCount < srv.PlayerSlots+srv.SpecSlots {
newState = specsonly
} else {
newState = full
}
if newState > srv.serverState && newState <= srv.maxStateToMessage {
srv.lastStatePromotion = time.Now()
srv.serverState = newState
switch newState {
case seedingstarted:
sendChan <- fmt.Sprintf("%s started seeding! There are %d players there currently: %s",
srv.Name, len(srv.players), srv.playersString())
srv.maxStateToMessage = specsonly
case almostfull:
sendChan <- fmt.Sprintf("%s is almost full! There are %d players there currently",
srv.Name, len(srv.players))
case specsonly:
srv.maxStateToMessage = seedingstarted
sendChan <- fmt.Sprintf("%s is full but you can still make it! There are %d spectator slots available currently",
srv.Name, srv.PlayerSlots+srv.SpecSlots-len(srv.players))
}
} else {
if time.Since(srv.lastStatePromotion).Seconds() > float64(config.Seeding.Cooldown) {
srv.serverState = newState
}
}
}

func query(srv *ns2server, sendChan chan string) error {
client, err := a2s.NewClient(srv.Address)
if err != nil {
return fmt.Errorf("error creating client: %s", err)
}
defer client.Close()
for {
info, err := client.QueryPlayer()
if err != nil {
log.Printf("query error: %s", err)
} else {
srv.players = srv.players[:0]
for _, p := range info.Players {
srv.players = append(srv.players, p.Name)
}
maybeNotify(srv, sendChan)
}
time.Sleep(config.QueryInterval * time.Second)
}
}
Loading

0 comments on commit 4b0cad7

Please sign in to comment.