diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c23774c --- /dev/null +++ b/.vscode/launch.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..d1dafd5 --- /dev/null +++ b/.vscode/tasks.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fac8364 --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..49d56a6 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/bot.go b/bot.go new file mode 100644 index 0000000..5ba8de5 --- /dev/null +++ b/bot.go @@ -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 +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..68228c9 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/config_sample.json b/config_sample.json new file mode 100644 index 0000000..4d47e0c --- /dev/null +++ b/config_sample.json @@ -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 + } +} \ No newline at end of file diff --git a/config_test.json b/config_test.json new file mode 100644 index 0000000..8c68336 --- /dev/null +++ b/config_test.json @@ -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 + } +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..08d8e0f --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6367b2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..7443ceb --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..df0f3fa --- /dev/null +++ b/query.go @@ -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) + } +} diff --git a/query_test.go b/query_test.go new file mode 100644 index 0000000..b979c2a --- /dev/null +++ b/query_test.go @@ -0,0 +1,90 @@ +package main + +import ( + "log" + "os" + "strconv" + "testing" + "time" +) + +func TestMain(t *testing.M) { + if err := loadConfigFilename("config_test.json"); err != nil { + log.Fatal(err) + } + os.Exit(t.Run()) +} + +func notif(t *testing.T, srv *ns2server, expected string) { + sendChan := make(chan string) + q := make(chan bool) + go func() { + maybeNotify(srv, sendChan) + close(q) + }() + select { + case m := <-sendChan: + if expected == "" { + t.Errorf("unexpected message reported: '%s'", m) + } else if m != expected { + t.Errorf("expected message '%s' got '%s'", expected, m) + } + case <-q: + if expected != "" { + t.Errorf("expected '%s' got nothing", expected) + } + } +} + +func fillPlayers(num int) []string { + players := []string{} + for i := 0; i < num; i++ { + players = append(players, strconv.FormatInt(int64(i+1), 10)) + } + return players +} + +func passTime(srv *ns2server) { + srv.lastStatePromotion = time.Now().Add(-time.Minute) // a minute passed +} + +func TestNotification(t *testing.T) { + srv := &ns2server{ + Name: "Test", + maxStateToMessage: full, + PlayerSlots: 20, + SpecSlots: 6, + } + notif(t, srv, "") + srv.players = fillPlayers(4) + notif(t, srv, "Test started seeding! There are 4 players there currently: 1, 2, 3, 4") + // test demotion + srv.players = fillPlayers(3) + notif(t, srv, "") + srv.players = fillPlayers(5) + notif(t, srv, "") + passTime(srv) + notif(t, srv, "") + srv.players = fillPlayers(13) + notif(t, srv, "Test is almost full! There are 13 players there currently") + srv.players = fillPlayers(21) + notif(t, srv, "Test is full but you can still make it! There are 5 spectator slots available currently") + srv.players = fillPlayers(26) + notif(t, srv, "") // no message when full + srv.players = fillPlayers(19) + notif(t, srv, "") + passTime(srv) + srv.players = fillPlayers(21) + notif(t, srv, "") + passTime(srv) + srv.players = fillPlayers(13) + notif(t, srv, "") + passTime(srv) + srv.players = fillPlayers(3) + notif(t, srv, "") + passTime(srv) + srv.players = fillPlayers(7) + notif(t, srv, "Test started seeding! There are 7 players there currently: 1, 2, 3, 4, 5, 6, 7") + srv.players = fillPlayers(12) + notif(t, srv, "Test is almost full! There are 12 players there currently") +}