diff --git a/cmd/themis-server/main.go b/cmd/themis-server/main.go index 5a923cb..307f11b 100644 --- a/cmd/themis-server/main.go +++ b/cmd/themis-server/main.go @@ -1,5 +1,257 @@ package main +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/bwmarrin/discordgo" + + "go.wperron.io/themis" +) + +const ( + DB_FILE = "prod.db" + CONN_STRING = "file:" + DB_FILE + "?cache=shared&mode=rw&_journal_mode=WAL" + DISCORD_APP_ID = "1014881815921705030" + DISCORD_GUILD_ID = "1014883118764806164" +) + +var ( + dbFile = flag.String("db", "", "SQlite database file path") + + store *themis.Store +) + +var () + func main() { - panic("not implemented") + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGKILL, syscall.SIGINT) + defer cancel() + + flag.Parse() + + err := touchDbFile(*dbFile) + if err != nil { + log.Fatalln("fatal error: failed to touch database file:", err) + } + + store, err = themis.NewStore(CONN_STRING) + if err != nil { + log.Fatalln("fatal error: failed to initialize database:", err) + } + + authToken, ok := os.LookupEnv("DISCORD_TOKEN") + if !ok { + log.Fatalln("fatal error: no auth token found at DISCORD_TOKEN env var") + } + + discord, err := discordgo.New(fmt.Sprintf("Bot %s", authToken)) + if err != nil { + log.Fatalln("fatal error: failed to create discord app:", err) + } + + commands := []*discordgo.ApplicationCommand{ + { + Name: "themis", + Description: "Call dibs on EU4 provinces", + Type: discordgo.ChatApplicationCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "ping", + Description: "Ping Themis", + Type: discordgo.ApplicationCommandOptionSubCommand, + }, + { + Name: "list-claims", + Description: "List current claims", + Type: discordgo.ApplicationCommandOptionSubCommand, + }, + { + Name: "claim", + Description: "Take a claim on provinces", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "claim-type", + Description: "one of `area`, `region` or `trade`", + Type: discordgo.ApplicationCommandOptionString, + }, + { + Name: "name", + Description: "the name of zone claimed", + Type: discordgo.ApplicationCommandOptionString, + }, + }, + }, + }, + }, + } + handlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ + "themis": func(s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options + + switch options[0].Name { + case "ping": + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Pong", + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + case "list-claims": + claims, err := store.ListClaims(ctx) + if err != nil { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Oops, something went wrong! :(", + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + } + + sb := strings.Builder{} + sb.WriteString(fmt.Sprintf("There are currently %d claims:\n", len(claims))) + for _, c := range claims { + sb.WriteString(fmt.Sprintf("%s\n", c)) + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: sb.String(), + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + case "claim": + opts := options[0].Options + claimType, err := themis.ClaimTypeFromString(opts[0].StringValue()) + if err != nil { + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You can only take claims of types `area`, `region` or `trade`", + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + return + } + name := opts[1].StringValue() + + player := i.Member.Nick + if player == "" { + player = i.Member.User.Username + } + + err = store.Claim(ctx, player, name, claimType) + if err != nil { + fmt.Printf("[error]: failed to acquire claim: %s\n", err) + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "failed to acquire claim :(", + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + return + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Claimed %s for %s!", name, player), + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + default: + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("Oops, I don't know any `%s` action", options[0].Name), + }, + }) + if err != nil { + log.Println("[error] failed to respond to command:", err) + } + } + }, + } + + registerHandlers(discord, handlers) + + err = discord.Open() + if err != nil { + log.Fatalln("fatal error: failed to open session:", err) + } + defer discord.Close() + + registeredCommands := make([]*discordgo.ApplicationCommand, len(commands)) + for i, c := range commands { + command, err := discord.ApplicationCommandCreate(DISCORD_APP_ID, DISCORD_GUILD_ID, c) + if err != nil { + log.Fatalln("fatal error: failed to register command:", err) + } + registeredCommands[i] = command + } + + log.Printf("registered %d commands\n", len(registeredCommands)) + <-ctx.Done() + + for _, c := range registeredCommands { + err = discord.ApplicationCommandDelete(DISCORD_APP_ID, DISCORD_GUILD_ID, c.ID) + if err != nil { + log.Printf("[error]: failed to delete command: %s\n", err) + } + } + log.Println("deregistered commands, bye bye!") + os.Exit(0) +} + +func touchDbFile(path string) error { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + f, err := os.Create(path) + if err != nil { + return err + } + f.Close() + } else { + return err + } + } + f.Close() + + return nil +} + +func registerHandlers(sess *discordgo.Session, handlers map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate)) { + sess.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + log.Printf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator) + }) + sess.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if h, ok := handlers[i.ApplicationCommandData().Name]; ok { + h(s, i) + } + }) } diff --git a/go.mod b/go.mod index 672bde7..159e077 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,13 @@ go 1.19 require github.com/mattn/go-sqlite3 v1.14.15 require ( + github.com/bwmarrin/discordgo v0.26.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/stretchr/objx v0.4.0 // indirect github.com/stretchr/testify v1.8.0 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7713f5e..fc42333 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ +github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE= +github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -11,6 +15,14 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/store.go b/store.go index a55ff4a..8753a1a 100644 --- a/store.go +++ b/store.go @@ -87,15 +87,15 @@ func (s *Store) Claim(ctx context.Context, player, province string, claimType Cl // Check conflicts stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(`SELECT provinces.name FROM provinces WHERE provinces.%s = ? and provinces.name in ( - SELECT provinces.name FROM claims LEFT JOIN provinces ON claims.val = provinces.trade_node WHERE claims.claim_type = 'trade' - UNION SELECT provinces.name from claims LEFT JOIN provinces ON claims.val = provinces.region WHERE claims.claim_type = 'region' - UNION SELECT provinces.name from claims LEFT JOIN provinces ON claims.val = provinces.area WHERE claims.claim_type = 'area' + SELECT provinces.name FROM claims LEFT JOIN provinces ON claims.val = provinces.trade_node WHERE claims.claim_type = 'trade' AND claims.player IS NOT ? + UNION SELECT provinces.name from claims LEFT JOIN provinces ON claims.val = provinces.region WHERE claims.claim_type = 'region' AND claims.player IS NOT ? + UNION SELECT provinces.name from claims LEFT JOIN provinces ON claims.val = provinces.area WHERE claims.claim_type = 'area' AND claims.player IS NOT ? )`, claimTypeToColumn[claimType])) if err != nil { return fmt.Errorf("failed to prepare conflicts query: %w", err) } - rows, err := stmt.QueryContext(ctx, province) + rows, err := stmt.QueryContext(ctx, province, player, player, player) if err != nil { return fmt.Errorf("failed to get conflicting provinces: %w", err) } diff --git a/store_test.go b/store_test.go index 913086e..8fe7666 100644 --- a/store_test.go +++ b/store_test.go @@ -50,6 +50,15 @@ func TestStore_Claim(t *testing.T) { }, wantErr: true, }, + { + name: "same player overlapp", + args: args{ + player: "foo", // 'foo' has a claim on Italy, which has overlapping provinces + province: "Genoa", + claimType: CLAIM_TYPE_TRADE, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {