feat: coords translation (#116)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #116
This commit is contained in:
Dawid Wysokiński 2023-07-13 04:22:52 +00:00
parent 71a61533db
commit d1ba315bd2
32 changed files with 1529 additions and 81 deletions

View File

@ -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
...

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

3
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -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 == "" {

View File

@ -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,
}),
),

View File

@ -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
}

View File

@ -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(&params); 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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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()),
},
},

View File

@ -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) +
"]"
}

View File

@ -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 }}"
}

View File

@ -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 }}"
}

View File

@ -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:

View File

@ -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
}

View File

@ -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
}

View File

@ -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,
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -6,4 +6,6 @@ type: Opaque
data:
# postgres://dcbot:dcbot@twdcbotdb-postgresql:5432/dcbot?sslmode=disable
db-dsn: cG9zdGdyZXM6Ly9kY2JvdDpkY2JvdEB0d2RjYm90ZGItcG9zdGdyZXNxbDo1NDMyL2RjYm90P3NzbG1vZGU9ZGlzYWJsZQ==
token: tokenhere
# redis://:dcbot@twdcbotredis-master:6379/0
redis-connection-string: cmVkaXM6Ly86ZGNib3RAdHdkY2JvdHJlZGlzLW1hc3Rlcjo2Mzc5LzA=
token: tokenhere

View File

@ -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

View File

@ -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: {}