From d554cdc0875137258aeab46f0cf7b47d496ed457 Mon Sep 17 00:00:00 2001 From: = <=> Date: Sun, 2 Jun 2024 23:22:18 +0100 Subject: [PATCH 01/11] Add Ping/Pong Discord bot --- .gitignore | 2 ++ app.go | 29 +++++++++++++++++++++++++++++ go.mod | 2 ++ go.sum | 5 +++++ 4 files changed, 38 insertions(+) diff --git a/.gitignore b/.gitignore index 80ae255..9e47138 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ game/game.debug # Diff tool files *.orig + +.env \ No newline at end of file diff --git a/app.go b/app.go index e00cdf8..4913831 100644 --- a/app.go +++ b/app.go @@ -1,9 +1,13 @@ package main import ( + "fmt" + "log" "net/http" "net/url" + "os" + "github.com/bwmarrin/discordgo" "github.com/gorilla/mux" "github.com/zond/diplicity/routes" "google.golang.org/appengine/v2" @@ -12,6 +16,31 @@ import ( ) func main() { + + discord, err := discordgo.New("Bot " + os.Getenv("DISCORD_BOT_TOKEN")) + if err != nil { + log.Fatal(err) + } + + discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.ID == s.State.User.ID { + return + } + if m.Content == "ping" { + s.ChannelMessageSend(m.ChannelID, "pong") + } + }) + + discord.Identify.Intents = discordgo.IntentsAllWithoutPrivileged + + err = discord.Open() + if err != nil { + log.Fatal(err) + } + defer discord.Close() + + fmt.Println("Discord bot is now running!") + jsonFormURL, err := url.Parse("/js/jsonform.js") if err != nil { panic(err) diff --git a/go.mod b/go.mod index a6a5dd9..6539e0f 100644 --- a/go.mod +++ b/go.mod @@ -28,10 +28,12 @@ require ( require ( cloud.google.com/go v0.38.0 // indirect + github.com/bwmarrin/discordgo v0.28.1 // indirect github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 // indirect github.com/golang/protobuf v1.5.0 // indirect github.com/googleapis/gax-go/v2 v2.0.5 // indirect github.com/gorilla/schema v1.2.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/hashicorp/golang-lru v0.5.1 // indirect github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect github.com/kr/text v0.1.0 // indirect diff --git a/go.sum b/go.sum index 2e98b1a..9d2c5c8 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/VividCortex/ewma v1.1.1 h1:MnEK4VOv6n0RSY4vtRe3h11qjxL3+t0B8yOL8iMXdc github.com/VividCortex/ewma v1.1.1/go.mod h1:2Tkkvm3sRDVXaiyucHiACn4cqf7DpdyLvmxzcbUokwA= github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0= github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cheggaaa/pb/v3 v3.0.8 h1:bC8oemdChbke2FHIIGy9mn4DPJ2caZYQnfbRqwmdCoA= github.com/cheggaaa/pb/v3 v3.0.8/go.mod h1:UICbiLec/XO6Hw6k+BHEtHeQFzzBH4i2/qk/ow1EJTA= @@ -59,6 +61,8 @@ github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB7 github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +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/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= @@ -119,6 +123,7 @@ github.com/zond/replace v0.0.0-20180415193355-5a1dc330b27e/go.mod h1:J7mWP0y029F go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= From a13cfbbaefa9dac7e6892e77f2edad40bc01bcb6 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 3 Jun 2024 10:02:53 +0100 Subject: [PATCH 02/11] wip --- app.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/app.go b/app.go index 4913831..8e2eb3e 100644 --- a/app.go +++ b/app.go @@ -10,11 +10,58 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gorilla/mux" "github.com/zond/diplicity/routes" + "github.com/zond/godip/variants" "google.golang.org/appengine/v2" . "github.com/zond/goaeoas" ) +var ( + commands = []discordgo.ApplicationCommand{ + { + Name: "create-order", + Description: "Create a new order", + }, + { + Name: "create-game", + Description: "Create a new game", + } + } + commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "create-order": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Title: "Create Order", + Components: []discordgo.MessageComponent{ + &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: "source", + Placeholder: "Select a unit to move", + Options: []discordgo.SelectMenuOption{ + { + Label: "Army Berlin", + Value: "berlin", + }, + { + Label: "Fleet Kiel", + Value: "kiel", + }, + }, + }, + }, + }, + }, + }, + }) + if err != nil { + panic(err) + } + }, + } +) + func main() { discord, err := discordgo.New("Bot " + os.Getenv("DISCORD_BOT_TOKEN")) @@ -22,6 +69,8 @@ func main() { log.Fatal(err) } + // Note these are dummy handlers for now. Will create a separate package + // for discord bot handlers which will do actually useful stuff. discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { if m.Author.ID == s.State.User.ID { return @@ -29,8 +78,36 @@ func main() { if m.Content == "ping" { s.ChannelMessageSend(m.ChannelID, "pong") } + if m.Content == "list variants" { + // iterate over variants and create a list of variant names + variantNames := "" + for _, variant := range variants.Variants { + variantNames += variant.Name + "\n" + } + s.ChannelMessageSend(m.ChannelID, variantNames) + } + }) + + discord.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + } }) + cmdIDs := make(map[string]string, len(commands)) + + for _, cmd := range commands { + rcmd, err := discord.ApplicationCommandCreate("1246942452791644281", "", &cmd) + if err != nil { + log.Fatalf("Cannot create slash command %q: %v", cmd.Name, err) + } + + cmdIDs[rcmd.ID] = rcmd.Name + } + discord.Identify.Intents = discordgo.IntentsAllWithoutPrivileged err = discord.Open() From 1eef9bfaba1f477e75611cf03b88ec68e926f34a Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 3 Jun 2024 12:10:22 +0100 Subject: [PATCH 03/11] Move Dockerfile into subdirectory --- Dockerfile => .docker/Dockerfile | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Dockerfile => .docker/Dockerfile (100%) diff --git a/Dockerfile b/.docker/Dockerfile similarity index 100% rename from Dockerfile rename to .docker/Dockerfile From 8b63c4e74ce808a2072125e9588d170e3acefc59 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 3 Jun 2024 12:36:57 +0100 Subject: [PATCH 04/11] Add Powershell scripts to run Docker correctly --- .docker/Dockerfile | 4 +-- .docker/docker-build.ps1 | 18 ++++++++++++ .docker/docker-create-network.ps1 | 10 +++++++ .docker/docker-run.ps1 | 47 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 .docker/docker-build.ps1 create mode 100644 .docker/docker-create-network.ps1 create mode 100644 .docker/docker-run.ps1 diff --git a/.docker/Dockerfile b/.docker/Dockerfile index c8f5e4d..3e6f461 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -22,10 +22,10 @@ ENV PATH $PATH:/usr/local/gcloud/google-cloud-sdk/bin WORKDIR /go/src/app # Copy the current directory contents into the container at /go/src/app -COPY . . +COPY ../ . # Remove Dockerfile to prevent issue with App Engine -RUN rm Dockerfile +# RUN rm Dockerfile # Expose port 8080 to the outside world EXPOSE 8080 diff --git a/.docker/docker-build.ps1 b/.docker/docker-build.ps1 new file mode 100644 index 0000000..723b852 --- /dev/null +++ b/.docker/docker-build.ps1 @@ -0,0 +1,18 @@ +# Description: Build docker image from Dockerfile + +Write-Host "Building Docker image..." + +# Confirm that the script is being executed from the correct directory +if (-not (Test-Path ".docker/docker-run.ps1")) { + Write-Host "You must run this command from the parent directory of this script." + exit +} + +Write-Host "Confirmed that script is being executed from the correct directory." + +$dockerCommand = "docker build --tag 'diplicity' ./.docker" + +Write-Host "Running Docker command: $dockerCommand" + +# Execute the Docker command +Invoke-Expression $dockerCommand \ No newline at end of file diff --git a/.docker/docker-create-network.ps1 b/.docker/docker-create-network.ps1 new file mode 100644 index 0000000..e5d477c --- /dev/null +++ b/.docker/docker-create-network.ps1 @@ -0,0 +1,10 @@ +# Description: Create a new docker network called my-net + +Write-Host "Creating Docker network..." + +$dockerCommand = "docker network create -d bridge my-net" + +Write-Host "Running Docker command: $dockerCommand" + +# Execute the Docker command +Invoke-Expression $dockerCommand \ No newline at end of file diff --git a/.docker/docker-run.ps1 b/.docker/docker-run.ps1 new file mode 100644 index 0000000..dcbf720 --- /dev/null +++ b/.docker/docker-run.ps1 @@ -0,0 +1,47 @@ +# Ensure that this command is being called from the parent directory +# of the Dockerfile. This is necessary for the volume mapping to work +# correctly. + +Write-Host "Running Docker container..." + +# Confirm that the script is being executed from the correct directory +if (-not (Test-Path ".docker/docker-run.ps1")) { + Write-Host "You must run this command from the parent directory of this script." + exit +} + +Write-Host "Confirmed that script is being executed from the correct directory." + +# Base docker run command +$dockerCommand = "docker run" + +# Mount a volume from the host machine to the container. This means +# that the container will have access to the files in the host machine +# and that changes made in the host machine will be reflected in the +# container. +$dockerCommand += " -v .:/go/src/app:ro" # Add volume mapping + +# Specify the network that the container should be connected to. This +# is necessary for the Discord bot to be able to work correctly. +$dockerCommand += " --network my-net" # Specify the network + +# Specify the environment file that should be used by the container. This +# file contains secret environment variables that the application needs +# to run correctly, e.g. DISCORD_BOT_TOKEN. +$dockerCommand += " --env-file ./.env" # Specify the environment file + +# Specify that the 8080 port should be exposed to the host machine. This +# is the port that the API application listens on. +$dockerCommand += " -p 8080:8080" # Map port 8080 + +# Specify that the 8000 port should be exposed to the host machine. This +# is the port that the admin application listens on. +$dockerCommand += " -p 8000:8000" # Map port 8000 + +# Specify the image that should be used to create the container. This +$dockerCommand += " diplicity" + +Write-Host "Running Docker command: $dockerCommand" + +# Execute the Docker command +Invoke-Expression $dockerCommand \ No newline at end of file From 05166fb96d582dcb59e2dcc5900b9499b7bcd908 Mon Sep 17 00:00:00 2001 From: = <=> Date: Mon, 3 Jun 2024 18:29:58 +0100 Subject: [PATCH 05/11] Add tests --- app.go | 47 +---- discord/api/create_order.go | 122 ++++++++++++ discord/handlers/create_order.go | 260 ++++++++++++++++++++++++++ discord/handlers/create_order_test.go | 88 +++++++++ discord/handlers/mock_create_order.go | 92 +++++++++ go.mod | 3 +- go.sum | 6 + 7 files changed, 576 insertions(+), 42 deletions(-) create mode 100644 discord/api/create_order.go create mode 100644 discord/handlers/create_order.go create mode 100644 discord/handlers/create_order_test.go create mode 100644 discord/handlers/mock_create_order.go diff --git a/app.go b/app.go index 8e2eb3e..215af30 100644 --- a/app.go +++ b/app.go @@ -9,6 +9,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gorilla/mux" + "github.com/zond/diplicity/discord/handlers" "github.com/zond/diplicity/routes" "github.com/zond/godip/variants" "google.golang.org/appengine/v2" @@ -17,48 +18,12 @@ import ( ) var ( - commands = []discordgo.ApplicationCommand{ - { - Name: "create-order", - Description: "Create a new order", - }, - { - Name: "create-game", - Description: "Create a new game", - } + APPLICATION_ID = "1246942452791644281" + commands = []discordgo.ApplicationCommand{ + handlers.CreateOrderCommand, } commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "create-order": func(s *discordgo.Session, i *discordgo.InteractionCreate) { - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Title: "Create Order", - Components: []discordgo.MessageComponent{ - &discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - &discordgo.SelectMenu{ - CustomID: "source", - Placeholder: "Select a unit to move", - Options: []discordgo.SelectMenuOption{ - { - Label: "Army Berlin", - Value: "berlin", - }, - { - Label: "Fleet Kiel", - Value: "kiel", - }, - }, - }, - }, - }, - }, - }, - }) - if err != nil { - panic(err) - } - }, + "create-order": handlers.CreateOrderCommandHandler, } ) @@ -100,7 +65,7 @@ func main() { cmdIDs := make(map[string]string, len(commands)) for _, cmd := range commands { - rcmd, err := discord.ApplicationCommandCreate("1246942452791644281", "", &cmd) + rcmd, err := discord.ApplicationCommandCreate(APPLICATION_ID, "", &cmd) if err != nil { log.Fatalf("Cannot create slash command %q: %v", cmd.Name, err) } diff --git a/discord/api/create_order.go b/discord/api/create_order.go new file mode 100644 index 0000000..0ff4514 --- /dev/null +++ b/discord/api/create_order.go @@ -0,0 +1,122 @@ +package api + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +// This file is a facade between the discord package and the backend +// logic. It helps make the Discord bot easier to maintain and +// understand by separating the command logic from the Discord +// bot logic. + +// TODO implement real facade + +// listSourceUnits is a function that returns a list of units that +// the user can select as the source unit for the order. +var ListAvailableSourceUnits = func(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) { + // TODO implement + return []discordgo.SelectMenuOption{ + { + Label: "Army Berlin", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"source": "berlin"}`), + }, + { + Label: "Fleet Kiel", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"source": "kiel"}`), + }, + { + Label: "Army Munich", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"source": "munich"}`), + }, + }, nil +} + +var ListAvailableOrderTypes = func(userID, gameID, phaseID string, source string) []discordgo.SelectMenuOption { + return []discordgo.SelectMenuOption{ + { + Label: "Move", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"type": "move", "source": "%s"}`, source), + }, + { + Label: "Support", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"type": "support", "source": "%s"}`, source), + }, + { + Label: "Hold", + // TODO move this to Discord layer + Value: fmt.Sprintf(`{"type": "hold", "source": "%s"}`, source), + }, + { + Label: "Convoy", + Value: fmt.Sprintf(`{"type": "convoy", "source": "%s"}`, source), + }, + } +} + +var ListAvailableAuxUnits = func(userID, gameID, phaseID string, source string, orderType string) []discordgo.SelectMenuOption { + return []discordgo.SelectMenuOption{ + { + Label: "Army Berlin", + Value: "berlin", + }, + { + Label: "Fleet Kiel", + Value: "kiel", + }, + { + Label: "Army Munich", + Value: "munich", + }, + } +} + +var ListAvailableDestinations = func(userID, gameID, phaseID string, source string, orderType string) []discordgo.SelectMenuOption { + return []discordgo.SelectMenuOption{ + { + Label: "Berlin", + Value: "berlin", + }, + { + Label: "Kiel", + Value: "kiel", + }, + { + Label: "Munich", + Value: "munich", + }, + } +} + +var ListAvailableAuxDestinations = func(userID, gameID, phaseID string, source string, orderType string, auxUnit string) []discordgo.SelectMenuOption { + return []discordgo.SelectMenuOption{ + { + Label: "Berlin", + Value: "berlin", + }, + { + Label: "Kiel", + Value: "kiel", + }, + { + Label: "Munich", + Value: "munich", + }, + } +} + +var AuxUnitRequired = func(orderType string) bool { + // return true if orderType is support or convoy + return orderType == "support" || orderType == "convoy" +} + +var DestinationRequired = func(orderType string) bool { + // return false if orderType is hold + return orderType != "hold" +} diff --git a/discord/handlers/create_order.go b/discord/handlers/create_order.go new file mode 100644 index 0000000..d037bda --- /dev/null +++ b/discord/handlers/create_order.go @@ -0,0 +1,260 @@ +package handlers + +// This file container the handler for the create-order command +// and subsequent interactions + +import ( + "github.com/bwmarrin/discordgo" +) + +// Declare a Discord application command +var CreateOrderCommand = discordgo.ApplicationCommand{ + Name: "create-order", + Description: "Create a new order", +} + +type OrderData struct { + Source string + OrderType string + AuxUnit string + Destination string +} + +type Session interface { + InteractionRespond(interaction *discordgo.Interaction, response *discordgo.InteractionResponse) error +} + +type InteractionCreate struct { + Interaction *discordgo.Interaction + Member *discordgo.Member + Type discordgo.InteractionType +} + +type Api interface { + ListAvailableSourceUnits(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) +} + +func NewCreateOrderCommandHandler(s Session, i InteractionCreate, api Api) { + options, err := api.ListAvailableSourceUnits(i.Member.User.ID, "gameID", "phaseID") + + if err != nil { + panic(err) + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Title: "Create Order", + Components: []discordgo.MessageComponent{ + &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: "source", + Placeholder: "Select a unit to move", + Options: options, + }, + }, + }, + }, + }, + }) + if err != nil { + panic(err) + } +} + +// // Executed when the Discord command is invoked by a user +// func CreateOrderCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { +// // TODO add mechanism to ensure that command is being called from the +// // correct channel OR check if command can be added such that it is only +// // available in some channels + +// // TODO add mechanism to ensure that command is being called by a user +// // who is in the game (role). If not, return an error message + +// options := api.ListAvailableSourceUnits(i.Member.User.ID, "gameID", "phaseID") + +// // TODO add mechanism to return error message if there are no units +// // to order + +// orderData := OrderData{} + +// // check that interaction is ApplicationCommand type +// if i.Type == discordgo.InteractionApplicationCommand { +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Title: "Create Order", +// Components: []discordgo.MessageComponent{ +// &discordgo.ActionsRow{ +// Components: []discordgo.MessageComponent{ +// &discordgo.SelectMenu{ +// CustomID: "source", +// Placeholder: "Select a unit to move", +// Options: options, +// }, +// }, +// }, +// }, +// }, +// }) +// if err != nil { +// panic(err) +// } +// } + +// // Add a handler for the InteractionCreate event +// s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { +// // Check that the interaction is not the ApplicationCommand type +// if i.Type != discordgo.InteractionApplicationCommand { +// // Check if the interaction is a select menu interaction +// if i.MessageComponentData().CustomID == "source" { +// // Get the selected option +// selectedOption := i.MessageComponentData().Values[0] +// orderData.Source = selectedOption + +// // Get the available order types for the selected unit +// options := api.ListAvailableOrderTypes(i.Member.User.ID, "gameID", "phaseID", selectedOption) + +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Title: "Create Order", +// Components: []discordgo.MessageComponent{ +// &discordgo.ActionsRow{ +// Components: []discordgo.MessageComponent{ +// &discordgo.SelectMenu{ +// CustomID: "order-type", +// Placeholder: "Select an order type", +// Options: options, +// }, +// }, +// }, +// }, +// }, +// }) +// if err != nil { +// panic(err) +// } +// } else if i.MessageComponentData().CustomID == "order-type" { +// // Get the selected option +// selectedOption := i.MessageComponentData().Values[0] +// orderData.OrderType = selectedOption + +// // Check if an aux unit is required for the selected order type +// auxUnitRequired := api.AuxUnitRequired(orderData.OrderType) + +// // Check if a destination is required for the selected order type +// destinationRequired := api.DestinationRequired(orderData.OrderType) + +// if auxUnitRequired { +// options := api.ListAvailableAuxUnits(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType) +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Title: "Create Order", +// Components: []discordgo.MessageComponent{ +// &discordgo.ActionsRow{ +// Components: []discordgo.MessageComponent{ +// &discordgo.SelectMenu{ +// CustomID: "aux-unit", +// Placeholder: "Select an aux unit", +// Options: options, +// }, +// }, +// }, +// }, +// }, +// }) +// if err != nil { +// panic(err) +// } +// } else if destinationRequired { +// options := api.ListAvailableDestinations(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType) +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Title: "Create Order", +// Components: []discordgo.MessageComponent{ +// &discordgo.ActionsRow{ +// Components: []discordgo.MessageComponent{ +// &discordgo.SelectMenu{ +// CustomID: "destination", +// Placeholder: "Select a destination", +// Options: options, +// }, +// }, +// }, +// }, +// }, +// }) + +// if err != nil { +// panic(err) +// } +// } else { +// // Create string representation of the order +// orderString := fmt.Sprintf("%s %s", orderData.Source, orderData.OrderType) +// // TODO add mechanism to create the order + +// // Clear the order data +// orderData = OrderData{} +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Content: fmt.Sprintf("Order created: %s", orderString), +// }, +// }) +// if err != nil { +// panic(err) +// } +// } +// } else if i.MessageComponentData().CustomID == "aux-unit" { +// // Get the selected option +// selectedOption := i.MessageComponentData().Values[0] +// orderData.AuxUnit = selectedOption + +// options := api.ListAvailableAuxDestinations(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType, orderData.AuxUnit) +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Title: "Create Order", +// Components: []discordgo.MessageComponent{ +// &discordgo.ActionsRow{ +// Components: []discordgo.MessageComponent{ +// &discordgo.SelectMenu{ +// CustomID: "destination", +// Placeholder: "Select a destination", +// Options: options, +// }, +// }, +// }, +// }, +// }, +// }) + +// if err != nil { +// panic(err) +// } +// } else if i.MessageComponentData().CustomID == "destination" { +// // Get the selected option +// selectedOption := i.MessageComponentData().Values[0] +// orderData.Destination = selectedOption +// orderString := fmt.Sprintf("%s %s %s %s", orderData.Source, orderData.OrderType, orderData.AuxUnit, orderData.Destination) + +// // TODO add mechanism to create the order +// // Clear the order data +// orderData = OrderData{} +// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ +// Type: discordgo.InteractionResponseChannelMessageWithSource, +// Data: &discordgo.InteractionResponseData{ +// Content: fmt.Sprintf("Order created: %s", orderString), +// }, +// }) +// if err != nil { +// panic(err) +// } +// } +// } +// }) +// } diff --git a/discord/handlers/create_order_test.go b/discord/handlers/create_order_test.go new file mode 100644 index 0000000..840b2dd --- /dev/null +++ b/discord/handlers/create_order_test.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "errors" + "testing" + + "github.com/bwmarrin/discordgo" + gomock "go.uber.org/mock/gomock" +) + +func expectPanic(t *testing.T) { + if r := recover(); r == nil { + t.Errorf("expected panic") + } +} + +var mockMember = &discordgo.Member{ + User: &discordgo.User{ + ID: "123", + }, +} + +var mockUnitOptions = []discordgo.SelectMenuOption{ + { + Label: "Army Berlin", + Value: `{"source": "berlin"}`, + }, +} + +var expectedResponse = &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Title: "Create Order", + Components: []discordgo.MessageComponent{ + &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: "source", + Placeholder: "Select a unit to move", + Options: []discordgo.SelectMenuOption{ + { + Label: "Army Berlin", + Value: `{"source": "berlin"}`, + }, + }, + }, + }, + }, + }, + }, +} + +var mockInteraction = InteractionCreate{ + Member: mockMember, + Interaction: &discordgo.Interaction{}, +} + +func TestCreateOrderCommandHandler(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockApi := NewMockApi(mockCtrl) + mockApi.EXPECT().ListAvailableSourceUnits(gomock.Eq(mockMember.User.ID), gomock.Eq("gameID"), gomock.Eq("phaseID")).Return(mockUnitOptions, nil).AnyTimes() + + mockSession := NewMockSession(mockCtrl) + + t.Run("Calls InteractionRespond with expected response", func(t *testing.T) { + mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Eq(expectedResponse)).Return(nil) + NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) + }) + + t.Run("Panics if InteractionRespond returns an error", func(t *testing.T) { + mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Any()).Return(errors.New("Custom test error")) + + defer expectPanic(t) + + NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) + }) + + t.Run("Panics if ListAvailableSourceUnits returns an error", func(t *testing.T) { + mockApi.EXPECT().ListAvailableSourceUnits(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("Custom test error")).AnyTimes() + mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Any()).Return(errors.New("Custom test error")) + + defer expectPanic(t) + + NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) + }) +} diff --git a/discord/handlers/mock_create_order.go b/discord/handlers/mock_create_order.go new file mode 100644 index 0000000..98401d2 --- /dev/null +++ b/discord/handlers/mock_create_order.go @@ -0,0 +1,92 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: create_order.go +// +// Generated by this command: +// +// mockgen -source=create_order.go -destination=mock_create_order.go -package=handlers +// + +// Package handlers is a generated GoMock package. +package handlers + +import ( + reflect "reflect" + + discordgo "github.com/bwmarrin/discordgo" + gomock "go.uber.org/mock/gomock" +) + +// MockSession is a mock of Session interface. +type MockSession struct { + ctrl *gomock.Controller + recorder *MockSessionMockRecorder +} + +// MockSessionMockRecorder is the mock recorder for MockSession. +type MockSessionMockRecorder struct { + mock *MockSession +} + +// NewMockSession creates a new mock instance. +func NewMockSession(ctrl *gomock.Controller) *MockSession { + mock := &MockSession{ctrl: ctrl} + mock.recorder = &MockSessionMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSession) EXPECT() *MockSessionMockRecorder { + return m.recorder +} + +// InteractionRespond mocks base method. +func (m *MockSession) InteractionRespond(interaction *discordgo.Interaction, response *discordgo.InteractionResponse) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InteractionRespond", interaction, response) + ret0, _ := ret[0].(error) + return ret0 +} + +// InteractionRespond indicates an expected call of InteractionRespond. +func (mr *MockSessionMockRecorder) InteractionRespond(interaction, response any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InteractionRespond", reflect.TypeOf((*MockSession)(nil).InteractionRespond), interaction, response) +} + +// MockApi is a mock of Api interface. +type MockApi struct { + ctrl *gomock.Controller + recorder *MockApiMockRecorder +} + +// MockApiMockRecorder is the mock recorder for MockApi. +type MockApiMockRecorder struct { + mock *MockApi +} + +// NewMockApi creates a new mock instance. +func NewMockApi(ctrl *gomock.Controller) *MockApi { + mock := &MockApi{ctrl: ctrl} + mock.recorder = &MockApiMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockApi) EXPECT() *MockApiMockRecorder { + return m.recorder +} + +// ListAvailableSourceUnits mocks base method. +func (m *MockApi) ListAvailableSourceUnits(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListAvailableSourceUnits", userID, gameID, phaseID) + ret0, _ := ret[0].([]discordgo.SelectMenuOption) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListAvailableSourceUnits indicates an expected call of ListAvailableSourceUnits. +func (mr *MockApiMockRecorder) ListAvailableSourceUnits(userID, gameID, phaseID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAvailableSourceUnits", reflect.TypeOf((*MockApi)(nil).ListAvailableSourceUnits), userID, gameID, phaseID) +} diff --git a/go.mod b/go.mod index 6539e0f..14ec1b3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/dustin/go-humanize v1.0.0 github.com/gorilla/feeds v1.1.1 - github.com/gorilla/mux v1.8.0 + github.com/gorilla/mux v1.8.1 github.com/jmoiron/jsonq v0.0.0-20150511023944-e874b168d07e github.com/kr/pretty v0.2.0 github.com/kvannotten/mailstrip v0.0.0-20181210132851-650244c72ccd @@ -44,6 +44,7 @@ require ( github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/stretchr/testify v1.5.1 // indirect go.opencensus.io v0.21.0 // indirect + go.uber.org/mock v0.4.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.7.0 // indirect google.golang.org/appengine v1.6.7 // indirect diff --git a/go.sum b/go.sum index 9d2c5c8..9289c95 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ewohltman/discordgo-mock v0.0.11 h1:aRbgVXLFeoSLMCJjO7GyDkXHdMKZh6mVSEKxv73gDyY= +github.com/ewohltman/discordgo-mock v0.0.11/go.mod h1:tu+6ymSz5JKvySUmv7/Q2Oh+aXgxGPir7ZfOz9gfadM= github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= @@ -58,6 +60,8 @@ github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBba github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= @@ -122,6 +126,8 @@ github.com/zond/godip v0.6.4/go.mod h1:wmbPxr4nyQ87BDh3/wiEUCYj2lTTp/OK7p88gRDMj github.com/zond/replace v0.0.0-20180415193355-5a1dc330b27e/go.mod h1:J7mWP0y029F6sVX7sUb472GkhlBSg1u1W2T0yzU/HKg= go.opencensus.io v0.21.0 h1:mU6zScU4U1YAFPHEHYk+3JC4SY7JxgkqS10ZOSyksNg= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= From f2b9093195663633820586ea303bdbe8c3d208e0 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:29:54 +0100 Subject: [PATCH 06/11] Add discord functionality --- app.go | 83 +--- discord/api/api.go | 165 ++++++++ discord/api/create_order.go | 122 ------ discord/api/request.go | 36 ++ discord/api/util.go | 104 +++++ discord/handlers/create_game.go | 62 +++ discord/handlers/create_order.go | 529 +++++++++++++++----------- discord/handlers/create_order_test.go | 88 ----- discord/handlers/mock_create_order.go | 92 ----- discord/handlers/register.go | 68 ++++ discord/handlers/util.go | 63 +++ 11 files changed, 812 insertions(+), 600 deletions(-) create mode 100644 discord/api/api.go delete mode 100644 discord/api/create_order.go create mode 100644 discord/api/request.go create mode 100644 discord/api/util.go create mode 100644 discord/handlers/create_game.go delete mode 100644 discord/handlers/create_order_test.go delete mode 100644 discord/handlers/mock_create_order.go create mode 100644 discord/handlers/register.go create mode 100644 discord/handlers/util.go diff --git a/app.go b/app.go index 215af30..c475f5f 100644 --- a/app.go +++ b/app.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "log" "net/http" "net/url" @@ -9,80 +8,16 @@ import ( "github.com/bwmarrin/discordgo" "github.com/gorilla/mux" + "github.com/zond/diplicity/discord/api" "github.com/zond/diplicity/discord/handlers" "github.com/zond/diplicity/routes" - "github.com/zond/godip/variants" "google.golang.org/appengine/v2" . "github.com/zond/goaeoas" ) -var ( - APPLICATION_ID = "1246942452791644281" - commands = []discordgo.ApplicationCommand{ - handlers.CreateOrderCommand, - } - commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "create-order": handlers.CreateOrderCommandHandler, - } -) - func main() { - discord, err := discordgo.New("Bot " + os.Getenv("DISCORD_BOT_TOKEN")) - if err != nil { - log.Fatal(err) - } - - // Note these are dummy handlers for now. Will create a separate package - // for discord bot handlers which will do actually useful stuff. - discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { - if m.Author.ID == s.State.User.ID { - return - } - if m.Content == "ping" { - s.ChannelMessageSend(m.ChannelID, "pong") - } - if m.Content == "list variants" { - // iterate over variants and create a list of variant names - variantNames := "" - for _, variant := range variants.Variants { - variantNames += variant.Name + "\n" - } - s.ChannelMessageSend(m.ChannelID, variantNames) - } - }) - - discord.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - switch i.Type { - case discordgo.InteractionApplicationCommand: - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) - } - } - }) - - cmdIDs := make(map[string]string, len(commands)) - - for _, cmd := range commands { - rcmd, err := discord.ApplicationCommandCreate(APPLICATION_ID, "", &cmd) - if err != nil { - log.Fatalf("Cannot create slash command %q: %v", cmd.Name, err) - } - - cmdIDs[rcmd.ID] = rcmd.Name - } - - discord.Identify.Intents = discordgo.IntentsAllWithoutPrivileged - - err = discord.Open() - if err != nil { - log.Fatal(err) - } - defer discord.Close() - - fmt.Println("Discord bot is now running!") - jsonFormURL, err := url.Parse("/js/jsonform.js") if err != nil { panic(err) @@ -101,5 +36,21 @@ func main() { router := mux.NewRouter() routes.Setup(router) http.Handle("/", router) + + apiImpl := api.CreateApi() + session, err := discordgo.New("Bot " + os.Getenv("DISCORD_BOT_TOKEN")) + if err != nil { + log.Fatalf("Cannot create Discord session: %v", err) + } + handlers.RegisterHandlers(session, apiImpl) + log.Println("Discord initialization complete! Starting session...") + + err = session.Open() + if err != nil { + log.Fatal(err) + } + + defer session.Close() + appengine.Main() } diff --git a/discord/api/api.go b/discord/api/api.go new file mode 100644 index 0000000..06bc3b5 --- /dev/null +++ b/discord/api/api.go @@ -0,0 +1,165 @@ +package api + +// Api is a facade between the discord package and the backend + +import ( + "log" + + "github.com/zond/diplicity/game" +) + +type Province struct { + Name string + Key string + UnitType string +} + +type OrderType struct { + Name string + Key string +} + +type Api struct { +} + +func (a *Api) SourceProvinces(userId, channelId string) ([]Province, error) { + return []Province{ + { + Name: "Berlin", + Key: "berlin", + UnitType: "Army", + }, + { + Name: "Kiel", + Key: "kiel", + UnitType: "Fleet", + }, + { + Name: "Munich", + Key: "munich", + UnitType: "Army", + }, + }, nil +} + +func (a *Api) OrderTypes(userId, channelId, source string) ([]OrderType, error) { + if source == "" { + return []OrderType{}, nil + } + return []OrderType{ + { + Name: "Hold", + Key: "hold", + }, + { + Name: "Move", + Key: "move", + }, + { + Name: "Support", + Key: "support", + }, + { + Name: "Convoy", + Key: "convoy", + }, + }, nil +} + +func (a *Api) DestinationProvinces(userId, channelID, source, orderType string) ([]Province, error) { + if source == "" || orderType == "" { + return []Province{}, nil + } + return []Province{ + { + Name: "Berlin", + Key: "berlin", + }, + { + Name: "Kiel", + Key: "kiel", + }, + { + Name: "Munich", + Key: "munich", + }, + }, nil +} + +func (a *Api) AuxProvinces(userId, channelId, source, orderType string) ([]Province, error) { + if source == "" || orderType == "" { + return []Province{}, nil + } + return []Province{ + { + Name: "Berlin", + Key: "berlin", + }, + { + Name: "Kiel", + Key: "kiel", + }, + { + Name: "Munich", + Key: "munich", + }, + }, nil +} + +func (a *Api) AuxDestinationProvinces(userId, channelId, source, orderType, auxUnit string) ([]Province, error) { + if source == "" || orderType == "" || auxUnit == "" { + return []Province{}, nil + } + return []Province{ + { + Name: "Berlin", + Key: "berlin", + }, + { + Name: "Kiel", + Key: "kiel", + }, + { + Name: "Munich", + Key: "munich", + }, + }, nil +} + +func (a *Api) CreateOrder(userId, channelId string) (*game.Order, error) { + gameId := "gameId" // TODO get game from channelId and get gameId from game + phaseOrdinal := "phaseOrdinal" // TODO get game from channelId and get phaseOrdinal from game + vars := map[string]string{ + "game_id": gameId, + "phase_ordinal": phaseOrdinal, + } + request, err := CreateAuthenticatedRequest(userId, vars, "") + if err != nil { + return nil, err + } + + return game.CreateOrder(nil, request) +} + +func (a *Api) CreateGame(userId, channelId string) (*game.Game, error) { + log.Printf("api.CreateGame invoked\n") + + newGame := NewGameDefaultValues + newGame.Desc = channelId // All new games are created with the channel ID as the name + + log.Printf("Creating game with values: %+v\n", newGame) + + vars := map[string]string{} + + request, err := CreateAuthenticatedRequest(userId, vars, NewGameDefaultValues) + if err != nil { + return nil, err + } + + log.Printf("Calling game.CreateGame with request: %+v\n", request) + return game.CreateGame(nil, request) +} + +func CreateApi() *Api { + return &Api{} +} diff --git a/discord/api/create_order.go b/discord/api/create_order.go deleted file mode 100644 index 0ff4514..0000000 --- a/discord/api/create_order.go +++ /dev/null @@ -1,122 +0,0 @@ -package api - -import ( - "fmt" - - "github.com/bwmarrin/discordgo" -) - -// This file is a facade between the discord package and the backend -// logic. It helps make the Discord bot easier to maintain and -// understand by separating the command logic from the Discord -// bot logic. - -// TODO implement real facade - -// listSourceUnits is a function that returns a list of units that -// the user can select as the source unit for the order. -var ListAvailableSourceUnits = func(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) { - // TODO implement - return []discordgo.SelectMenuOption{ - { - Label: "Army Berlin", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"source": "berlin"}`), - }, - { - Label: "Fleet Kiel", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"source": "kiel"}`), - }, - { - Label: "Army Munich", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"source": "munich"}`), - }, - }, nil -} - -var ListAvailableOrderTypes = func(userID, gameID, phaseID string, source string) []discordgo.SelectMenuOption { - return []discordgo.SelectMenuOption{ - { - Label: "Move", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"type": "move", "source": "%s"}`, source), - }, - { - Label: "Support", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"type": "support", "source": "%s"}`, source), - }, - { - Label: "Hold", - // TODO move this to Discord layer - Value: fmt.Sprintf(`{"type": "hold", "source": "%s"}`, source), - }, - { - Label: "Convoy", - Value: fmt.Sprintf(`{"type": "convoy", "source": "%s"}`, source), - }, - } -} - -var ListAvailableAuxUnits = func(userID, gameID, phaseID string, source string, orderType string) []discordgo.SelectMenuOption { - return []discordgo.SelectMenuOption{ - { - Label: "Army Berlin", - Value: "berlin", - }, - { - Label: "Fleet Kiel", - Value: "kiel", - }, - { - Label: "Army Munich", - Value: "munich", - }, - } -} - -var ListAvailableDestinations = func(userID, gameID, phaseID string, source string, orderType string) []discordgo.SelectMenuOption { - return []discordgo.SelectMenuOption{ - { - Label: "Berlin", - Value: "berlin", - }, - { - Label: "Kiel", - Value: "kiel", - }, - { - Label: "Munich", - Value: "munich", - }, - } -} - -var ListAvailableAuxDestinations = func(userID, gameID, phaseID string, source string, orderType string, auxUnit string) []discordgo.SelectMenuOption { - return []discordgo.SelectMenuOption{ - { - Label: "Berlin", - Value: "berlin", - }, - { - Label: "Kiel", - Value: "kiel", - }, - { - Label: "Munich", - Value: "munich", - }, - } -} - -var AuxUnitRequired = func(orderType string) bool { - // return true if orderType is support or convoy - return orderType == "support" || orderType == "convoy" -} - -var DestinationRequired = func(orderType string) bool { - // return false if orderType is hold - return orderType != "hold" -} diff --git a/discord/api/request.go b/discord/api/request.go new file mode 100644 index 0000000..236f630 --- /dev/null +++ b/discord/api/request.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + + "github.com/zond/goaeoas" +) + +type GoaeoasRequest struct { + req *http.Request + vars map[string]string + values map[string]interface{} +} + +func (r *GoaeoasRequest) Req() *http.Request { + return r.req +} + +func (r *GoaeoasRequest) Vars() map[string]string { + return r.vars +} + +func (r *GoaeoasRequest) Values() map[string]interface{} { + return r.values +} + +func (r *GoaeoasRequest) DecorateLinks(links goaeoas.LinkDecorator) { +} + +func (r *GoaeoasRequest) Media() string { + return "" +} + +func (r *GoaeoasRequest) NewLink(link goaeoas.Link) goaeoas.Link { + return goaeoas.Link{} +} diff --git a/discord/api/util.go b/discord/api/util.go new file mode 100644 index 0000000..83579ee --- /dev/null +++ b/discord/api/util.go @@ -0,0 +1,104 @@ +package api + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/zond/diplicity/auth" + "github.com/zond/diplicity/game" + "google.golang.org/appengine" + "google.golang.org/appengine/v2/datastore" +) + +func CreateAuthenticatedRequest(userId string, vars map[string]string, data any) (*GoaeoasRequest, error) { + log.Printf("api.CreateAuthenticatedRequest invoked - userId: %s; var: %s; data: %s \n", userId, vars, data) + + user := createUserFromDiscordUserId(userId) + log.Printf("User instance created from Discord user ID: %+v\n", user) + + bodyJson, err := json.Marshal(data) + if err != nil { + return nil, err + } + log.Printf("Data marshalled to JSON: %s\n", bodyJson) + + // Note, url and method don't matter because we skip router + httpRequest, err := http.NewRequest("GET", "", bytes.NewBuffer(bodyJson)) + if err != nil { + return nil, err + } + log.Printf("HTTP request created: %+v\n", httpRequest) + + httpRequest.Header.Set("Content-Type", "application/json") + + goaeoasRequest := &GoaeoasRequest{ + req: httpRequest, + vars: vars, + values: map[string]interface{}{ + "user": user, + }, + } + log.Printf("GoaeoasRequest created: %+v\n", goaeoasRequest) + + _, err = getOrCreateUser(goaeoasRequest, user) + if err != nil { + return nil, err + } + + return goaeoasRequest, nil +} + +var NewGameDefaultValues = &game.Game{ + Variant: "Classical", + PhaseLengthMinutes: 60 * 24, + NonMovementPhaseLengthMinutes: 60 * 24, + MaxHated: 0, + MaxHater: 0, + MinRating: 0, + MaxRating: 0, + MinReliability: 0, + MinQuickness: 0, + Private: true, + NoMerge: false, + DisableConferenceChat: true, + DisableGroupChat: true, + DisablePrivateChat: true, + NationAllocation: 0, + Anonymous: false, + LastYear: 0, + SkipMuster: false, + ChatLanguageISO639_1: "en", + GameMasterEnabled: false, + RequireGameMasterInvitation: false, +} + +func createUserFromDiscordUserId(userId string) *auth.User { + return &auth.User{ + Email: "discord-user@discord-user.fake", + FamilyName: "Discord User", + GivenName: "Discord User", + Id: userId, + Name: "Discord User", + VerifiedEmail: true, + ValidUntil: time.Now().Add(time.Hour * 24 * 365 * 10), + } +} + +// Get the user from the datastore or create it if it does not exist. +func getOrCreateUser(r *GoaeoasRequest, user *auth.User) (*auth.User, error) { + ctx := appengine.NewContext(r.Req()) + log.Printf("Getting or creating user: %+v\n", user) + if err := datastore.Get(ctx, auth.UserID(ctx, user.Id), user); err == datastore.ErrNoSuchEntity { + log.Printf("User not found, creating it\n") + if _, err := datastore.Put(ctx, auth.UserID(ctx, user.Id), user); err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + log.Printf("User: %+v\n", user) + return user, nil +} diff --git a/discord/handlers/create_game.go b/discord/handlers/create_game.go new file mode 100644 index 0000000..58b7204 --- /dev/null +++ b/discord/handlers/create_game.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "fmt" + "log" + + "github.com/bwmarrin/discordgo" + "github.com/zond/diplicity/discord/api" + "github.com/zond/diplicity/game" +) + +var CreateGameCommand = discordgo.ApplicationCommand{ + Name: "create-game", + Description: "Create a new game", +} + +func CreateGameCommandHandlerFactory(api *api.Api) func(Session, *discordgo.InteractionCreate) { + return func(s Session, i *discordgo.InteractionCreate) { + log.Printf("Handling create game command\n") + + userId, channelId := GetUserAndChannelId(i) + + game, err := api.CreateGame(userId, channelId) + if err != nil { + RespondWithError("Failed to create game", s, i, err) + return + } + + successMessage := createSuccessMessage(game) + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: successMessage, + }, + }) + if err != nil { + panic(err) + } + } +} + +func createSuccessMessage(game *game.Game) string { + return fmt.Sprintf(` +## Game Created! + +### Game Settings + +- **Variant**: %s +- **Phase length**: %s + +### Next steps + +- **The game has not started yet**: You need to add players to the game +- Run the **/add-members** command to add players to the game +- **Note**: you can only add users which have joined the server to the game + +### Additional information + +- Only one game can be created per channel +`, game.Variant, GetPhaseLengthDisplay(game)) +} diff --git a/discord/handlers/create_order.go b/discord/handlers/create_order.go index d037bda..ef7fbdc 100644 --- a/discord/handlers/create_order.go +++ b/discord/handlers/create_order.go @@ -4,257 +4,322 @@ package handlers // and subsequent interactions import ( + "encoding/json" + "fmt" + "log" + "github.com/bwmarrin/discordgo" + "github.com/zond/diplicity/discord/api" +) + +var ( + CreateOrderInteractionIdPrefix = "create-order-interaction-" + CreateOrderSubmitIdPrefix = "create-order-submit-" ) -// Declare a Discord application command +type OrderData struct { + Source string `json:"source,omitempty"` + Type string `json:"type,omitempty"` + Destination string `json:"destination,omitempty"` + Aux string `json:"aux,omitempty"` + AuxDestination string `json:"auxDestination,omitempty"` +} + var CreateOrderCommand = discordgo.ApplicationCommand{ Name: "create-order", Description: "Create a new order", } -type OrderData struct { - Source string - OrderType string - AuxUnit string - Destination string +func CreateOrderCommandHandlerFactory(api *api.Api) func(Session, *discordgo.InteractionCreate) { + return func(s Session, i *discordgo.InteractionCreate) { + log.Printf("Handling create order command\n") + + userId, channelId := GetUserAndChannelId(i) + + orderData := &OrderData{} + + if i.Type == discordgo.InteractionApplicationCommand { + // TODO allow user to pass arguments with command + } else { + err := UnmarshalMessageComponentData(i, orderData) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + } + + sourceProvinces, err := api.SourceProvinces(userId, channelId) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + orderTypes, err := api.OrderTypes(userId, channelId, orderData.Source) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + destinationProvinces, err := api.DestinationProvinces(userId, channelId, orderData.Source, orderData.Type) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + auxProvinces, err := api.AuxProvinces(userId, channelId, orderData.Source, orderData.Type) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + auxDestinationProvinces, err := api.AuxDestinationProvinces(userId, channelId, orderData.Source, orderData.Type, orderData.Aux) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + orderDataString, err := json.Marshal(orderData) + if err != nil { + RespondWithError("Failed to unmarshal message component data", s, i, err) + return + } + + sourceProvinceOptions := createOptions(orderData, provincesToItemTypes(sourceProvinces), setSource, setDefaultSource) + orderTypeOptions := createOptions(orderData, orderTypesToItemTypes(orderTypes), setOrderType, setDefaultOrderType) + destinationProvinceOptions := createOptions(orderData, provincesToItemTypes(destinationProvinces), setDestination, setDefaultDestination) + auxProvinceOptions := createOptions(orderData, provincesToItemTypes(auxProvinces), setAux, setDefaultAux) + auxDestinationProvinceOptions := createOptions(orderData, provincesToItemTypes(auxDestinationProvinces), setAuxDestination, setDefaultAuxDestination) + + components := []discordgo.MessageComponent{} + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s%s", CreateOrderInteractionIdPrefix, "source"), + Placeholder: "Select a unit to move", + Options: sourceProvinceOptions, + }, + }, + }) + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s%s", CreateOrderInteractionIdPrefix, "type"), + Placeholder: "Select order type", + Options: orderTypeOptions, + Disabled: orderData.Source == "", + }, + }, + }) + if orderData.Type == "move" { + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s%s", CreateOrderInteractionIdPrefix, "destination"), + Placeholder: "Select destination", + Options: destinationProvinceOptions, + }, + }, + }) + } + if orderData.Type == "support" || orderData.Type == "convoy" { + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s%s", CreateOrderInteractionIdPrefix, "aux"), + Placeholder: "Select auxiliary unit", + Options: auxProvinceOptions, + }, + }, + }) + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.SelectMenu{ + CustomID: fmt.Sprintf("%s%s", CreateOrderInteractionIdPrefix, "aux-destination"), + Placeholder: "Select destination for auxiliary unit", + Options: auxDestinationProvinceOptions, + Disabled: orderData.Aux == "", + }, + }, + }) + } + components = append(components, &discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + &discordgo.Button{ + CustomID: "cancel", + Label: "Cancel", + Style: discordgo.SecondaryButton, + }, + &discordgo.Button{ + CustomID: fmt.Sprintf("%s%s", CreateOrderSubmitIdPrefix, string(orderDataString)), + Label: "Submit", + Style: discordgo.SuccessButton, + Disabled: !orderReadyToSubmit(orderData), + }, + }, + }) + + responseData := &discordgo.InteractionResponseData{ + Title: "Create Order", + Components: components, + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: responseData, + }) + if err != nil { + panic(err) + } + } } -type Session interface { - InteractionRespond(interaction *discordgo.Interaction, response *discordgo.InteractionResponse) error +func SubmitOrderInteractionHandlerFactory(api *api.Api) func(Session, *discordgo.InteractionCreate) { + return func(s Session, i *discordgo.InteractionCreate) { + userId, channelId := GetUserAndChannelId(i) + buttonId := i.MessageComponentData().CustomID + orderDataString := buttonId[len(CreateOrderSubmitIdPrefix):] + orderData := &OrderData{} + err := json.Unmarshal([]byte(orderDataString), orderData) + if err != nil { + panic(err) + } + + _, error := api.CreateOrder(userId, channelId) + if error != nil { + panic(error) + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + // TODO better success message, see create_game + Content: "Order created!", + }, + }) + if err != nil { + panic(err) + } + } +} + +func orderReadyToSubmit(orderData *OrderData) bool { + if orderData.Type == "hold" { + return orderData.Source != "" && orderData.Type != "" + } + if orderData.Type == "move" { + return orderData.Source != "" && orderData.Type != "" && orderData.Destination != "" + } + if orderData.Type == "support" || orderData.Type == "convoy" { + return orderData.Source != "" && orderData.Type != "" && orderData.Aux != "" && orderData.AuxDestination != "" + } + return false } -type InteractionCreate struct { - Interaction *discordgo.Interaction - Member *discordgo.Member - Type discordgo.InteractionType +// Note, this function is a bit complex. The create-order process is a multi-step process +// but we don't want our bot to be stateful. So instead, we pass the current state of the +// order as a JSON string in the value of the select menu options. This way, we can +// continuously reconstruct the order state from the interaction data. +func createOptions(orderData *OrderData, items []itemType, setValueFunc setValue, setDefaultFunc setDefault) []discordgo.SelectMenuOption { + options := make([]discordgo.SelectMenuOption, len(items)) + if len(items) == 0 { + return DummySelectMenuOptions + } + for i, item := range items { + optionOrderData := &OrderData{ + Source: orderData.Source, + Type: orderData.Type, + Destination: orderData.Destination, + Aux: orderData.Aux, + AuxDestination: orderData.AuxDestination, + } + optionOrderData = setValueFunc(optionOrderData, item) + value, error := json.Marshal(optionOrderData) + if error != nil { + panic(error) + } + options[i] = discordgo.SelectMenuOption{ + Label: item.Name, + Value: string(value), + Default: setDefaultFunc(orderData, item), + } + } + return options } -type Api interface { - ListAvailableSourceUnits(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) +// NOTE the types and functions below are required to make the createOptions function reusable +// for each property of OrderData. +type itemType struct { + Name string + Key string } -func NewCreateOrderCommandHandler(s Session, i InteractionCreate, api Api) { - options, err := api.ListAvailableSourceUnits(i.Member.User.ID, "gameID", "phaseID") +type setValue func(*OrderData, itemType) *OrderData + +type setDefault func(*OrderData, itemType) bool - if err != nil { - panic(err) +func provincesToItemTypes(province []api.Province) []itemType { + items := make([]itemType, len(province)) + for i, p := range province { + items[i] = itemType{ + Name: p.Name, + Key: p.Key, + } } + return items +} - err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Title: "Create Order", - Components: []discordgo.MessageComponent{ - &discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - &discordgo.SelectMenu{ - CustomID: "source", - Placeholder: "Select a unit to move", - Options: options, - }, - }, - }, - }, - }, - }) - if err != nil { - panic(err) +func orderTypesToItemTypes(orderTypes []api.OrderType) []itemType { + items := make([]itemType, len(orderTypes)) + for i, ot := range orderTypes { + items[i] = itemType{ + Name: ot.Name, + Key: ot.Key, + } } + return items +} + +func setSource(orderData *OrderData, item itemType) *OrderData { + orderData.Source = item.Key + return orderData +} + +func setDefaultSource(orderData *OrderData, item itemType) bool { + return item.Key == orderData.Source +} + +func setOrderType(orderData *OrderData, item itemType) *OrderData { + orderData.Type = item.Key + return orderData +} + +func setDefaultOrderType(orderData *OrderData, item itemType) bool { + return item.Key == orderData.Type +} + +func setDestination(orderData *OrderData, item itemType) *OrderData { + orderData.Destination = item.Key + return orderData } -// // Executed when the Discord command is invoked by a user -// func CreateOrderCommandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { -// // TODO add mechanism to ensure that command is being called from the -// // correct channel OR check if command can be added such that it is only -// // available in some channels - -// // TODO add mechanism to ensure that command is being called by a user -// // who is in the game (role). If not, return an error message - -// options := api.ListAvailableSourceUnits(i.Member.User.ID, "gameID", "phaseID") - -// // TODO add mechanism to return error message if there are no units -// // to order - -// orderData := OrderData{} - -// // check that interaction is ApplicationCommand type -// if i.Type == discordgo.InteractionApplicationCommand { -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Title: "Create Order", -// Components: []discordgo.MessageComponent{ -// &discordgo.ActionsRow{ -// Components: []discordgo.MessageComponent{ -// &discordgo.SelectMenu{ -// CustomID: "source", -// Placeholder: "Select a unit to move", -// Options: options, -// }, -// }, -// }, -// }, -// }, -// }) -// if err != nil { -// panic(err) -// } -// } - -// // Add a handler for the InteractionCreate event -// s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { -// // Check that the interaction is not the ApplicationCommand type -// if i.Type != discordgo.InteractionApplicationCommand { -// // Check if the interaction is a select menu interaction -// if i.MessageComponentData().CustomID == "source" { -// // Get the selected option -// selectedOption := i.MessageComponentData().Values[0] -// orderData.Source = selectedOption - -// // Get the available order types for the selected unit -// options := api.ListAvailableOrderTypes(i.Member.User.ID, "gameID", "phaseID", selectedOption) - -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Title: "Create Order", -// Components: []discordgo.MessageComponent{ -// &discordgo.ActionsRow{ -// Components: []discordgo.MessageComponent{ -// &discordgo.SelectMenu{ -// CustomID: "order-type", -// Placeholder: "Select an order type", -// Options: options, -// }, -// }, -// }, -// }, -// }, -// }) -// if err != nil { -// panic(err) -// } -// } else if i.MessageComponentData().CustomID == "order-type" { -// // Get the selected option -// selectedOption := i.MessageComponentData().Values[0] -// orderData.OrderType = selectedOption - -// // Check if an aux unit is required for the selected order type -// auxUnitRequired := api.AuxUnitRequired(orderData.OrderType) - -// // Check if a destination is required for the selected order type -// destinationRequired := api.DestinationRequired(orderData.OrderType) - -// if auxUnitRequired { -// options := api.ListAvailableAuxUnits(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType) -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Title: "Create Order", -// Components: []discordgo.MessageComponent{ -// &discordgo.ActionsRow{ -// Components: []discordgo.MessageComponent{ -// &discordgo.SelectMenu{ -// CustomID: "aux-unit", -// Placeholder: "Select an aux unit", -// Options: options, -// }, -// }, -// }, -// }, -// }, -// }) -// if err != nil { -// panic(err) -// } -// } else if destinationRequired { -// options := api.ListAvailableDestinations(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType) -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Title: "Create Order", -// Components: []discordgo.MessageComponent{ -// &discordgo.ActionsRow{ -// Components: []discordgo.MessageComponent{ -// &discordgo.SelectMenu{ -// CustomID: "destination", -// Placeholder: "Select a destination", -// Options: options, -// }, -// }, -// }, -// }, -// }, -// }) - -// if err != nil { -// panic(err) -// } -// } else { -// // Create string representation of the order -// orderString := fmt.Sprintf("%s %s", orderData.Source, orderData.OrderType) -// // TODO add mechanism to create the order - -// // Clear the order data -// orderData = OrderData{} -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Content: fmt.Sprintf("Order created: %s", orderString), -// }, -// }) -// if err != nil { -// panic(err) -// } -// } -// } else if i.MessageComponentData().CustomID == "aux-unit" { -// // Get the selected option -// selectedOption := i.MessageComponentData().Values[0] -// orderData.AuxUnit = selectedOption - -// options := api.ListAvailableAuxDestinations(i.Member.User.ID, "gameID", "phaseID", orderData.Source, orderData.OrderType, orderData.AuxUnit) -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Title: "Create Order", -// Components: []discordgo.MessageComponent{ -// &discordgo.ActionsRow{ -// Components: []discordgo.MessageComponent{ -// &discordgo.SelectMenu{ -// CustomID: "destination", -// Placeholder: "Select a destination", -// Options: options, -// }, -// }, -// }, -// }, -// }, -// }) - -// if err != nil { -// panic(err) -// } -// } else if i.MessageComponentData().CustomID == "destination" { -// // Get the selected option -// selectedOption := i.MessageComponentData().Values[0] -// orderData.Destination = selectedOption -// orderString := fmt.Sprintf("%s %s %s %s", orderData.Source, orderData.OrderType, orderData.AuxUnit, orderData.Destination) - -// // TODO add mechanism to create the order -// // Clear the order data -// orderData = OrderData{} -// err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ -// Type: discordgo.InteractionResponseChannelMessageWithSource, -// Data: &discordgo.InteractionResponseData{ -// Content: fmt.Sprintf("Order created: %s", orderString), -// }, -// }) -// if err != nil { -// panic(err) -// } -// } -// } -// }) -// } +func setDefaultDestination(orderData *OrderData, item itemType) bool { + return item.Key == orderData.Destination +} + +func setAux(orderData *OrderData, item itemType) *OrderData { + orderData.Aux = item.Key + return orderData +} + +func setDefaultAux(orderData *OrderData, item itemType) bool { + return item.Key == orderData.Aux +} + +func setAuxDestination(orderData *OrderData, item itemType) *OrderData { + orderData.AuxDestination = item.Key + return orderData +} + +func setDefaultAuxDestination(orderData *OrderData, item itemType) bool { + return item.Key == orderData.AuxDestination +} diff --git a/discord/handlers/create_order_test.go b/discord/handlers/create_order_test.go deleted file mode 100644 index 840b2dd..0000000 --- a/discord/handlers/create_order_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package handlers - -import ( - "errors" - "testing" - - "github.com/bwmarrin/discordgo" - gomock "go.uber.org/mock/gomock" -) - -func expectPanic(t *testing.T) { - if r := recover(); r == nil { - t.Errorf("expected panic") - } -} - -var mockMember = &discordgo.Member{ - User: &discordgo.User{ - ID: "123", - }, -} - -var mockUnitOptions = []discordgo.SelectMenuOption{ - { - Label: "Army Berlin", - Value: `{"source": "berlin"}`, - }, -} - -var expectedResponse = &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Title: "Create Order", - Components: []discordgo.MessageComponent{ - &discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - &discordgo.SelectMenu{ - CustomID: "source", - Placeholder: "Select a unit to move", - Options: []discordgo.SelectMenuOption{ - { - Label: "Army Berlin", - Value: `{"source": "berlin"}`, - }, - }, - }, - }, - }, - }, - }, -} - -var mockInteraction = InteractionCreate{ - Member: mockMember, - Interaction: &discordgo.Interaction{}, -} - -func TestCreateOrderCommandHandler(t *testing.T) { - mockCtrl := gomock.NewController(t) - defer mockCtrl.Finish() - - mockApi := NewMockApi(mockCtrl) - mockApi.EXPECT().ListAvailableSourceUnits(gomock.Eq(mockMember.User.ID), gomock.Eq("gameID"), gomock.Eq("phaseID")).Return(mockUnitOptions, nil).AnyTimes() - - mockSession := NewMockSession(mockCtrl) - - t.Run("Calls InteractionRespond with expected response", func(t *testing.T) { - mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Eq(expectedResponse)).Return(nil) - NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) - }) - - t.Run("Panics if InteractionRespond returns an error", func(t *testing.T) { - mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Any()).Return(errors.New("Custom test error")) - - defer expectPanic(t) - - NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) - }) - - t.Run("Panics if ListAvailableSourceUnits returns an error", func(t *testing.T) { - mockApi.EXPECT().ListAvailableSourceUnits(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("Custom test error")).AnyTimes() - mockSession.EXPECT().InteractionRespond(gomock.Any(), gomock.Any()).Return(errors.New("Custom test error")) - - defer expectPanic(t) - - NewCreateOrderCommandHandler(mockSession, mockInteraction, mockApi) - }) -} diff --git a/discord/handlers/mock_create_order.go b/discord/handlers/mock_create_order.go deleted file mode 100644 index 98401d2..0000000 --- a/discord/handlers/mock_create_order.go +++ /dev/null @@ -1,92 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: create_order.go -// -// Generated by this command: -// -// mockgen -source=create_order.go -destination=mock_create_order.go -package=handlers -// - -// Package handlers is a generated GoMock package. -package handlers - -import ( - reflect "reflect" - - discordgo "github.com/bwmarrin/discordgo" - gomock "go.uber.org/mock/gomock" -) - -// MockSession is a mock of Session interface. -type MockSession struct { - ctrl *gomock.Controller - recorder *MockSessionMockRecorder -} - -// MockSessionMockRecorder is the mock recorder for MockSession. -type MockSessionMockRecorder struct { - mock *MockSession -} - -// NewMockSession creates a new mock instance. -func NewMockSession(ctrl *gomock.Controller) *MockSession { - mock := &MockSession{ctrl: ctrl} - mock.recorder = &MockSessionMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSession) EXPECT() *MockSessionMockRecorder { - return m.recorder -} - -// InteractionRespond mocks base method. -func (m *MockSession) InteractionRespond(interaction *discordgo.Interaction, response *discordgo.InteractionResponse) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InteractionRespond", interaction, response) - ret0, _ := ret[0].(error) - return ret0 -} - -// InteractionRespond indicates an expected call of InteractionRespond. -func (mr *MockSessionMockRecorder) InteractionRespond(interaction, response any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InteractionRespond", reflect.TypeOf((*MockSession)(nil).InteractionRespond), interaction, response) -} - -// MockApi is a mock of Api interface. -type MockApi struct { - ctrl *gomock.Controller - recorder *MockApiMockRecorder -} - -// MockApiMockRecorder is the mock recorder for MockApi. -type MockApiMockRecorder struct { - mock *MockApi -} - -// NewMockApi creates a new mock instance. -func NewMockApi(ctrl *gomock.Controller) *MockApi { - mock := &MockApi{ctrl: ctrl} - mock.recorder = &MockApiMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockApi) EXPECT() *MockApiMockRecorder { - return m.recorder -} - -// ListAvailableSourceUnits mocks base method. -func (m *MockApi) ListAvailableSourceUnits(userID, gameID, phaseID string) ([]discordgo.SelectMenuOption, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListAvailableSourceUnits", userID, gameID, phaseID) - ret0, _ := ret[0].([]discordgo.SelectMenuOption) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListAvailableSourceUnits indicates an expected call of ListAvailableSourceUnits. -func (mr *MockApiMockRecorder) ListAvailableSourceUnits(userID, gameID, phaseID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAvailableSourceUnits", reflect.TypeOf((*MockApi)(nil).ListAvailableSourceUnits), userID, gameID, phaseID) -} diff --git a/discord/handlers/register.go b/discord/handlers/register.go new file mode 100644 index 0000000..52ffe38 --- /dev/null +++ b/discord/handlers/register.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "log" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/zond/diplicity/discord/api" +) + +var applicationId = "1246942452791644281" + +var commands = []discordgo.ApplicationCommand{ + CreateOrderCommand, + CreateGameCommand, +} + +func RegisterHandlers(session *discordgo.Session, apiImpl *api.Api) { + log.Printf("Initializing Discord handlers\n") + + commandHandlers := map[string]func(s Session, i *discordgo.InteractionCreate){ + CreateOrderCommand.Name: CreateOrderCommandHandlerFactory(apiImpl), + CreateGameCommand.Name: CreateGameCommandHandlerFactory(apiImpl), + } + + log.Printf("Registering debug handler\n") + session.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type == discordgo.InteractionApplicationCommand { + log.Printf("Received command %q\n", i.ApplicationCommandData().Name) + } + if i.Type == discordgo.InteractionMessageComponent { + log.Printf("Received message component %q\n", i.MessageComponentData().CustomID) + } + }) + + session.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + } + }) + + for _, cmd := range commands { + log.Printf("Creating slash command %q\n", cmd.Name) + _, err := session.ApplicationCommandCreate(applicationId, "", &cmd) + if err != nil { + log.Fatalf("Cannot create slash command %q: %v", cmd.Name, err) + } + } + + log.Printf("Registering interaction handlers\n") + session.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type == discordgo.InteractionMessageComponent { + if strings.HasPrefix(i.MessageComponentData().CustomID, CreateOrderInteractionIdPrefix) { + CreateOrderCommandHandlerFactory(apiImpl)(s, i) + } + if strings.HasPrefix(i.MessageComponentData().CustomID, CreateOrderSubmitIdPrefix) { + SubmitOrderInteractionHandlerFactory(apiImpl)(s, i) + } + } + }) + log.Printf("Discord handlers initialized\n") + + log.Printf("Setting intents\n") + session.Identify.Intents = discordgo.IntentsAllWithoutPrivileged +} diff --git a/discord/handlers/util.go b/discord/handlers/util.go new file mode 100644 index 0000000..1241c9f --- /dev/null +++ b/discord/handlers/util.go @@ -0,0 +1,63 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + + "github.com/bwmarrin/discordgo" + "github.com/zond/diplicity/game" +) + +// Discord handler related utilities + +// Session interface is used to mock discordgo.Session in tests +type Session interface { + InteractionRespond(*discordgo.Interaction, *discordgo.InteractionResponse, ...discordgo.RequestOption) error + ChannelMessageDelete(channelID string, messageID string, options ...discordgo.RequestOption) (err error) + ChannelMessageSend(channelID string, content string, options ...discordgo.RequestOption) (*discordgo.Message, error) +} + +func GetUserAndChannelId(i *discordgo.InteractionCreate) (string, string) { + log.Printf("Getting user ID and channel ID from interaction\n") + userId := i.Member.User.ID + channelId := i.ChannelID + log.Printf("User ID: %s, Channel ID: %s\n", userId, channelId) + return userId, channelId +} + +func RespondWithError(message string, s Session, i *discordgo.InteractionCreate, err error) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("%s: %s", message, err.Error()), + }, + }) +} + +// Useful when interaction message value is a JSON string (used in multi-step interactions) +func UnmarshalMessageComponentData(i *discordgo.InteractionCreate, v any) error { + return json.Unmarshal([]byte(i.MessageComponentData().Values[0]), v) +} + +// Useful when rendering a disabled select menu where available options are not known +var DummySelectMenuOptions = []discordgo.SelectMenuOption{ + { + Label: "Dummy", + Value: "dummy", + }, +} + +func GetPhaseLengthDisplay(g *game.Game) string { + fmt.Printf("Getting phase length display for game: %+v\n", g) + phaseLengthHours := g.PhaseLengthMinutes / 60 + phaseLengthMinutes := g.PhaseLengthMinutes % 60 + phaseLengthDisplay := "" + if phaseLengthHours > 0 { + phaseLengthDisplay += fmt.Sprintf("%d hours", phaseLengthHours) + } + if phaseLengthMinutes > 0 { + phaseLengthDisplay += fmt.Sprintf(" %d minutes", phaseLengthMinutes) + } + return phaseLengthDisplay +} From 1d7e91a2321a463bdee610ed43ea3b013247b448 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:31:50 +0100 Subject: [PATCH 07/11] Make api functions public --- game/game.go | 4 ++-- game/order.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/game/game.go b/game/game.go index 03ee303..ef29e6d 100644 --- a/game/game.go +++ b/game/game.go @@ -78,7 +78,7 @@ func init() { GameResource = &Resource{ Load: loadGame, Delete: gameMasterDeleteGame, - Create: createGame, + Create: CreateGame, Update: gameMasterUpdateGame, Listers: []Lister{ { @@ -887,7 +887,7 @@ func gameMasterDeleteGame(w ResponseWriter, r Request) (*Game, error) { return game, nil } -func createGame(w ResponseWriter, r Request) (*Game, error) { +func CreateGame(w ResponseWriter, r Request) (*Game, error) { ctx := appengine.NewContext(r.Req()) user, ok := r.Values()["user"].(*auth.User) diff --git a/game/order.go b/game/order.go index 724ae53..87054b6 100644 --- a/game/order.go +++ b/game/order.go @@ -25,7 +25,7 @@ var OrderResource *Resource func init() { OrderResource = &Resource{ - Create: createOrder, + Create: CreateOrder, Update: updateOrder, Delete: deleteOrder, CreatePath: "/Game/{game_id}/Phase/{phase_ordinal}/Order", @@ -250,7 +250,7 @@ func updateOrder(w ResponseWriter, r Request) (*Order, error) { } func createAndCorroborate(w ResponseWriter, r Request) error { - _, err := createOrder(w, r) + _, err := CreateOrder(w, r) if err != nil { return err } @@ -258,7 +258,7 @@ func createAndCorroborate(w ResponseWriter, r Request) error { return corroboratePhase(w, r) } -func createOrder(w ResponseWriter, r Request) (*Order, error) { +func CreateOrder(w ResponseWriter, r Request) (*Order, error) { ctx := appengine.NewContext(r.Req()) user, ok := r.Values()["user"].(*auth.User) From 7c5a1b954f4a9bfd5bd67ae9ca9fd0e6c4818b2e Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:38:09 +0100 Subject: [PATCH 08/11] Add shell scripts for Docker --- .docker/Dockerfile | 3 -- .docker/docker-build.sh | 18 ++++++++++++ .docker/docker-create-network.sh | 11 +++++++ .docker/docker-run.sh | 49 ++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 .docker/docker-build.sh create mode 100644 .docker/docker-create-network.sh create mode 100644 .docker/docker-run.sh diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 3e6f461..5a3119e 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -24,9 +24,6 @@ WORKDIR /go/src/app # Copy the current directory contents into the container at /go/src/app COPY ../ . -# Remove Dockerfile to prevent issue with App Engine -# RUN rm Dockerfile - # Expose port 8080 to the outside world EXPOSE 8080 diff --git a/.docker/docker-build.sh b/.docker/docker-build.sh new file mode 100644 index 0000000..723b852 --- /dev/null +++ b/.docker/docker-build.sh @@ -0,0 +1,18 @@ +# Description: Build docker image from Dockerfile + +Write-Host "Building Docker image..." + +# Confirm that the script is being executed from the correct directory +if (-not (Test-Path ".docker/docker-run.ps1")) { + Write-Host "You must run this command from the parent directory of this script." + exit +} + +Write-Host "Confirmed that script is being executed from the correct directory." + +$dockerCommand = "docker build --tag 'diplicity' ./.docker" + +Write-Host "Running Docker command: $dockerCommand" + +# Execute the Docker command +Invoke-Expression $dockerCommand \ No newline at end of file diff --git a/.docker/docker-create-network.sh b/.docker/docker-create-network.sh new file mode 100644 index 0000000..26975c6 --- /dev/null +++ b/.docker/docker-create-network.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +# Description: Create a new docker network called my-net +echo "Creating Docker network..." + +dockerCommand="docker network create -d bridge my-net" + +echo "Running Docker command: $dockerCommand" + +# Execute the Docker command +eval $dockerCommand \ No newline at end of file diff --git a/.docker/docker-run.sh b/.docker/docker-run.sh new file mode 100644 index 0000000..ac9ecd8 --- /dev/null +++ b/.docker/docker-run.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +# Ensure that this command is being called from the parent directory +# of the Dockerfile. This is necessary for the volume mapping to work +# correctly. + +echo "Running Docker container..." + +# Confirm that the script is being executed from the correct directory +if [ ! -f ".docker/docker-run.sh" ]; then + echo "You must run this command from the parent directory of this script." + exit 1 +fi + +echo "Confirmed that script is being executed from the correct directory." + +# Base docker run command +dockerCommand="docker run" + +# Mount a volume from the host machine to the container. This means +# that the container will have access to the files in the host machine +# and that changes made in the host machine will be reflected in the +# container. +dockerCommand+=" -v .:/go/src/app:ro" # Add volume mapping + +# Specify the network that the container should be connected to. This +# is necessary for the Discord bot to be able to work correctly. +dockerCommand+=" --network my-net" # Specify the network + +# Specify the environment file that should be used by the container. This +# file contains secret environment variables that the application needs +# to run correctly, e.g. DISCORD_BOT_TOKEN. +dockerCommand+=" --env-file ./.env" # Specify the environment file + +# Specify that the 8080 port should be exposed to the host machine. This +# is the port that the API application listens on. +dockerCommand+=" -p 8080:8080" # Map port 8080 + +# Specify that the 8000 port should be exposed to the host machine. This +# is the port that the admin application listens on. +dockerCommand+=" -p 8000:8000" # Map port 8000 + +# Specify the image that should be used to create the container. +dockerCommand+=" diplicity" + +echo "Running Docker command: $dockerCommand" + +# Execute the Docker command +eval $dockerCommand \ No newline at end of file From d99e4f0beee325e5a081c49f60a3597c7e2cee01 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:41:12 +0100 Subject: [PATCH 09/11] Add instructions to README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b93274d..b721772 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,10 @@ To enable debugging the JSON output in a browser, adding the query parameter `ac - Download Docker - Navigate to the root directory of this project -- Run `docker build --tag 'diplicity' .` -- Run `docker run -p 8080:8080 -p 8000:8000 diplicity` +- Use `ps1` files for Windows and `sh` files for UNIX +- Run `.\.docker\docker-build.ps1` **or** `.\.docker\docker-build.sh` (only required once) +- Run `.\.docker\docker-network.ps1` **or** `.\.docker\docker-network.sh` (only required once) +- Run `.\.docker\docker-run.ps1` **or** `.\.docker\docker-run.sh` - The API is now available on your machine at `localhost:8080` - The Admin server is now available on your machine at `localhost:8000` From 03351811e69d29df2919cd74aa2e488cd4070e27 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:42:21 +0100 Subject: [PATCH 10/11] Fix build script --- .docker/docker-build.sh | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.docker/docker-build.sh b/.docker/docker-build.sh index 723b852..ce2a4f1 100644 --- a/.docker/docker-build.sh +++ b/.docker/docker-build.sh @@ -1,18 +1,19 @@ -# Description: Build docker image from Dockerfile +#!/bin/bash -Write-Host "Building Docker image..." +# Description: Build docker image from Dockerfile +echo "Building Docker image..." # Confirm that the script is being executed from the correct directory -if (-not (Test-Path ".docker/docker-run.ps1")) { - Write-Host "You must run this command from the parent directory of this script." - exit -} +if [ ! -f ".docker/docker-run.sh" ]; then + echo "You must run this command from the parent directory of this script." + exit 1 +fi -Write-Host "Confirmed that script is being executed from the correct directory." +echo "Confirmed that script is being executed from the correct directory." -$dockerCommand = "docker build --tag 'diplicity' ./.docker" +dockerCommand="docker build --tag 'diplicity' ./.docker" -Write-Host "Running Docker command: $dockerCommand" +echo "Running Docker command: $dockerCommand" # Execute the Docker command -Invoke-Expression $dockerCommand \ No newline at end of file +eval $dockerCommand \ No newline at end of file From 1421c698cb7f32ade8785064605ec7253d35c434 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 5 Jun 2024 20:46:08 +0100 Subject: [PATCH 11/11] Add .env explainer --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b721772..4b018f1 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ To enable debugging the JSON output in a browser, adding the query parameter `ac ## Running locally using Docker (recommended) +- **Note** you need to create a `.env` file in the root directory of the repo an + add `DISCORD_BOT_TOKEN` value, e.g. `DISCORD_BOT_TOKEN=abc123` (no quote marks). + Create a Discord app and get a token if you want to test Discord locally. - Download Docker - Navigate to the root directory of this project - Use `ps1` files for Windows and `sh` files for UNIX