diff --git a/.gitignore b/.gitignore index 71d823c..8bc3723 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ yarn-debug.log* yarn-error.log* /scratch.txt /config.json -/sneaker-server \ No newline at end of file +/sneaker-server +/state.json \ No newline at end of file diff --git a/README.md b/README.md index 53ed7bf..684e0f6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,29 @@ A live example of Sneaker can be viewed [here](https://hoggit.brrt.me/). 2. Create a configuration file based off the [example](/example.config.json), replacing the required information (and optionally adding multiple servers to the array) 3. Run the executable with the configuration path: `sneaker-server.exe --config config.json` +### Discord Integration + +Sneaker features a built-in Discord integration which provides basic server information and GCI duty tracking via Discord slash-commands. + +1. Create a new [Discord Application](https://discord.com/developers/applications) and configure the `Interactions Endpoint URL` to point at your Sneaker installations `/api/discord/interactions` endpoint. +2. Add a Bot to the application (this is used to DM users about GCI duty timeouts) +3. Add the bot to your server by opening a link generated [here](https://discord.com/developers/applications/935306685692674078/oauth2/url-generator). You only need the `applications.commands` scope. +4. Add the following to your `config.json`: +```json +"discord": { + "application_id": "", + "application_key": "", + "token": "", + "state_path": "", + "timeout": "", + "reminder": "" +} +``` + +## Documentation + +- [API](/docs/API.md) provides information on the internal Sneaker API. + ## Web UI The Sneaker web UI presents an emulated radar scope over top a [Open Street Map](https://openstreetmap.org) rendered via [maptalks](https://maptalks.org). The web UI is updated at a configurable simulated refresh rate (by default 5 seconds). diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..662d0df --- /dev/null +++ b/docs/API.md @@ -0,0 +1,127 @@ +# API + +## Available Endpoints + +### Server List + +``` +$ curl https://sneaker.example.com/api/servers +[ + { + "name": "saw", + "ground_unit_modes": [ + "enemy", + "friendly" + ], + "players": [ + { + "name": "[TFP] Ghost", + "type": "F-16C_50" + }, + { + "name": "Legacy 1-1 | Zanax116", + "type": "F-16C_50" + }, + { + "name": "Barack 1-1", + "type": "F-16C_50" + }, + { + "name": "Hesgad", + "type": "Mi-24P" + } + ], + "gcis": [ + { + "id": "80351110224678912", + "notes": "asfd", + "expires_at": "2022-01-26T18:11:50.948081071Z" + } + ] + } +] +``` + +### Server Information + +``` +$ curl https://sneaker.example.com/api/servers/saw +{ + "name": "saw", + "ground_unit_modes": [ + "enemy", + "friendly" + ], + "players": [ + { + "name": "[TFP] Ghost", + "type": "F-16C_50" + }, + { + "name": "Legacy 1-1 | Zanax116", + "type": "F-16C_50" + }, + { + "name": "Barack 1-1", + "type": "F-16C_50" + }, + { + "name": "Hesgad", + "type": "Mi-24P" + } + ], + "gcis": [ + { + "id": "80351110224678912", + "notes": "asfd", + "expires_at": "2022-01-26T18:11:50.948081071Z" + } + ] +} +``` + +### Server Events + +This is a long-poll SSE HTTP connection. + +``` +$ curl https://sneaker.example.com/api/servers/saw/events +data: { + "d": { + "session_id": "2022-01-26T17:22:03.013Z", + "offset": 17975, + "objects": null + }, + "e": "SESSION_STATE" +}\n\n +data: { + "d": { + "updated": [], + "deleted": [], + "created": [ + { + "id": 62210, + "types": [ + "Ground", + "Vehicle" + ], + "properties": { + "Coalition": "Enemies", + "Color": "Blue", + "Country": "us", + "Group": "Ground-3", + "Name": "Patriot AMG", + "Pilot": "Ground-2-3-1" + }, + "latitude": 34.5961321, + "longitude": 32.9832006, + "altitude": 13.04, + "heading": 90, + "updated_at": 17844, + "created_at": 17844 + } + ] + }, + "e": "SESSION_RADAR_SNAPSHOT" +}\n\n +``` \ No newline at end of file diff --git a/go.mod b/go.mod index 04da3e4..f193a90 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.17 require ( github.com/alioygur/gores v1.2.2 github.com/b1naryth1ef/jambon v0.0.4-0.20220109012622-92223168294c + github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820 github.com/go-chi/chi/v5 v5.0.7 github.com/go-chi/cors v1.2.0 github.com/urfave/cli/v2 v2.3.0 @@ -12,7 +13,10 @@ require ( require ( github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/spkg/bom v1.0.0 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect ) diff --git a/go.sum b/go.sum index 420b0f0..6548cbb 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alioygur/gores v1.2.2 h1:y5mzo3R5cNWz1LBTIwAEU6DmB8grIC7iJwfc91kmBVU= github.com/alioygur/gores v1.2.2/go.mod h1:z9GuicgNf03HUIQ5aPbiQGshig430sMeaFFfqfRacl8= -github.com/b1naryth1ef/jambon v0.0.4-0.20220106204832-12ebbf739bfb h1:Gxufp0hqv/TQelY+zYlaoBmHPXraU3AoZmFeCD96d6k= -github.com/b1naryth1ef/jambon v0.0.4-0.20220106204832-12ebbf739bfb/go.mod h1:jVzH5viOXvJXHc1DgMKauvOfR96UY7Zu49jIdUM6GX8= github.com/b1naryth1ef/jambon v0.0.4-0.20220109012622-92223168294c h1:mWNvev5C4sPVOWGrjoAQ8FHi+fJ0SmU6qeNak/UYHYI= github.com/b1naryth1ef/jambon v0.0.4-0.20220109012622-92223168294c/go.mod h1:jVzH5viOXvJXHc1DgMKauvOfR96UY7Zu49jIdUM6GX8= +github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820 h1:MIW5DnBVJAgAy4LYBqWwIMBB0ezklvh8b7DsYvHZHb0= +github.com/bwmarrin/discordgo v0.23.3-0.20211228023845-29269347e820/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= @@ -13,6 +13,8 @@ github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.0 h1:tV1g1XENQ8ku4Bq3K9ub2AtgG+p16SmzeMSGTwrOKdE= github.com/go-chi/cors v1.2.0/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -26,6 +28,14 @@ github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= diff --git a/server/config.go b/server/config.go index 1148c3e..a215eb6 100644 --- a/server/config.go +++ b/server/config.go @@ -1,9 +1,19 @@ package server type Config struct { - Bind string `json:"bind"` - Servers []TacViewServerConfig `json:"servers"` - AssetsPath *string `json:"assets_path"` + Bind string `json:"bind"` + Servers []TacViewServerConfig `json:"servers"` + AssetsPath *string `json:"assets_path"` + Discord *DiscordIntegrationConfig `json:"discord"` +} + +type DiscordIntegrationConfig struct { + Token string `json:"token"` + ApplicationKey string `json:"application_key"` + ApplicationID string `json:"application_id"` + StatePath *string `json:"state_path"` + Timeout *int `json:"timeout"` + Reminder *int `json:"reminder"` } type TacViewServerConfig struct { diff --git a/server/discord.go b/server/discord.go new file mode 100644 index 0000000..9ec1b1d --- /dev/null +++ b/server/discord.go @@ -0,0 +1,486 @@ +package server + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/alioygur/gores" + "github.com/bwmarrin/discordgo" +) + +type gciState struct { + DiscordId string + Server string + Notes string + ExpiresAt time.Time + Warned bool + DirectMessageId string +} + +type DiscordIntegration struct { + sync.RWMutex + + config *DiscordIntegrationConfig + key ed25519.PublicKey + session *discordgo.Session + http *httpServer + + gcis map[string]*gciState +} + +func NewDiscordIntegration(http *httpServer, config *DiscordIntegrationConfig) *DiscordIntegration { + keyBytes, err := hex.DecodeString(config.ApplicationKey) + if err != nil { + log.Panicf("Failed to decode discord application key: %v", err) + } + + session, err := discordgo.New("Bot " + config.Token) + if err != nil { + log.Panicf("Failed to initialize discord session: %v", err) + } + + key := ed25519.PublicKey(keyBytes) + + if config.Timeout == nil { + timeout := 60 + config.Timeout = &timeout + } + + if config.Reminder == nil { + reminder := 5 + config.Reminder = &reminder + } + + return &DiscordIntegration{ + key: key, + config: config, + session: session, + http: http, + gcis: make(map[string]*gciState), + } +} + +func respondWithMessage(w http.ResponseWriter, content string) { + gores.JSON(w, 200, discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + }, + }) +} + +func (d *DiscordIntegration) commandGCISunrise(w http.ResponseWriter, interaction *discordgo.Interaction, options []*discordgo.ApplicationCommandInteractionDataOption, userId string) { + server := options[0].Value.(string) + var notes string + if len(options) > 1 { + notes = options[1].Value.(string) + } + + d.RLock() + state, exists := d.gcis[userId] + d.RUnlock() + if exists { + respondWithMessage(w, fmt.Sprintf("You are already registered as an active GCI on %s", state.Server)) + return + } + + d.http.Lock() + _, exists = d.http.sessions[server] + d.http.Unlock() + if !exists { + respondWithMessage(w, fmt.Sprintf("No server named '%s'.", server)) + return + } + + dm, _ := d.session.UserChannelCreate(userId) + + d.Lock() + d.gcis[userId] = &gciState{ + DiscordId: userId, + Server: server, + Notes: notes, + ExpiresAt: time.Now().Add(time.Minute * time.Duration(*d.config.Timeout)), + DirectMessageId: dm.ID, + } + d.save() + d.Unlock() + + welcome := "You have been marked on-duty as an active GCI, good luck <:blobsalute:357248938933223434>" + if dm == nil { + welcome += fmt.Sprintf( + " (Warning: you have DMs disabled so the bot will not be able to warn you before your GCI session expires. Make sure to /gci refresh every %d minutes!", + *d.config.Timeout, + ) + } + + respondWithMessage(w, welcome) +} + +func (d *DiscordIntegration) commandGCISunset(w http.ResponseWriter, interaction *discordgo.Interaction, userId string) { + d.Lock() + _, ok := d.gcis[userId] + if !ok { + respondWithMessage(w, "You are not on-duty as a GCI.") + } else { + delete(d.gcis, userId) + d.save() + respondWithMessage(w, "You have been marked off-duty. Thanks for your service <:blobsalute:357248938933223434>") + } + d.Unlock() + +} + +func (d *DiscordIntegration) commandGCIRefresh(w http.ResponseWriter, interaction *discordgo.Interaction, userId string) { + d.Lock() + gci, ok := d.gcis[userId] + if !ok { + respondWithMessage(w, "You are not on-duty as a GCI.") + } else { + gci.ExpiresAt = time.Now().Add(time.Minute * time.Duration(*d.config.Timeout)) + gci.Warned = false + d.save() + respondWithMessage(w, fmt.Sprintf("Your GCI session has been refreshed for another %d minutes.", *d.config.Timeout)) + } + d.Unlock() + +} + +func (d *DiscordIntegration) commandSneakerStatus( + w http.ResponseWriter, + interaction *discordgo.Interaction, + options []*discordgo.ApplicationCommandInteractionDataOption, +) { + var serverName string + if len(options) == 0 { + if len(d.http.config.Servers) > 0 { + serverName = d.http.config.Servers[0].Name + } else { + respondWithMessage(w, fmt.Sprintf("No servers available to GCI on.")) + return + } + } else { + serverName = options[0].Value.(string) + } + + session, err := d.http.getOrCreateSession(serverName) + if err != nil { + respondWithMessage(w, fmt.Sprintf("No server named '%s'.", serverName)) + } else { + d.RLock() + gcis := []*gciState{} + for _, gci := range d.gcis { + if gci.Server == serverName { + gcis = append(gcis, gci) + } + } + d.RUnlock() + + playerList := session.GetPlayerList() + respondWithMessage(w, fmt.Sprintf( + "%s Status\n**Flying**: %d\n**GCI**: %s\n**Players**: \n```\n%s\n```", + strings.ToUpper(serverName), + len(playerList), + formatGCIList(gcis), + formatPlayerListTable(playerList), + )) + } + +} + +func (d *DiscordIntegration) commandGCIInfo(w http.ResponseWriter, interaction *discordgo.Interaction) { + d.RLock() + gcis := []*gciState{} + for _, gci := range d.gcis { + gcis = append(gcis, gci) + } + d.RUnlock() + respondWithMessage(w, fmt.Sprintf("**Active GCIs**: %s", formatGCIList(gcis))) +} + +func (d *DiscordIntegration) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !discordgo.VerifyInteraction(r, d.key) { + gores.Error(w, 400, "failed to verify request") + return + } + + var interaction discordgo.Interaction + err := json.NewDecoder(r.Body).Decode(&interaction) + if err != nil { + gores.Error(w, 400, "failed to decode request") + return + } + var userId string + if interaction.Member != nil { + userId = interaction.Member.User.ID + } else if interaction.User != nil { + userId = interaction.User.ID + } + + if interaction.Type == discordgo.InteractionPing { + gores.JSON(w, 200, discordgo.InteractionResponse{ + Type: discordgo.InteractionResponsePong, + }) + return + } else if interaction.Type == discordgo.InteractionApplicationCommand { + data := interaction.ApplicationCommandData() + + if data.Name == "gci" { + switch data.Options[0].Name { + case "info": + d.commandGCIInfo(w, &interaction) + case "sunrise": + d.commandGCISunrise(w, &interaction, data.Options[0].Options, userId) + case "sunset": + d.commandGCISunset(w, &interaction, userId) + case "refresh": + d.commandGCIRefresh(w, &interaction, userId) + } + } else if data.Name == "sneaker-status" { + d.commandSneakerStatus(w, &interaction, data.Options) + } + } else if interaction.Type == discordgo.InteractionMessageComponent { + data := interaction.MessageComponentData() + + if data.CustomID == "refresh-gci" { + gores.JSON(w, 200, discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredMessageUpdate, + Data: &discordgo.InteractionResponseData{}, + }) + + d.Lock() + gci, ok := d.gcis[userId] + if ok { + gci.ExpiresAt = time.Now().Add(time.Minute * time.Duration(*d.config.Timeout)) + gci.Warned = false + d.save() + + err = d.session.InteractionRespond( + &interaction, + &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "Your GCI session has been refreshed!", + }, + }, + ) + if err != nil { + log.Printf("error: failed to interact respond: %v", err) + } + } else { + d.session.InteractionRespond( + &interaction, + &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "No active GCI session.", + }, + }, + ) + } + d.Unlock() + } + return + } + + gores.NoContent(w) +} + +// Returns a copy of the GCI list for a given server +func (d *DiscordIntegration) GetGCIList(serverName string) []gciState { + d.RLock() + defer d.RUnlock() + result := []gciState{} + for _, gci := range d.gcis { + if gci.Server != serverName { + continue + } + + result = append(result, *gci) + } + return result +} + +func formatGCIList(gciList []*gciState) string { + if len(gciList) == 0 { + return "none" + } + + table := []string{} + for _, gci := range gciList { + table = append(table, fmt.Sprintf(" <@%s> - %v", gci.DiscordId, gci.Notes)) + } + + return strings.Join(table, "\n") +} + +func formatPlayerListTable(playerList []PlayerMetadata) string { + maxPlayerNameLength := 0 + for _, player := range playerList { + if len(player.Name) > maxPlayerNameLength { + maxPlayerNameLength = len(player.Name) + } + } + + table := []string{} + for _, player := range playerList { + name := player.Name + strings.Repeat(" ", maxPlayerNameLength-len(player.Name)) + table = append(table, fmt.Sprintf(" %v %v", name, player.Type)) + } + + return strings.Join(table, "\n") +} + +// assumes you have a write lock +func (d *DiscordIntegration) save() { + if d.config.StatePath == nil { + return + } + + data, err := json.Marshal(d.gcis) + if err != nil { + panic(err) + } + + err = ioutil.WriteFile(*d.config.StatePath, data, os.ModePerm) + if err != nil { + log.Printf("error: failed to save GCI state file: %v", err) + } +} + +func (d *DiscordIntegration) expireLoop() { + for { + d.Lock() + for id, gci := range d.gcis { + if gci.ExpiresAt.Before(time.Now().Add(time.Second * 10)) { + delete(d.gcis, id) + _, err := d.session.ChannelMessageSend( + gci.DirectMessageId, "Your GCI session has expired. Please re-sunrise if you are not done yet.", + ) + if err != nil { + log.Printf("warning: failed to send GCI expiry warning: %v", err) + continue + } + } else if gci.ExpiresAt.Before(time.Now().Add(time.Minute*time.Duration(*d.config.Reminder))) && !gci.Warned { + gci.Warned = true + + data := &discordgo.MessageSend{ + Content: fmt.Sprintf( + "Your GCI session expires in %d minutes! Please /gci refresh if you are not done yet.", + *d.config.Reminder, + ), + Components: []discordgo.MessageComponent{ + &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.Button{ + Label: "Refresh", + CustomID: "refresh-gci", + Style: discordgo.SuccessButton, + }, + }, + }, + }, + } + _, err := d.session.ChannelMessageSendComplex( + gci.DirectMessageId, + data, + ) + if err != nil { + log.Printf("warning: failed to send GCI expiry warning: %v", err) + continue + } + } + } + d.save() + d.Unlock() + + time.Sleep(time.Second * 60) + } +} + +func (d *DiscordIntegration) Setup() error { + _, err := d.session.ApplicationCommandCreate(d.config.ApplicationID, "", &discordgo.ApplicationCommand{ + Name: "gci", + Description: "List and control current GCI status", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "info", + Description: "Display information about the active GCI", + Type: discordgo.ApplicationCommandOptionSubCommand, + }, + { + Name: "sunrise", + Description: "Register yourself as an active GCI", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "server", + Description: "server name", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "notes", + Description: "Frequencies, coverage details, etc", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + }, + }, + { + Name: "sunset", + Description: "Delist yourself as an active GCI", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{}, + }, + }, + }) + if err != nil { + return err + } + + _, err = d.session.ApplicationCommandCreate(d.config.ApplicationID, "", &discordgo.ApplicationCommand{ + Name: "status", + Description: "Lists the current GCIs and players on a given server", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "server", + Description: "server name", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }) + if err != nil { + return err + } + + if d.config.StatePath != nil { + _, err := os.Stat(*d.config.StatePath) + + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + data, err := ioutil.ReadFile(*d.config.StatePath) + if err != nil { + return err + } + err = json.Unmarshal(data, &d.gcis) + if err != nil { + return err + } + } + } + + go d.expireLoop() + return nil +} diff --git a/server/http.go b/server/http.go index 52288b4..d1b815f 100644 --- a/server/http.go +++ b/server/http.go @@ -5,8 +5,8 @@ import ( "errors" "log" "net/http" - "strings" "sync" + "time" "github.com/alioygur/gores" "github.com/go-chi/chi/v5" @@ -19,6 +19,7 @@ type httpServer struct { config *Config sessions map[string]*serverSession + discord *DiscordIntegration } func newHttpServer(config *Config) *httpServer { @@ -28,63 +29,49 @@ func newHttpServer(config *Config) *httpServer { } } +func (h *httpServer) getServerMetadata(server *TacViewServerConfig) serverMetadata { + result := serverMetadata{ + Name: server.Name, + GroundUnitModes: getGroundUnitModes(server), + GCIs: []gciMetadata{}, + } + + session, err := h.getOrCreateSession(server.Name) + if err == nil { + result.Players = session.GetPlayerList() + } + + if h.discord != nil { + gciList := h.discord.GetGCIList(server.Name) + for _, gciState := range gciList { + result.GCIs = append(result.GCIs, gciMetadata{ + Id: gciState.DiscordId, + Notes: gciState.Notes, + ExpiresAt: gciState.ExpiresAt, + }) + } + } + + return result +} + // Returns a list of available servers func (h *httpServer) getServerList(w http.ResponseWriter, r *http.Request) { - result := make([]*tacViewServerMetadata, len(h.config.Servers)) + result := make([]serverMetadata, len(h.config.Servers)) for idx, server := range h.config.Servers { - result[idx] = &tacViewServerMetadata{ - Name: server.Name, - GroundUnitModes: getGroundUnitModes(&server), - } + // note: safe, we're not leaking this reference anywhere + result[idx] = h.getServerMetadata(&server) - session, err := h.getOrCreateSession(server.Name) - if err == nil { - players := []playerMetadata{} - session.state.RLock() - for _, object := range session.state.objects { - isPlayer := false - - for _, typeName := range object.Types { - if typeName == "Air" { - isPlayer = true - continue - } - } - if !isPlayer { - continue - } - - pilotName, ok := object.Properties["Pilot"] - if !ok { - continue - } - - if strings.HasPrefix(pilotName, object.Properties["Group"]) { - continue - } - - players = append(players, playerMetadata{ - Name: pilotName, - Type: object.Properties["Name"], - }) - } - session.state.RUnlock() - result[idx].Players = players - } } gores.JSON(w, 200, result) } -type playerMetadata struct { - Name string `json:"name"` - Type string `json:"type"` -} - -type tacViewServerMetadata struct { +type serverMetadata struct { Name string `json:"name"` GroundUnitModes []string `json:"ground_unit_modes"` - Players []playerMetadata `json:"players"` + Players []PlayerMetadata `json:"players"` + GCIs []gciMetadata `json:"gcis"` } func getGroundUnitModes(config *TacViewServerConfig) []string { @@ -98,8 +85,7 @@ func getGroundUnitModes(config *TacViewServerConfig) []string { return result } -// Return information about a specific server -func (h *httpServer) getServer(w http.ResponseWriter, r *http.Request) { +func (h *httpServer) ensureServer(w http.ResponseWriter, r *http.Request) *TacViewServerConfig { serverName := chi.URLParam(r, "serverName") var server *TacViewServerConfig @@ -111,13 +97,25 @@ func (h *httpServer) getServer(w http.ResponseWriter, r *http.Request) { } if server == nil { gores.Error(w, 404, "server not found") + return nil + } + return server +} + +type gciMetadata struct { + Id string `json:"id"` + Notes string `json:"notes"` + ExpiresAt time.Time `json:"expires_at"` +} + +// Return information about a specific server +func (h *httpServer) getServer(w http.ResponseWriter, r *http.Request) { + server := h.ensureServer(w, r) + if server == nil { return } - gores.JSON(w, 200, &tacViewServerMetadata{ - Name: server.Name, - GroundUnitModes: getGroundUnitModes(server), - }) + gores.JSON(w, 200, h.getServerMetadata(server)) } var errNoServerFound = errors.New("no server by that name was found") @@ -256,5 +254,20 @@ func Run(config *Config) error { r.Get("/api/servers/{serverName}", server.getServer) r.Get("/api/servers/{serverName}/events", server.streamServerEvents) + if config.Discord != nil { + server.discord = NewDiscordIntegration(server, config.Discord) + r.Handle("/api/discord/*", server.discord) + + err := server.discord.Setup() + if err != nil { + log.Panicf("Failed to setup discord integration: %v", err) + } + } + + log.Printf("Starting up %v Tacview clients", len(config.Servers)) + for _, serverConfig := range config.Servers { + server.getOrCreateSession(serverConfig.Name) + } + return http.ListenAndServe(config.Bind, r) } diff --git a/server/session.go b/server/session.go index 9a957d0..057a21b 100644 --- a/server/session.go +++ b/server/session.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" "log" + "strings" "sync" "time" ) @@ -34,6 +35,45 @@ func newServerSession(server *TacViewServerConfig) (*serverSession, error) { return &serverSession{server: server, subscribers: make(map[int]chan<- []byte)}, nil } +type PlayerMetadata struct { + Name string `json:"name"` + Type string `json:"type"` +} + +func (s *serverSession) GetPlayerList() []PlayerMetadata { + players := []PlayerMetadata{} + s.state.RLock() + for _, object := range s.state.objects { + isPlayer := false + + for _, typeName := range object.Types { + if typeName == "Air" { + isPlayer = true + continue + } + } + if !isPlayer { + continue + } + + pilotName, ok := object.Properties["Pilot"] + if !ok { + continue + } + + if strings.HasPrefix(pilotName, object.Properties["Group"]) { + continue + } + + players = append(players, PlayerMetadata{ + Name: pilotName, + Type: object.Properties["Name"], + }) + } + s.state.RUnlock() + return players +} + func (s *serverSession) updateLoop() { refreshRate := time.Duration(5) if s.server.RadarRefreshRate != 0 {