diff --git a/Dockerfile b/.docker/Dockerfile similarity index 93% rename from Dockerfile rename to .docker/Dockerfile index c8f5e4d..5a3119e 100644 --- a/Dockerfile +++ b/.docker/Dockerfile @@ -22,10 +22,7 @@ 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 . . - -# Remove Dockerfile to prevent issue with App Engine -RUN rm Dockerfile +COPY ../ . # 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-build.sh b/.docker/docker-build.sh new file mode 100644 index 0000000..ce2a4f1 --- /dev/null +++ b/.docker/docker-build.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Description: Build docker image from Dockerfile +echo "Building Docker image..." + +# 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." + +dockerCommand="docker build --tag 'diplicity' ./.docker" + +echo "Running Docker command: $dockerCommand" + +# Execute the Docker command +eval $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-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.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 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 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/README.md b/README.md index b93274d..4b018f1 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,15 @@ 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 -- 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` diff --git a/app.go b/app.go index e00cdf8..c475f5f 100644 --- a/app.go +++ b/app.go @@ -1,10 +1,15 @@ package main import ( + "log" "net/http" "net/url" + "os" + "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" "google.golang.org/appengine/v2" @@ -12,6 +17,7 @@ import ( ) func main() { + jsonFormURL, err := url.Parse("/js/jsonform.js") if err != nil { panic(err) @@ -30,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/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 new file mode 100644 index 0000000..ef7fbdc --- /dev/null +++ b/discord/handlers/create_order.go @@ -0,0 +1,325 @@ +package handlers + +// This file container the handler for the create-order command +// 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-" +) + +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", +} + +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) + } + } +} + +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 +} + +// 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 +} + +// 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 +} + +type setValue func(*OrderData, itemType) *OrderData + +type setDefault func(*OrderData, itemType) bool + +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 +} + +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 +} + +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/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 +} 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) diff --git a/go.mod b/go.mod index a6a5dd9..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 @@ -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 @@ -42,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 2e98b1a..9289c95 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= @@ -18,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= @@ -56,9 +60,13 @@ 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= +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= @@ -118,7 +126,10 @@ 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= golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=