diff --git a/.drone.yml b/.drone.yml index e991cd4..3bbdda1 100644 --- a/.drone.yml +++ b/.drone.yml @@ -9,6 +9,7 @@ steps: pull: always environment: TESTS_DB_DSN: postgres://postgres:dcbot@database:5432/dcbot?sslmode=disable + TESTS_REDIS_CONNECTION_STRING: redis://:@redis:6379/0 commands: - make generate - go test -race -coverprofile=coverage.txt -covermode=atomic ./... @@ -19,6 +20,8 @@ services: environment: POSTGRES_DB: dcbot POSTGRES_PASSWORD: dcbot + - name: redis + image: redis:7.0.12 trigger: event: @@ -392,6 +395,6 @@ depends_on: - migrations-manifest --- kind: signature -hmac: 4fa2583c281aacc0e4d0dc87296a2ce6ff7facf71d1ce2e9f5d4157b35635d4f +hmac: ed0db55292798e0b63ba43ae50c749730c9a72423f6387b670e3fd7f68d2f5ad ... diff --git a/cmd/dcbot/internal/bun.go b/cmd/dcbot/internal/bun.go index 72c2fd6..84c64ab 100644 --- a/cmd/dcbot/internal/bun.go +++ b/cmd/dcbot/internal/bun.go @@ -35,6 +35,7 @@ func NewBunDB() (*bun.DB, error) { ctx, cancel := context.WithTimeout(context.Background(), dbPingTimeout) defer cancel() if err := db.PingContext(ctx); err != nil { + _ = db.Close() return nil, fmt.Errorf("db.PingContext: %w", err) } diff --git a/cmd/dcbot/internal/redis.go b/cmd/dcbot/internal/redis.go new file mode 100644 index 0000000..62d4848 --- /dev/null +++ b/cmd/dcbot/internal/redis.go @@ -0,0 +1,41 @@ +package internal + +import ( + "context" + "fmt" + "time" + + "github.com/kelseyhightower/envconfig" + "github.com/redis/go-redis/v9" +) + +const ( + redisPingTimeout = 10 * time.Second +) + +type redisConfig struct { + ConnectionString string `envconfig:"CONNECTION_STRING" required:"true"` +} + +func NewRedisClient() (*redis.Client, error) { + var cfg redisConfig + if err := envconfig.Process("REDIS", &cfg); err != nil { + return nil, fmt.Errorf("envconfig.Process: %w", err) + } + + opts, err := redis.ParseURL(cfg.ConnectionString) + if err != nil { + return nil, fmt.Errorf("couldn't parse REDIS_CONNECTION_STRING: %w", err) + } + + c := redis.NewClient(opts) + + ctx, cancel := context.WithTimeout(context.Background(), redisPingTimeout) + defer cancel() + if err = c.Ping(ctx).Err(); err != nil { + _ = c.Close() + return nil, fmt.Errorf("couldn't ping redis: %w", err) + } + + return c, nil +} diff --git a/cmd/dcbot/internal/run/run.go b/cmd/dcbot/internal/run/run.go index c61ca20..2d202ea 100644 --- a/cmd/dcbot/internal/run/run.go +++ b/cmd/dcbot/internal/run/run.go @@ -41,17 +41,24 @@ func New() *cli.Command { _ = db.Close() }() + redisClient, err := internal.NewRedisClient() + if err != nil { + return err + } + twhelpService, err := internal.NewTWHelpService(c.App.Version) if err != nil { return fmt.Errorf("internal.NewTWHelpService: %w", err) } groupRepo := adapter.NewGroupBun(db) + villageRepo := adapter.NewVillageRedis(redisClient) choiceSvc := service.NewChoice(twhelpService) groupSvc := service.NewGroup(groupRepo, twhelpService, logger, cfg.MaxGroupsPerServer, cfg.MaxMonitorsPerGroup) + villageSvc := service.NewVillage(villageRepo, twhelpService) - bot, err := discord.NewBot(cfg.Token, groupSvc, choiceSvc, logger) + bot, err := discord.NewBot(cfg.Token, groupSvc, choiceSvc, villageSvc, logger) if err != nil { return fmt.Errorf("discord.NewBot: %w", err) } diff --git a/go.mod b/go.mod index d634173..9d04d38 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/nicksnyder/go-i18n/v2 v2.2.1 github.com/ory/dockertest/v3 v3.10.0 github.com/pressly/goose/v3 v3.11.2 + github.com/redis/go-redis/v9 v9.0.5 github.com/robfig/cron/v3 v3.0.1 github.com/stretchr/testify v1.8.4 github.com/uptrace/bun v1.1.14 @@ -28,9 +29,11 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/containerd/continuity v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/docker/cli v23.0.6+incompatible // indirect github.com/docker/docker v23.0.6+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect diff --git a/go.sum b/go.sum index 3bab4ee..0019077 100644 --- a/go.sum +++ b/go.sum @@ -7,10 +7,14 @@ github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5 github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= @@ -19,6 +23,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/docker/cli v23.0.6+incompatible h1:CScadyCJ2ZKUDpAMZta6vK8I+6/m60VIjGIV7Wg/Eu4= github.com/docker/cli v23.0.6+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v23.0.6+incompatible h1:aBD4np894vatVX99UTx/GyOUOK4uEcROwA3+bQhEcoU= @@ -76,6 +82,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.11.2 h1:QgTP45FhBBHdmf7hWKlbWFHtwPtxo0phSDkwDKGUrYs= github.com/pressly/goose/v3 v3.11.2/go.mod h1:LWQzSc4vwfHA/3B8getTp8g3J5Z8tFBxgxinmGlMlJk= +github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= +github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= diff --git a/internal/adapter/adapter_test.go b/internal/adapter/adapter_test.go index e91cb5b..af85904 100644 --- a/internal/adapter/adapter_test.go +++ b/internal/adapter/adapter_test.go @@ -14,16 +14,20 @@ import ( "github.com/pressly/goose/v3" ) -const envDBDSN = "TESTS_DB_DSN" +const ( + envDBDSN = "TESTS_DB_DSN" + envRedisConnectionString = "TESTS_REDIS_CONNECTION_STRING" +) func TestMain(m *testing.M) { goose.SetBaseFS(os.DirFS("../../migrations")) goose.SetLogger(goose.NopLogger()) _, isDBDSNPresent := os.LookupEnv(envDBDSN) + _, isRedisConnectionStringPresent := os.LookupEnv(envRedisConnectionString) // all required envs are set, there is no need to use dockertest - if isDBDSNPresent { + if isDBDSNPresent && isRedisConnectionStringPresent { os.Exit(m.Run()) } @@ -45,6 +49,12 @@ func TestMain(m *testing.M) { resources = append(resources, resource) } + if !isRedisConnectionStringPresent { + resource, connString := newRedis(pool) + _ = os.Setenv(envRedisConnectionString, connString.String()) + resources = append(resources, resource) + } + code := m.Run() for _, r := range resources { @@ -66,7 +76,7 @@ func newPostgres(pool *dockertest.Pool) (*dockertest.Resource, *url.URL) { pw, _ := dsn.User.Password() - resource, runErr := pool.RunWithOptions(&dockertest.RunOptions{ + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ Repository: "postgres", Tag: "14.8", Env: []string{ @@ -80,8 +90,8 @@ func newPostgres(pool *dockertest.Pool) (*dockertest.Resource, *url.URL) { Name: "no", } }) - if runErr != nil { - log.Fatalf("couldn't run postgres: %s", runErr) + if err != nil { + log.Fatalf("couldn't run postgres: %s", err) } _ = resource.Expire(120) @@ -91,6 +101,32 @@ func newPostgres(pool *dockertest.Pool) (*dockertest.Resource, *url.URL) { return resource, dsn } +func newRedis(pool *dockertest.Pool) (*dockertest.Resource, *url.URL) { + connectionString := &url.URL{ + Scheme: "redis", + Path: "0", + } + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "redis", + Tag: "7.0.11", + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{ + Name: "no", + } + }) + if err != nil { + log.Fatalf("couldn't run redis: %s", err) + } + + _ = resource.Expire(120) + + connectionString.Host = getHostPort(resource, "6379/tcp") + + return resource, connectionString +} + func getHostPort(resource *dockertest.Resource, id string) string { dockerURL := os.Getenv("DOCKER_HOST") if dockerURL == "" { diff --git a/internal/adapter/bun_test.go b/internal/adapter/bun_test.go index b83fb30..9322c3d 100644 --- a/internal/adapter/bun_test.go +++ b/internal/adapter/bun_test.go @@ -29,7 +29,7 @@ func newBunDB(tb testing.TB) *bun.DB { sqldb := sql.OpenDB( pgdriver.NewConnector( pgdriver.WithDSN(os.Getenv(envDBDSN)), - pgdriver.WithConnParams(map[string]interface{}{ + pgdriver.WithConnParams(map[string]any{ "search_path": schema, }), ), diff --git a/internal/adapter/redis_test.go b/internal/adapter/redis_test.go new file mode 100644 index 0000000..339496c --- /dev/null +++ b/internal/adapter/redis_test.go @@ -0,0 +1,27 @@ +package adapter_test + +import ( + "context" + "os" + "testing" + + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func newRedisClient(tb testing.TB) *redis.Client { + tb.Helper() + + opts, err := redis.ParseURL(os.Getenv(envRedisConnectionString)) + require.NoError(tb, err, "couldn't parse redis connection string") + + client := redis.NewClient(opts) + tb.Cleanup(func() { + _ = client.Close() + }) + require.NoError(tb, retry(func() error { + return client.Ping(context.Background()).Err() + }), "couldn't ping DB") + + return client +} diff --git a/internal/adapter/redis_village.go b/internal/adapter/redis_village.go new file mode 100644 index 0000000..d0a3bda --- /dev/null +++ b/internal/adapter/redis_village.go @@ -0,0 +1,94 @@ +package adapter + +import ( + "bytes" + "context" + "encoding/gob" + "errors" + "fmt" + "time" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "github.com/redis/go-redis/v9" +) + +type VillageRedis struct { + client redis.UniversalClient +} + +func NewVillageRedis(client redis.UniversalClient) *VillageRedis { + return &VillageRedis{client: client} +} + +const translateVillageCoordsParamsExp = 72 * time.Hour + +type translateVillageCoordsParamsRedis struct { + VersionCode string + ServerKey string + Coords []string + PerPage int32 + MaxPage int32 + SHA256 string +} + +func (v *VillageRedis) SaveTranslateCoordsParams(ctx context.Context, params domain.TranslateVillageCoordsParams) error { + buf := bytes.NewBuffer(nil) + + if err := gob.NewEncoder(buf).Encode(translateVillageCoordsParamsRedis{ + VersionCode: params.VersionCode(), + ServerKey: params.ServerKey(), + Coords: params.Coords(), + PerPage: params.PerPage(), + MaxPage: params.MaxPage(), + SHA256: params.SHA256(), + }); err != nil { + return err + } + + if err := v.client.Set( + ctx, + v.buildTranslateCoordsParamsKey(params.SHA256()), + buf.Bytes(), + translateVillageCoordsParamsExp, + ).Err(); err != nil { + return err + } + + return nil +} + +func (v *VillageRedis) GetTranslateCoordsParams(ctx context.Context, sha256Hash string) (domain.TranslateVillageCoordsParams, error) { + b, err := v.client.Get(ctx, v.buildTranslateCoordsParamsKey(sha256Hash)).Bytes() + if errors.Is(err, redis.Nil) { + return domain.TranslateVillageCoordsParams{}, domain.TranslateVillageCoordsParamsNotFoundError{ + SHA256: sha256Hash, + } + } + if err != nil { + return domain.TranslateVillageCoordsParams{}, err + } + + var params translateVillageCoordsParamsRedis + + if err = gob.NewDecoder(bytes.NewReader(b)).Decode(¶ms); err != nil { + return domain.TranslateVillageCoordsParams{}, err + } + + res, err := domain.UnmarshalTranslateVillageCoordsParamsFromDatabase( + params.VersionCode, + params.ServerKey, + params.Coords, + params.PerPage, + params.MaxPage, + params.SHA256, + ) + if err != nil { + return domain.TranslateVillageCoordsParams{}, fmt.Errorf("couldn't unmarshal TranslateVillageCoordsParams from database: %w", err) + } + + return res, nil +} + +func (v *VillageRedis) buildTranslateCoordsParamsKey(sha256Hash string) string { + return "translate_village_coords_params_" + sha256Hash +} diff --git a/internal/adapter/redis_village_test.go b/internal/adapter/redis_village_test.go new file mode 100644 index 0000000..21f5144 --- /dev/null +++ b/internal/adapter/redis_village_test.go @@ -0,0 +1,33 @@ +package adapter_test + +import ( + "context" + "testing" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/adapter" + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVillageRedis_SaveTranslateCoordsParams_GetTranslateCoordsParams(t *testing.T) { + t.Parallel() + + repo := adapter.NewVillageRedis(newRedisClient(t)) + + params, err := domain.NewTranslateVillageCoordsParams("pl", "pl181", "123|123 898|123", 20) + require.NoError(t, err) + + assert.NoError(t, repo.SaveTranslateCoordsParams(context.Background(), params)) + + paramsRedis, err := repo.GetTranslateCoordsParams(context.Background(), params.SHA256()) + assert.NoError(t, err) + assert.Equal(t, params, paramsRedis) + + sha256Hash := params.SHA256() + "123" + paramsRedis, err = repo.GetTranslateCoordsParams(context.Background(), sha256Hash) + assert.ErrorIs(t, err, domain.TranslateVillageCoordsParamsNotFoundError{ + SHA256: sha256Hash, + }) + assert.Zero(t, paramsRedis) +} diff --git a/internal/discord/bot.go b/internal/discord/bot.go index 3184900..d3be39c 100644 --- a/internal/discord/bot.go +++ b/internal/discord/bot.go @@ -33,19 +33,30 @@ type ChoiceService interface { Versions(ctx context.Context) ([]domain.Choice, error) } +type VillageService interface { + TranslateCoords( + ctx context.Context, + params domain.TranslateVillageCoordsParams, + page int32, + ) (domain.TranslateVillageCoordsResult, error) + TranslateCoordsFromHash(ctx context.Context, paramsSHA256Hash string, page int32) (domain.TranslateVillageCoordsResult, error) +} + type Bot struct { - session *discordgo.Session - cron *cron.Cron - groupSvc GroupService - choiceSvc ChoiceService - logger *zap.Logger - localizer *discordi18n.Localizer + session *discordgo.Session + cron *cron.Cron + groupSvc GroupService + choiceSvc *choiceService + villageSvc VillageService + logger *zap.Logger + localizer *discordi18n.Localizer } func NewBot( token string, groupSvc GroupService, - client ChoiceService, + choiceSvc ChoiceService, + villageSvc VillageService, logger *zap.Logger, ) (*Bot, error) { localizer, err := discordi18n.NewLocalizer(language.English) @@ -67,10 +78,14 @@ func NewBot( cron.SkipIfStillRunning(cron.DiscardLogger), ), ), - groupSvc: groupSvc, - choiceSvc: client, - logger: logger, - localizer: localizer, + groupSvc: groupSvc, + choiceSvc: &choiceService{ + choiceSvc: choiceSvc, + localizer: localizer, + }, + villageSvc: villageSvc, + logger: logger, + localizer: localizer, } b.session.AddHandler(b.handleSessionReady) @@ -106,6 +121,7 @@ type command interface { func (b *Bot) registerCommands() error { commands := []command{ &groupCommand{localizer: b.localizer, logger: b.logger, groupSvc: b.groupSvc, choiceSvc: b.choiceSvc}, + &coordsCommand{localizer: b.localizer, logger: b.logger, villageSvc: b.villageSvc, choiceSvc: b.choiceSvc}, } for _, cmd := range commands { diff --git a/internal/discord/command_coords.go b/internal/discord/command_coords.go new file mode 100644 index 0000000..852fb20 --- /dev/null +++ b/internal/discord/command_coords.go @@ -0,0 +1,377 @@ +package discord + +import ( + "context" + "fmt" + "strconv" + "strings" + "text/template" + "time" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/discord/internal/discordi18n" + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "github.com/bwmarrin/discordgo" + "github.com/nicksnyder/go-i18n/v2/i18n" + "go.uber.org/zap" +) + +type coordsCommand struct { + localizer *discordi18n.Localizer + logger *zap.Logger + villageSvc VillageService + choiceSvc *choiceService +} + +func (c *coordsCommand) name() string { + return "coords" +} + +func (c *coordsCommand) register(s *discordgo.Session) error { + if err := c.create(s); err != nil { + return err + } + + s.AddHandler(c.handle) + + return nil +} + +func (c *coordsCommand) create(s *discordgo.Session) error { + dm := true + + optionVersionDefault, optionVersionLocalizations, err := c.localizer.LocalizeDiscord("cmd.coords.option.version.description") + if err != nil { + return err + } + + optionServerDefault, optionServerLocalizations, err := c.localizer.LocalizeDiscord("cmd.coords.option.server.description") + if err != nil { + return err + } + + optionCoordsDefault, optionCoordsLocalizations, err := c.localizer.LocalizeDiscord("cmd.coords.option.coords.description") + if err != nil { + return err + } + + cmdDescriptionDefault, cmdDescriptionLocalizations, err := c.localizer.LocalizeDiscord("cmd.coords.description") + if err != nil { + return err + } + + versionChoices, err := c.choiceSvc.versions(context.Background()) + if err != nil { + return err + } + + _, err = s.ApplicationCommandCreate(s.State.User.ID, "", &discordgo.ApplicationCommand{ + Name: c.name(), + Description: cmdDescriptionDefault, + DescriptionLocalizations: &cmdDescriptionLocalizations, + DMPermission: &dm, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "version", + Description: optionVersionDefault, + DescriptionLocalizations: optionVersionLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Choices: versionChoices, + Required: true, + }, + { + Name: "server", + Description: optionServerDefault, + DescriptionLocalizations: optionServerLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + { + Name: "coords", + Description: optionCoordsDefault, + DescriptionLocalizations: optionCoordsLocalizations, + Type: discordgo.ApplicationCommandOptionString, + Required: true, + MaxLength: domain.VillageCoordsStringMaxLength, + }, + }, + }) + if err != nil { + return err + } + + return nil +} + +func (c *coordsCommand) handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + ctx := context.Background() + + //nolint:exhaustive + switch i.Type { + case discordgo.InteractionApplicationCommand: + c.handleCommand(ctx, s, i) + case discordgo.InteractionMessageComponent: + c.handleMessageComponent(ctx, s, i) + } +} + +func (c *coordsCommand) handleCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + cmdData := i.ApplicationCommandData() + + if cmdData.Name != c.name() { + return + } + + locale := string(i.Locale) + + params, err := c.optionsToTranslateCoordsParams(cmdData.Options) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + }) + return + } + + res, err := c.villageSvc.TranslateCoords(ctx, params, 1) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + }) + return + } + + _ = s.InteractionRespond(i.Interaction, c.buildResponse(res, locale)) +} + +const coordsTranslationVillagesPerPage = 7 + +func (c *coordsCommand) optionsToTranslateCoordsParams( + options []*discordgo.ApplicationCommandInteractionDataOption, +) (domain.TranslateVillageCoordsParams, error) { + version := "" + server := "" + coordsStr := "" + + for _, opt := range options { + switch opt.Name { + case "version": + version = opt.StringValue() + case "server": + server = opt.StringValue() + case "coords": + coordsStr = opt.StringValue() + } + } + + return domain.NewTranslateVillageCoordsParams(version, server, coordsStr, coordsTranslationVillagesPerPage) +} + +func (c *coordsCommand) handleMessageComponent(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { + cmpData := i.MessageComponentData() + + if cmpData.ComponentType != discordgo.ButtonComponent { + return + } + + locale := string(i.Locale) + + page, hash, err := c.parseButtonID(cmpData.CustomID) + if err != nil { + return + } + + res, err := c.villageSvc.TranslateCoordsFromHash(ctx, hash, page) + if err != nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + }) + return + } + + _ = s.InteractionRespond(i.Interaction, c.buildResponse(res, locale)) +} + +func (c *coordsCommand) buildResponse(res domain.TranslateVillageCoordsResult, locale string) *discordgo.InteractionResponse { + titleMessageID := "cmd.coords.embed.title" + title, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{MessageID: titleMessageID}) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", titleMessageID), zap.Error(err)) + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + } + } + + footerMessageID := "cmd.coords.embed.footer" + footer, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{ + MessageID: footerMessageID, + TemplateData: map[string]any{ + "Page": res.Page, + "MaxPage": res.MaxPage, + }, + }) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", footerMessageID), zap.Error(err)) + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + } + } + + buttonNextPageMessageID := "cmd.coords.embed.button.next" + buttonNextPage, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{MessageID: buttonNextPageMessageID}) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", buttonNextPageMessageID), zap.Error(err)) + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + } + } + + buttonPrevPageMessageID := "cmd.coords.embed.button.prev" + buttonPrevPage, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{MessageID: buttonPrevPageMessageID}) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", buttonPrevPageMessageID), zap.Error(err)) + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + } + } + + description, err := c.buildDescription(locale, res.Villages, res.NotFound) + if err != nil { + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: c.localizer.LocalizeError(locale, err), + }, + } + } + + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Type: discordgo.EmbedTypeRich, + Title: title, + Description: description, + Color: colorGrey, + Timestamp: formatTimestamp(time.Now()), + Footer: &discordgo.MessageEmbedFooter{ + Text: footer, + }, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Style: discordgo.PrimaryButton, + CustomID: c.buildButtonID(res.Page-1, res.ParamsSHA256), + Label: buttonPrevPage, + Disabled: !res.HasPrev, + }, + discordgo.Button{ + Style: discordgo.PrimaryButton, + CustomID: c.buildButtonID(res.Page+1, res.ParamsSHA256), + Label: buttonNextPage, + Disabled: !res.HasNext, + }, + }, + }, + }, + }, + } +} + +func (c *coordsCommand) buildDescription(locale string, villages []domain.Village, notFound []string) (string, error) { + var builderDescription strings.Builder + + for i, v := range villages { + if i > 0 { + builderDescription.WriteString("\n") + } + + descriptionMessageID := "cmd.coords.embed.description.village" + description, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{ + MessageID: descriptionMessageID, + TemplateData: map[string]any{ + "Village": v, + }, + Funcs: template.FuncMap{ + "buildPlayerMarkdown": buildPlayerMarkdown, + "buildLink": buildLink, + }, + }) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", descriptionMessageID), zap.Error(err)) + return "", err + } + + builderDescription.WriteString(description) + } + + if len(notFound) == 0 { + return builderDescription.String(), nil + } + + if builderDescription.Len() > 0 { + builderDescription.WriteString("\n") + } + + descriptionMessageID := "cmd.coords.embed.description.not-found" + description, err := c.localizer.Localize(locale, &i18n.LocalizeConfig{ + MessageID: descriptionMessageID, + TemplateData: map[string]any{ + "Coords": strings.Join(notFound, ", "), + }, + }) + if err != nil { + c.logger.Error("no message with the specified id", zap.String("id", descriptionMessageID), zap.Error(err)) + return "", err + } + + builderDescription.WriteString(description) + + return builderDescription.String(), nil +} + +const ( + coordsTranslationButtonIDPrefix = "coords" + coordsTranslationButtonIDSeparator = "-" +) + +func (c *coordsCommand) buildButtonID(page int32, hash string) string { + return fmt.Sprintf("%s%s%d%s%s", coordsTranslationButtonIDPrefix, coordsTranslationButtonIDSeparator, page, coordsTranslationButtonIDSeparator, hash) +} + +func (c *coordsCommand) parseButtonID(id string) (int32, string, error) { + chunks := strings.Split(id, coordsTranslationButtonIDSeparator) + if len(chunks) != 3 || chunks[0] != coordsTranslationButtonIDPrefix { + return 0, "", fmt.Errorf("couldn't parse '%s': incorrect format", id) + } + + page, err := strconv.ParseInt(chunks[1], 10, 32) + if err != nil { + return 0, "", fmt.Errorf("couldn't parse '%s': %w", id, err) + } + + return int32(page), chunks[2], nil +} diff --git a/internal/discord/command_group.go b/internal/discord/command_group.go index 71797f3..cb3ecf6 100644 --- a/internal/discord/command_group.go +++ b/internal/discord/command_group.go @@ -2,7 +2,6 @@ package discord import ( "context" - "fmt" "strings" "time" @@ -21,7 +20,7 @@ type groupCommand struct { localizer *discordi18n.Localizer logger *zap.Logger groupSvc GroupService - choiceSvc ChoiceService + choiceSvc *choiceService } func (c *groupCommand) name() string { @@ -100,12 +99,12 @@ func (c *groupCommand) buildSubcommandCreate() (*discordgo.ApplicationCommandOpt } func (c *groupCommand) buildSubcommandCreateOptions() ([]*discordgo.ApplicationCommandOption, error) { - versionChoices, err := c.getVersionChoices() + versionChoices, err := c.choiceSvc.versions(context.Background()) if err != nil { return nil, err } - languageChoices, err := c.getLanguageChoices() + languageChoices, err := c.choiceSvc.languages() if err != nil { return nil, err } @@ -208,40 +207,6 @@ func (c *groupCommand) buildSubcommandCreateOptions() ([]*discordgo.ApplicationC }, nil } -func (c *groupCommand) getVersionChoices() ([]*discordgo.ApplicationCommandOptionChoice, error) { - choices, err := c.choiceSvc.Versions(context.Background()) - if err != nil { - return nil, fmt.Errorf("ChoiceService.Versions: %w", err) - } - - dcChoices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(choices)) - for _, v := range choices { - dcChoices = append(dcChoices, &discordgo.ApplicationCommandOptionChoice{ - Name: v.Name, - Value: v.Value, - }) - } - - return dcChoices, nil -} - -func (c *groupCommand) getLanguageChoices() ([]*discordgo.ApplicationCommandOptionChoice, error) { - tags := c.localizer.LanguageTags() - choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(tags)) - for _, tag := range tags { - lang, langLocalizations, err := c.localizer.LocalizeDiscord(buildLangMessageID(tag.String())) - if err != nil { - return nil, err - } - choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ - Name: lang, - NameLocalizations: langLocalizations, - Value: tag.String(), - }) - } - return choices, nil -} - func (c *groupCommand) buildSubcommandList() (*discordgo.ApplicationCommandOption, error) { cmdDescriptionDefault, cmdDescriptionLocalizations, err := c.localizer.LocalizeDiscord("cmd.group.list.description") if err != nil { @@ -420,7 +385,7 @@ func (c *groupCommand) buildSubcommandSet() (*discordgo.ApplicationCommandOption } func (c *groupCommand) buildSubcommandSetLanguage() (*discordgo.ApplicationCommandOption, error) { - languageChoices, err := c.getLanguageChoices() + languageChoices, err := c.choiceSvc.languages() if err != nil { return nil, err } @@ -877,6 +842,7 @@ func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i * Type: discordgo.EmbedTypeRich, Title: title, Fields: fields, + Color: colorGrey, Timestamp: formatTimestamp(time.Now()), }, }, @@ -964,6 +930,7 @@ func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, Type: discordgo.EmbedTypeRich, Title: g.ID, Description: description, + Color: colorGrey, Timestamp: formatTimestamp(time.Now()), }, }, diff --git a/internal/discord/discord.go b/internal/discord/discord.go index 6e0565b..2552267 100644 --- a/internal/discord/discord.go +++ b/internal/discord/discord.go @@ -3,9 +3,12 @@ package discord import ( "fmt" "time" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" ) const ( + colorGrey = 0x808080 embedDescriptionCharLimit = 4096 ) @@ -34,3 +37,19 @@ func boolToEmoji(val bool) string { func buildLangMessageID(languageTag string) string { return "lang." + languageTag } + +func buildPlayerMarkdown(p domain.NullPlayerMeta) string { + if !p.Valid { + return "-" + } + + tag := p.Player.Tribe.Tribe.Tag + if tag == "" { + tag = "-" + } + + return buildLink(p.Player.Name, p.Player.ProfileURL) + + " [" + + buildLink(tag, p.Player.Tribe.Tribe.ProfileURL) + + "]" +} diff --git a/internal/discord/internal/discordi18n/locale.en.json b/internal/discord/internal/discordi18n/locale.en.json index 532c423..12041b7 100644 --- a/internal/discord/internal/discordi18n/locale.en.json +++ b/internal/discord/internal/discordi18n/locale.en.json @@ -14,6 +14,12 @@ "err.tribe-tag-not-found": "Tribe (tag={{ .Tag }}) not found.", "err.tribe-id-not-found": "Tribe (id={{ .ID }}) not found.", "err.tribe-does-not-exist": "The tribe (tag={{ .Tag }}) doesn't exist.", + "err.greater-equal-than": "{{ .Field }} must be no less than {{ .Threshold }}.", + "err.less-equal-than": "{{ .Field }} must be no greater than {{ .Threshold }}.", + "err.length-too-short": "{{ .Field }}: the length must be no less than than {{ .Threshold }}.", + "err.village-coords-string-too-long": "The given string is too long ({{ len .Str }}/{{ .Max }}).", + "err.village-coords-not-found-in-string": "No coords found in the given string.", + "err.translate-village-coords-params-not-found": "This action is no longer available.", "job.execute-monitors.embed.description": "{{ buildPlayerMarkdown .Ennoblement.NewOwner }} has taken {{ buildLink .Ennoblement.Village.FullName .Ennoblement.Village.ProfileURL }} (Old owner: {{ buildPlayerMarkdown .Ennoblement.Village.Player }}).", @@ -77,5 +83,16 @@ "cmd.group.delete.description": "Deletes a group", "cmd.group.delete.option.group.description": "Group ID", - "cmd.group.delete.success": "The group has been successfully deleted." + "cmd.group.delete.success": "The group has been successfully deleted.", + + "cmd.coords.description": "Translates village coords", + "cmd.coords.option.version.description": "e.g. www.tribalwars.net, www.plemiona.pl", + "cmd.coords.option.server.description": "Tribal Wars server (e.g. en115, pl170)", + "cmd.coords.option.coords.description": "Coords", + "cmd.coords.embed.title": "Villages", + "cmd.coords.embed.description.village": "{{ buildLink .Village.FullName .Village.ProfileURL }} owned by {{ buildPlayerMarkdown .Village.Player }}", + "cmd.coords.embed.description.not-found": "**Not found**: {{ .Coords }}", + "cmd.coords.embed.button.next": "Next page", + "cmd.coords.embed.button.prev": "Previous page", + "cmd.coords.embed.footer": "Page {{ .Page }}/{{ .MaxPage }}" } diff --git a/internal/discord/internal/discordi18n/locale.pl.json b/internal/discord/internal/discordi18n/locale.pl.json index 5987a7c..90fb7b8 100644 --- a/internal/discord/internal/discordi18n/locale.pl.json +++ b/internal/discord/internal/discordi18n/locale.pl.json @@ -14,6 +14,12 @@ "err.tribe-tag-not-found": "Plemię (skrót={{ .Tag }}) nie istnieje.", "err.tribe-id-not-found": "Plemię (id={{ .ID }}) nie istnieje.", "err.tribe-does-not-exist": "Plemię (skrót={{ .Tag }}) nie istnieje.", + "err.greater-equal-than": "{{ .Field }} musi byc nie mniejszy niż {{ .Threshold }}.", + "err.less-equal-than": "{{ .Field }} musi być nie większy niż {{ .Threshold }}.", + "err.length-too-short": "{{ .Field }}: długość nie może być mniejsza niż {{ .Threshold }}.", + "err.village-coords-string-too-long": "Podany ciąg znaków jest zbyt długi ({{ len .Str }}/{{ .Max }}).", + "err.village-coords-not-found-in-string": "Nie znaleziono współrzędnych w podanym ciągu znaków.", + "err.translate-village-coords-params-not-found": "Ta akcja nie jest już dostępna.", "job.execute-monitors.embed.description": "{{ buildPlayerMarkdown .Ennoblement.NewOwner }} przejął {{ buildLink .Ennoblement.Village.FullName .Ennoblement.Village.ProfileURL }} (Poprzedni właściciel: {{ buildPlayerMarkdown .Ennoblement.Village.Player }}).", @@ -77,5 +83,16 @@ "cmd.group.delete.description": "Usuwa grupę", "cmd.group.delete.option.group.description": "ID grupy", - "cmd.group.delete.success": "Grupa została pomyślnie usunięta." + "cmd.group.delete.success": "Grupa została pomyślnie usunięta.", + + "cmd.coords.description": "Tłumaczy kordy wiosek", + "cmd.coords.option.version.description": "np. www.tribalwars.net, www.plemiona.pl", + "cmd.coords.option.server.description": "Serwer (np. en115, pl170)", + "cmd.coords.option.coords.description": "Kordy", + "cmd.coords.embed.title": "Wioski", + "cmd.coords.embed.description.village": "{{ buildLink .Village.FullName .Village.ProfileURL }} należąca do {{ buildPlayerMarkdown .Village.Player }}", + "cmd.coords.embed.description.not-found": "**Nie znaleziono**: {{ .Coords }}", + "cmd.coords.embed.button.next": "Następna strona", + "cmd.coords.embed.button.prev": "Poprzednia strona", + "cmd.coords.embed.footer": "Strona {{ .Page }}/{{ .MaxPage }}" } diff --git a/internal/discord/job_execute_monitors.go b/internal/discord/job_execute_monitors.go index ec81df1..2cae30b 100644 --- a/internal/discord/job_execute_monitors.go +++ b/internal/discord/job_execute_monitors.go @@ -129,28 +129,12 @@ func (c ennoblementNotificationToMessageEmbedConverter) buildDescription(n domai "Ennoblement": n.Ennoblement, }, Funcs: template.FuncMap{ - "buildPlayerMarkdown": c.buildPlayerMarkdown, + "buildPlayerMarkdown": buildPlayerMarkdown, "buildLink": buildLink, }, }) } -func (c ennoblementNotificationToMessageEmbedConverter) buildPlayerMarkdown(p domain.NullPlayerMeta) string { - if !p.Valid { - return "Barbarians" - } - - tag := p.Player.Tribe.Tribe.Tag - if tag == "" { - tag = "-" - } - - return buildLink(p.Player.Name, p.Player.ProfileURL) + - " [" + - buildLink(tag, p.Player.Tribe.Tribe.ProfileURL) + - "]" -} - func (c ennoblementNotificationToMessageEmbedConverter) mapNotificationTypeToColor(t domain.EnnoblementNotificationType) int { switch t { case domain.EnnoblementNotificationTypeGain: diff --git a/internal/discord/service_choice.go b/internal/discord/service_choice.go new file mode 100644 index 0000000..b21f5ca --- /dev/null +++ b/internal/discord/service_choice.go @@ -0,0 +1,51 @@ +package discord + +import ( + "context" + "fmt" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/discord/internal/discordi18n" + "github.com/bwmarrin/discordgo" +) + +type choiceService struct { + localizer *discordi18n.Localizer + choiceSvc ChoiceService +} + +func (s *choiceService) versions(ctx context.Context) ([]*discordgo.ApplicationCommandOptionChoice, error) { + choices, err := s.choiceSvc.Versions(ctx) + if err != nil { + return nil, fmt.Errorf("ChoiceService.Versions: %w", err) + } + + dcChoices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(choices)) + for _, v := range choices { + dcChoices = append(dcChoices, &discordgo.ApplicationCommandOptionChoice{ + Name: v.Name, + Value: v.Value, + }) + } + + return dcChoices, nil +} + +func (s *choiceService) languages() ([]*discordgo.ApplicationCommandOptionChoice, error) { + tags := s.localizer.LanguageTags() + + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(tags)) + for _, tag := range tags { + lang, langLocalizations, err := s.localizer.LocalizeDiscord(buildLangMessageID(tag.String())) + if err != nil { + return nil, err + } + + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: lang, + NameLocalizations: langLocalizations, + Value: tag.String(), + }) + } + + return choices, nil +} diff --git a/internal/domain/domain.go b/internal/domain/domain.go index e8fe851..87e10ec 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -9,3 +9,19 @@ type NullBool struct { Bool bool Valid bool // Valid is true if Bool is not NULL } + +func uniq[T comparable](sl []T) []T { + res := make([]T, 0, len(sl)) + seen := make(map[T]struct{}, len(sl)) + + for _, it := range sl { + if _, ok := seen[it]; ok { + continue + } + + seen[it] = struct{}{} + res = append(res, it) + } + + return res +} diff --git a/internal/domain/error.go b/internal/domain/error.go index 4d77f09..be1e8e7 100644 --- a/internal/domain/error.go +++ b/internal/domain/error.go @@ -32,3 +32,69 @@ func (e RequiredError) Params() map[string]any { "Field": e.Field, } } + +type GreaterEqualThanError struct { + Field string + Threshold int +} + +var _ TranslatableError = GreaterEqualThanError{} + +func (e GreaterEqualThanError) Error() string { + return fmt.Sprintf("%s must be no less than %d", e.Field, e.Threshold) +} + +func (e GreaterEqualThanError) Slug() string { + return "greater-equal-than" +} + +func (e GreaterEqualThanError) Params() map[string]any { + return map[string]any{ + "Field": e.Field, + "Threshold": e.Threshold, + } +} + +type LessEqualThanError struct { + Field string + Threshold int +} + +var _ TranslatableError = LessEqualThanError{} + +func (e LessEqualThanError) Error() string { + return fmt.Sprintf("%s must be no no greater than %d", e.Field, e.Threshold) +} + +func (e LessEqualThanError) Slug() string { + return "less-equal-than" +} + +func (e LessEqualThanError) Params() map[string]any { + return map[string]any{ + "Field": e.Field, + "Threshold": e.Threshold, + } +} + +type LengthTooShortError struct { + Field string + Threshold int +} + +var _ TranslatableError = LengthTooShortError{} + +func (e LengthTooShortError) Error() string { + return fmt.Sprintf("%s: the length must be no less than %d", e.Field, e.Threshold) +} + +func (e LengthTooShortError) Slug() string { + return "length-too-short" +} + +func (e LengthTooShortError) Params() map[string]any { + return map[string]any{ + "Field": e.Field, + "Threshold": e.Threshold, + } +} diff --git a/internal/domain/tribe.go b/internal/domain/tribe.go index 91e3f54..6f24169 100644 --- a/internal/domain/tribe.go +++ b/internal/domain/tribe.go @@ -34,7 +34,7 @@ type TribeTagNotFoundError struct { var _ TranslatableError = TribeTagNotFoundError{} func (e TribeTagNotFoundError) Error() string { - return fmt.Sprintf("tribe (versionCode=%s,serverKey=%s,tag=%s) not found", e.VersionCode, e.ServerKey, e.Tag) + return fmt.Sprintf("tribe (VersionCode=%s,ServerKey=%s,Tag=%s) not found", e.VersionCode, e.ServerKey, e.Tag) } func (e TribeTagNotFoundError) Slug() string { @@ -58,7 +58,7 @@ type TribeIDNotFoundError struct { var _ TranslatableError = TribeIDNotFoundError{} func (e TribeIDNotFoundError) Error() string { - return fmt.Sprintf("tribe (versionCode=%s,serverKey=%s,id=%d) not found", e.VersionCode, e.ServerKey, e.ID) + return fmt.Sprintf("tribe (VersionCode=%s,ServerKey=%s,ID=%d) not found", e.VersionCode, e.ServerKey, e.ID) } func (e TribeIDNotFoundError) Slug() string { diff --git a/internal/domain/tw_server.go b/internal/domain/tw_server.go index 3a05f16..38932c3 100644 --- a/internal/domain/tw_server.go +++ b/internal/domain/tw_server.go @@ -16,7 +16,7 @@ type TWServerNotFoundError struct { var _ TranslatableError = TWServerNotFoundError{} func (e TWServerNotFoundError) Error() string { - return fmt.Sprintf("server (versionCode=%s,key=%s) not found", e.VersionCode, e.Key) + return fmt.Sprintf("server (VersionCode=%s,Key=%s) not found", e.VersionCode, e.Key) } func (e TWServerNotFoundError) Slug() string { diff --git a/internal/domain/village.go b/internal/domain/village.go index e79f96e..2dc791e 100644 --- a/internal/domain/village.go +++ b/internal/domain/village.go @@ -1,5 +1,14 @@ package domain +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "regexp" + "strings" +) + type Village struct { ID int64 FullName string @@ -10,9 +19,215 @@ type Village struct { Player NullPlayerMeta } +func (v Village) Coords() string { + return fmt.Sprintf("%d|%d", v.X, v.Y) +} + type VillageMeta struct { ID int64 FullName string ProfileURL string Player NullPlayerMeta } + +type VillageCoordsStringTooLongError struct { + Str string + Max int +} + +var _ TranslatableError = VillageCoordsStringTooLongError{} + +func (e VillageCoordsStringTooLongError) Error() string { + return fmt.Sprintf("coords string is too long (%d/%d)", len(e.Str), e.Max) +} + +func (e VillageCoordsStringTooLongError) Slug() string { + return "village-coords-string-too-long" +} + +func (e VillageCoordsStringTooLongError) Params() map[string]any { + return map[string]any{ + "Str": e.Str, + "Max": e.Max, + } +} + +type VillageCoordsNotFoundInStringError struct { + Str string +} + +var _ TranslatableError = VillageCoordsNotFoundInStringError{} + +func (e VillageCoordsNotFoundInStringError) Error() string { + return fmt.Sprintf("no coords found in string: '%s'", e.Str) +} + +func (e VillageCoordsNotFoundInStringError) Slug() string { + return "village-coords-not-found-in-string" +} + +func (e VillageCoordsNotFoundInStringError) Params() map[string]any { + return map[string]any{ + "Str": e.Str, + } +} + +type TranslateVillageCoordsParams struct { + versionCode string + serverKey string + coords []string + perPage int32 + maxPage int32 + sha256 string +} + +var villageCoordsRegex = regexp.MustCompile(`(\d+)\|(\d+)`) + +const ( + VillageCoordsStringMaxLength = 1000 + translateVillageCoordsMinPerPage = 1 +) + +func NewTranslateVillageCoordsParams(versionCode, serverKey, coordsStr string, perPage int32) (TranslateVillageCoordsParams, error) { + if versionCode == "" { + return TranslateVillageCoordsParams{}, RequiredError{Field: "VersionCode"} + } + + if serverKey == "" { + return TranslateVillageCoordsParams{}, RequiredError{Field: "ServerKey"} + } + + if perPage < translateVillageCoordsMinPerPage { + return TranslateVillageCoordsParams{}, GreaterEqualThanError{ + Field: "PerPage", + Threshold: translateVillageCoordsMinPerPage, + } + } + + if len(coordsStr) > VillageCoordsStringMaxLength { + return TranslateVillageCoordsParams{}, VillageCoordsStringTooLongError{ + Max: VillageCoordsStringMaxLength, + Str: coordsStr, + } + } + + coords := uniq(villageCoordsRegex.FindAllString(coordsStr, -1)) + if len(coords) == 0 { + return TranslateVillageCoordsParams{}, VillageCoordsNotFoundInStringError{ + Str: coordsStr, + } + } + + sum256 := sha256.Sum256(fmt.Appendf(nil, "%s-%s-%s-%d", versionCode, serverKey, strings.Join(coords, ","), perPage)) + + return TranslateVillageCoordsParams{ + versionCode: versionCode, + serverKey: serverKey, + coords: coords, + perPage: perPage, + maxPage: int32(math.Ceil(float64(len(coords)) / float64(perPage))), + sha256: hex.EncodeToString(sum256[:]), + }, nil +} + +// UnmarshalTranslateVillageCoordsParamsFromDatabase unmarshals TranslateVillageCoordsParams from the database. +// +// It should be used only for unmarshalling from the database! +// You can't use UnmarshalTranslateVillageCoordsParamsFromDatabase as constructor - It may put domain into the invalid state! +func UnmarshalTranslateVillageCoordsParamsFromDatabase( + versionCode string, + serverKey string, + coords []string, + perPage int32, + maxPage int32, + sha256Hash string, +) (TranslateVillageCoordsParams, error) { + if versionCode == "" { + return TranslateVillageCoordsParams{}, RequiredError{Field: "VersionCode"} + } + + if serverKey == "" { + return TranslateVillageCoordsParams{}, RequiredError{Field: "ServerKey"} + } + + if len(coords) == 0 { + return TranslateVillageCoordsParams{}, LengthTooShortError{ + Field: "Coords", + Threshold: 1, + } + } + + if perPage < translateVillageCoordsMinPerPage { + return TranslateVillageCoordsParams{}, GreaterEqualThanError{ + Field: "PerPage", + Threshold: translateVillageCoordsMinPerPage, + } + } + + if sha256Hash == "" { + return TranslateVillageCoordsParams{}, RequiredError{Field: "SHA256"} + } + + return TranslateVillageCoordsParams{ + versionCode: versionCode, + serverKey: serverKey, + coords: coords, + perPage: perPage, + maxPage: maxPage, + sha256: sha256Hash, + }, nil +} + +func (t TranslateVillageCoordsParams) VersionCode() string { + return t.versionCode +} + +func (t TranslateVillageCoordsParams) ServerKey() string { + return t.serverKey +} + +func (t TranslateVillageCoordsParams) Coords() []string { + return t.coords +} + +func (t TranslateVillageCoordsParams) PerPage() int32 { + return t.perPage +} + +func (t TranslateVillageCoordsParams) MaxPage() int32 { + return t.maxPage +} + +func (t TranslateVillageCoordsParams) SHA256() string { + return t.sha256 +} + +type TranslateVillageCoordsResult struct { + Villages []Village + NotFound []string + HasNext bool + HasPrev bool + Page int32 + MaxPage int32 + ParamsSHA256 string +} + +type TranslateVillageCoordsParamsNotFoundError struct { + SHA256 string +} + +var _ TranslatableError = TranslateVillageCoordsParamsNotFoundError{} + +func (e TranslateVillageCoordsParamsNotFoundError) Error() string { + return fmt.Sprintf("params (SHA256=%s) not found", e.SHA256) +} + +func (e TranslateVillageCoordsParamsNotFoundError) Slug() string { + return "translate-village-coords-params-not-found" +} + +func (e TranslateVillageCoordsParamsNotFoundError) Params() map[string]any { + return map[string]any{ + "SHA256": e.SHA256, + } +} diff --git a/internal/domain/village_test.go b/internal/domain/village_test.go new file mode 100644 index 0000000..57431e3 --- /dev/null +++ b/internal/domain/village_test.go @@ -0,0 +1,146 @@ +package domain_test + +import ( + "fmt" + "testing" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTranslateVillageCoordsParams(t *testing.T) { + t.Parallel() + + tooLongStr := "" + for len(tooLongStr) <= 1000 { + tooLongStr += "too long" + } + + tests := []struct { + versionCode string + serverKey string + coordsStr string + coords []string + perPage int32 + maxPage int32 + err error + }{ + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: "test string 123|123 898|123", + coords: []string{"123|123", "898|123"}, + perPage: 5, + maxPage: 1, + }, + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: "123|123 898|123 988|123 123|158 785|567", + coords: []string{"123|123", "898|123", "988|123", "123|158", "785|567"}, + perPage: 4, + maxPage: 2, + }, + + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: "123|123 123|123 898|898 898|898", + coords: []string{"123|123", "898|898"}, + perPage: 4, + maxPage: 1, + }, + { + versionCode: "", + serverKey: "pl181", + coordsStr: "123|123", + err: domain.RequiredError{ + Field: "VersionCode", + }, + perPage: 1, + }, + { + versionCode: "pl", + serverKey: "", + coordsStr: "123|123", + err: domain.RequiredError{ + Field: "ServerKey", + }, + perPage: 1, + }, + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: "123|123", + err: domain.GreaterEqualThanError{ + Field: "PerPage", + Threshold: 1, + }, + }, + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: "no coords", + err: domain.VillageCoordsNotFoundInStringError{ + Str: "no coords", + }, + perPage: 1, + }, + { + versionCode: "pl", + serverKey: "pl181", + coordsStr: tooLongStr, + err: domain.VillageCoordsStringTooLongError{ + Str: tooLongStr, + Max: 1000, + }, + perPage: 1, + }, + } + + for _, tt := range tests { + tt := tt + + testName := fmt.Sprintf("versionCode=%s | serverKey=%s | perPage=%d | coordsStr=%s", tt.versionCode, tt.serverKey, tt.perPage, tt.coordsStr) + if len(testName) > 100 { + testName = testName[:97] + "..." + } + + t.Run(testName, func(t *testing.T) { + t.Parallel() + + res, err := domain.NewTranslateVillageCoordsParams(tt.versionCode, tt.serverKey, tt.coordsStr, tt.perPage) + if tt.err != nil { + assert.ErrorIs(t, err, tt.err) + assert.Zero(t, res) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.versionCode, res.VersionCode()) + assert.Equal(t, tt.serverKey, res.ServerKey()) + assert.Equal(t, tt.coords, res.Coords()) + assert.Equal(t, tt.perPage, res.PerPage()) + assert.Equal(t, tt.maxPage, res.MaxPage()) + assert.NotEmpty(t, res.SHA256()) + }) + } +} + +func TestUnmarshalTranslateVillageCoordsParamsFromDatabase(t *testing.T) { + t.Parallel() + + params, err := domain.NewTranslateVillageCoordsParams("pl", "pl181", "123|123 871|981", 1) + require.NoError(t, err) + + paramsUnmarshal, err := domain.UnmarshalTranslateVillageCoordsParamsFromDatabase( + params.VersionCode(), + params.ServerKey(), + params.Coords(), + params.PerPage(), + params.MaxPage(), + params.SHA256(), + ) + assert.NoError(t, err) + assert.Equal(t, params, paramsUnmarshal) +} diff --git a/internal/service/village.go b/internal/service/village.go new file mode 100644 index 0000000..25b2099 --- /dev/null +++ b/internal/service/village.go @@ -0,0 +1,88 @@ +package service + +import ( + "context" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" +) + +//counterfeiter:generate -o internal/mock/village_repository.gen.go . VillageRepository +type VillageRepository interface { + SaveTranslateCoordsParams(ctx context.Context, params domain.TranslateVillageCoordsParams) error + GetTranslateCoordsParams(ctx context.Context, sha256Hash string) (domain.TranslateVillageCoordsParams, error) +} + +type Village struct { + repo VillageRepository + twhelpSvc TWHelpService +} + +func NewVillage(repo VillageRepository, twhelpSvc TWHelpService) *Village { + return &Village{repo: repo, twhelpSvc: twhelpSvc} +} + +func (v *Village) TranslateCoords( + ctx context.Context, + params domain.TranslateVillageCoordsParams, + page int32, +) (domain.TranslateVillageCoordsResult, error) { + if page < 1 { + return domain.TranslateVillageCoordsResult{}, domain.GreaterEqualThanError{Field: "page", Threshold: 1} + } + + if page > params.MaxPage() { + return domain.TranslateVillageCoordsResult{}, domain.LessEqualThanError{Field: "page", Threshold: int(params.MaxPage())} + } + + if err := v.repo.SaveTranslateCoordsParams(ctx, params); err != nil { + return domain.TranslateVillageCoordsResult{}, err + } + + low := params.PerPage() * (page - 1) + high := params.PerPage() * page + if coordsLen := int32(len(params.Coords())); coordsLen < high { + high = coordsLen + } + + coords := params.Coords()[low:high] + + villages, err := v.twhelpSvc.ListVillagesByCoords(ctx, params.VersionCode(), params.ServerKey(), coords, 0, high-low) + if err != nil { + return domain.TranslateVillageCoordsResult{}, err + } + + notFound := make([]string, 0, high) + for _, co := range coords { + found := false + + for _, v := range villages { + if co == v.Coords() { + found = true + break + } + } + + if !found { + notFound = append(notFound, co) + } + } + + return domain.TranslateVillageCoordsResult{ + Villages: villages, + NotFound: notFound, + HasNext: params.MaxPage() > page, + HasPrev: page > 1, + Page: page, + MaxPage: params.MaxPage(), + ParamsSHA256: params.SHA256(), + }, nil +} + +func (v *Village) TranslateCoordsFromHash(ctx context.Context, paramsSHA256Hash string, page int32) (domain.TranslateVillageCoordsResult, error) { + params, err := v.repo.GetTranslateCoordsParams(ctx, paramsSHA256Hash) + if err != nil { + return domain.TranslateVillageCoordsResult{}, err + } + + return v.TranslateCoords(ctx, params, page) +} diff --git a/internal/service/village_test.go b/internal/service/village_test.go new file mode 100644 index 0000000..fa38dec --- /dev/null +++ b/internal/service/village_test.go @@ -0,0 +1,190 @@ +package service_test + +import ( + "context" + "strconv" + "strings" + "testing" + + "gitea.dwysokinski.me/twhelp/dcbot/internal/domain" + "gitea.dwysokinski.me/twhelp/dcbot/internal/service" + "gitea.dwysokinski.me/twhelp/dcbot/internal/service/internal/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVillage_TranslateCoords(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + villages := []domain.Village{ + { + X: 123, + Y: 123, + }, + { + X: 897, + Y: 123, + }, + { + X: 778, + Y: 689, + }, + } + + notFound := []string{ + "789|123", + "678|123", + } + + var builderCoordsStr strings.Builder + + for _, v := range villages { + builderCoordsStr.WriteString(v.Coords() + " ") + } + + for _, coords := range notFound { + builderCoordsStr.WriteString(coords + " ") + } + + params, err := domain.NewTranslateVillageCoordsParams("pl", "pl181", builderCoordsStr.String(), 2) + require.NoError(t, err) + + tests := []struct { + page int32 + hasNext bool + hasPrev bool + villages []domain.Village + notFound []string + }{ + { + page: 1, + hasNext: true, + hasPrev: false, + villages: []domain.Village{ + villages[0], + villages[1], + }, + }, + { + page: 2, + hasNext: true, + hasPrev: true, + villages: []domain.Village{ + villages[2], + }, + notFound: []string{ + notFound[0], + }, + }, + { + page: 3, + hasNext: false, + hasPrev: true, + notFound: []string{ + notFound[1], + }, + }, + } + + for i := range tests { + tt := tests[i] + + t.Run(strconv.Itoa(i), func(t *testing.T) { + t.Parallel() + + twhelpSvc := &mock.FakeTWHelpService{} + twhelpSvc.ListVillagesByCoordsCalls(func( + ctx context.Context, + versionCode string, + serverKey string, + coords []string, + offset int32, + perPage int32, + ) ([]domain.Village, error) { + if versionCode != params.VersionCode() || serverKey != params.ServerKey() { + return nil, nil + } + + res := make([]domain.Village, 0, len(coords)) + + for _, c := range coords { + for _, v := range villages { + if v.Coords() == c { + res = append(res, v) + break + } + } + } + + return res, nil + }) + + res, err := service.NewVillage(&mock.FakeVillageRepository{}, twhelpSvc). + TranslateCoords(context.Background(), params, tt.page) + assert.NoError(t, err) + assert.Len(t, res.Villages, len(tt.villages)) + for _, v := range tt.villages { + assert.Contains(t, res.Villages, v) + } + assert.Len(t, res.NotFound, len(tt.notFound)) + for _, coords := range tt.notFound { + assert.Contains(t, res.NotFound, coords) + } + assert.Equal(t, tt.hasPrev, res.HasPrev) + assert.Equal(t, tt.hasNext, res.HasNext) + assert.Equal(t, tt.page, res.Page) + assert.Equal(t, params.MaxPage(), res.MaxPage) + assert.Equal(t, params.SHA256(), res.ParamsSHA256) + }) + } + }) + + t.Run("ERR: page < 1", func(t *testing.T) { + t.Parallel() + + res, err := service.NewVillage(nil, nil). + TranslateCoords(context.Background(), domain.TranslateVillageCoordsParams{}, 0) + assert.ErrorIs(t, err, domain.GreaterEqualThanError{ + Field: "page", + Threshold: 1, + }) + assert.Zero(t, res) + }) + + t.Run("ERR: page > max page", func(t *testing.T) { + t.Parallel() + + res, err := service.NewVillage(nil, nil). + TranslateCoords(context.Background(), domain.TranslateVillageCoordsParams{}, 1) + assert.ErrorIs(t, err, domain.LessEqualThanError{ + Field: "page", + Threshold: 0, + }) + assert.Zero(t, res) + }) +} + +func TestVillage_TranslateCoordsFromHash(t *testing.T) { + t.Parallel() + + params, err := domain.NewTranslateVillageCoordsParams("pl", "pl181", "123|123 897|123 778|689 789|123 678|123", 2) + require.NoError(t, err) + + repo := &mock.FakeVillageRepository{} + repo.GetTranslateCoordsParamsCalls(func(ctx context.Context, hash string) (domain.TranslateVillageCoordsParams, error) { + if hash != params.SHA256() { + return domain.TranslateVillageCoordsParams{}, domain.TranslateVillageCoordsParamsNotFoundError{ + SHA256: hash, + } + } + return params, nil + }) + + res, err := service.NewVillage(repo, &mock.FakeTWHelpService{}). + TranslateCoordsFromHash(context.Background(), params.SHA256(), 1) + assert.NoError(t, err) + assert.NotZero(t, res) +} diff --git a/k8s/base/bot.yml b/k8s/base/bot.yml index 3013dca..b3eeb91 100644 --- a/k8s/base/bot.yml +++ b/k8s/base/bot.yml @@ -23,6 +23,11 @@ spec: secretKeyRef: name: twhelp-dcbot-secret key: db-dsn + - name: REDIS_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: twhelp-dcbot-secret + key: redis-connection-string - name: DB_MAX_OPEN_CONNECTIONS value: "10" - name: DB_MAX_IDLE_CONNECTIONS diff --git a/k8s/overlays/dev/bot.yml b/k8s/overlays/dev/bot.yml index 41017ad..4d44552 100644 --- a/k8s/overlays/dev/bot.yml +++ b/k8s/overlays/dev/bot.yml @@ -16,6 +16,11 @@ spec: secretKeyRef: name: twhelp-dcbot-secret key: db-dsn + - name: REDIS_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: twhelp-dcbot-secret + key: redis-connection-string - name: DB_MAX_OPEN_CONNECTIONS value: "10" - name: DB_MAX_IDLE_CONNECTIONS diff --git a/k8s/overlays/dev/secret.example.yml b/k8s/overlays/dev/secret.example.yml index 532cc0e..f8ed9aa 100644 --- a/k8s/overlays/dev/secret.example.yml +++ b/k8s/overlays/dev/secret.example.yml @@ -6,4 +6,6 @@ type: Opaque data: # postgres://dcbot:dcbot@twdcbotdb-postgresql:5432/dcbot?sslmode=disable db-dsn: cG9zdGdyZXM6Ly9kY2JvdDpkY2JvdEB0d2RjYm90ZGItcG9zdGdyZXNxbDo1NDMyL2RjYm90P3NzbG1vZGU9ZGlzYWJsZQ== - token: tokenhere \ No newline at end of file + # redis://:dcbot@twdcbotredis-master:6379/0 + redis-connection-string: cmVkaXM6Ly86ZGNib3RAdHdkY2JvdHJlZGlzLW1hc3Rlcjo2Mzc5LzA= + token: tokenhere diff --git a/k8s/overlays/prod/bot.yml b/k8s/overlays/prod/bot.yml index 22effb6..c4492d7 100644 --- a/k8s/overlays/prod/bot.yml +++ b/k8s/overlays/prod/bot.yml @@ -16,6 +16,11 @@ spec: secretKeyRef: name: twhelp-dcbot-secret key: db-dsn + - name: REDIS_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: twhelp-dcbot-secret + key: redis-connection-string - name: DB_MAX_OPEN_CONNECTIONS value: "15" - name: DB_MAX_IDLE_CONNECTIONS diff --git a/skaffold.yml b/skaffold.yml index 196df3f..9b9eea1 100644 --- a/skaffold.yml +++ b/skaffold.yml @@ -34,4 +34,13 @@ deploy: auth.username: dcbot auth.password: dcbot auth.database: dcbot + - name: twdcbotredis + repo: https://charts.bitnami.com/bitnami + remoteChart: redis + version: 17.11.8 + wait: true + setValues: + architecture: standalone + image.tag: 7.0.12 + auth.password: dcbot kubectl: {}