feat: notifications

This commit is contained in:
Dawid Wysokiński 2022-10-25 07:25:14 +02:00
parent db2026c6e4
commit e76cb17caf
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
13 changed files with 687 additions and 106 deletions

View File

@ -28,6 +28,11 @@ func New() *cli.Command {
Action: func(c *cli.Context) error { Action: func(c *cli.Context) error {
logger := zap.L() logger := zap.L()
cfg, err := newBotConfig()
if err != nil {
return fmt.Errorf("newBotConfig: %w", err)
}
db, err := internal.NewBunDB() db, err := internal.NewBunDB()
if err != nil { if err != nil {
return fmt.Errorf("internal.NewBunDB: %w", err) return fmt.Errorf("internal.NewBunDB: %w", err)
@ -43,22 +48,18 @@ func New() *cli.Command {
choiceSvc := service.NewChoice(client) choiceSvc := service.NewChoice(client)
groupSvc := service.NewGroup(groupRepo, client) groupSvc := service.NewGroup(groupRepo, client)
monitorSvc := service.NewMonitor(monitorRepo, groupRepo, client) monitorSvc := service.NewMonitor(monitorRepo, groupRepo, client, cfg.EnnoblementsReqLimit)
cfg, err := newBotConfig()
if err != nil {
return fmt.Errorf("newBotConfig: %w", err)
}
bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, choiceSvc) bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, choiceSvc)
if err != nil { if err != nil {
return fmt.Errorf("discord.NewBot: %w", err) return fmt.Errorf("discord.NewBot: %w", err)
} }
defer func() {
_ = bot.Close() go func() {
_ = bot.Run()
}() }()
if err := os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil { if err = os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil {
return fmt.Errorf("couldn't create file (path=%s): %w", healthyFilePath, err) return fmt.Errorf("couldn't create file (path=%s): %w", healthyFilePath, err)
} }
@ -66,13 +67,18 @@ func New() *cli.Command {
waitForSignal(c.Context) waitForSignal(c.Context)
if err = bot.Close(); err != nil {
return fmt.Errorf("bot.Close: %w", err)
}
return nil return nil
}, },
} }
} }
type botConfig struct { type botConfig struct {
Token string `envconfig:"TOKEN" required:"true"` Token string `envconfig:"TOKEN" required:"true"`
EnnoblementsReqLimit int `envconfig:"ENNOBLEMENTS_REQ_LIMIT" default:"4"`
} }
func newBotConfig() (botConfig, error) { func newBotConfig() (botConfig, error) {

1
go.mod
View File

@ -43,6 +43,7 @@ require (
github.com/opencontainers/runc v1.1.2 // indirect github.com/opencontainers/runc v1.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.8.1 // indirect github.com/sirupsen/logrus v1.8.1 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect

2
go.sum
View File

@ -89,6 +89,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=

View File

@ -3,9 +3,11 @@ package discord
import ( import (
"context" "context"
"fmt" "fmt"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/robfig/cron/v3"
) )
type GroupService interface { type GroupService interface {
@ -18,6 +20,7 @@ type GroupService interface {
type MonitorService interface { type MonitorService interface {
Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error)
Execute(ctx context.Context) error
Delete(ctx context.Context, id, serverID string) error Delete(ctx context.Context, id, serverID string) error
} }
@ -27,6 +30,7 @@ type ChoiceService interface {
type Bot struct { type Bot struct {
s *discordgo.Session s *discordgo.Session
c *cron.Cron
groupSvc GroupService groupSvc GroupService
monitorSvc MonitorService monitorSvc MonitorService
choiceSvc ChoiceService choiceSvc ChoiceService
@ -38,19 +42,46 @@ func NewBot(token string, groupSvc GroupService, monitorSvc MonitorService, clie
return nil, fmt.Errorf("discordgo.New: %w", err) return nil, fmt.Errorf("discordgo.New: %w", err)
} }
if err = s.Open(); err != nil { b := &Bot{
return nil, fmt.Errorf("s.Open: %w", err) s: s,
} c: cron.New(
cron.WithLocation(time.UTC),
b := &Bot{s: s, groupSvc: groupSvc, monitorSvc: monitorSvc, choiceSvc: client} cron.WithChain(
if err = b.registerCommands(); err != nil { cron.SkipIfStillRunning(cron.DiscardLogger),
_ = s.Close() ),
return nil, fmt.Errorf("couldn't register commands: %w", err) ),
groupSvc: groupSvc,
monitorSvc: monitorSvc,
choiceSvc: client,
} }
return b, nil return b, nil
} }
func (b *Bot) Run() error {
if err := b.initCron(); err != nil {
return fmt.Errorf("initCron: %w", err)
}
if err := b.s.Open(); err != nil {
return fmt.Errorf("s.Open: %w", err)
}
if err := b.registerCommands(); err != nil {
_ = b.s.Close()
return fmt.Errorf("couldn't register commands: %w", err)
}
b.c.Run()
return nil
}
type command interface {
name() string
register(s *discordgo.Session) error
}
func (b *Bot) registerCommands() error { func (b *Bot) registerCommands() error {
commands := []command{ commands := []command{
&groupCommand{groupSvc: b.groupSvc, choiceSvc: b.choiceSvc}, &groupCommand{groupSvc: b.groupSvc, choiceSvc: b.choiceSvc},
@ -66,11 +97,6 @@ func (b *Bot) registerCommands() error {
return nil return nil
} }
type command interface {
name() string
register(s *discordgo.Session) error
}
func (b *Bot) registerCommand(cmd command) error { func (b *Bot) registerCommand(cmd command) error {
if err := cmd.register(b.s); err != nil { if err := cmd.register(b.s); err != nil {
return fmt.Errorf("couldn't register command '%s': %w", cmd.name(), err) return fmt.Errorf("couldn't register command '%s': %w", cmd.name(), err)
@ -78,6 +104,15 @@ func (b *Bot) registerCommand(cmd command) error {
return nil return nil
} }
func (b *Bot) initCron() error {
_, err := b.c.AddJob("@every 1m", &executeMonitorsJob{svc: b.monitorSvc})
if err != nil {
return err
}
return nil
}
func (b *Bot) Close() error { func (b *Bot) Close() error {
<-b.c.Stop().Done()
return b.s.Close() return b.s.Close()
} }

View File

@ -0,0 +1,22 @@
package discord
import (
"context"
"fmt"
"time"
)
const (
executeMonitorsJobTimeout = 2 * time.Minute
)
type executeMonitorsJob struct {
svc MonitorService
}
func (e *executeMonitorsJob) Run() {
ctx, cancel := context.WithTimeout(context.Background(), executeMonitorsJobTimeout)
defer cancel()
fmt.Println("execute")
_ = e.svc.Execute(ctx)
}

View File

@ -4,6 +4,8 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sync"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain" "gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp" "gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
@ -21,19 +23,33 @@ type MonitorRepository interface {
Delete(ctx context.Context, id string) error Delete(ctx context.Context, id string) error
} }
//counterfeiter:generate -o internal/mock/group_getter.gen.go . GroupGetter //counterfeiter:generate -o internal/mock/group_reader.gen.go . GroupReader
type GroupGetter interface { type GroupReader interface {
Get(ctx context.Context, id, serverID string) (domain.Group, error) Get(ctx context.Context, id, serverID string) (domain.Group, error)
List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error)
} }
type Monitor struct { type Monitor struct {
repo MonitorRepository repo MonitorRepository
client TWHelpClient client TWHelpClient
groupSvc GroupGetter groupSvc GroupReader
lastExecutionMu sync.RWMutex
lastExecution time.Time
ennoblementsReqLimit int
} }
func NewMonitor(repo MonitorRepository, groupSvc GroupGetter, client TWHelpClient) *Monitor { func NewMonitor(
return &Monitor{repo: repo, client: client, groupSvc: groupSvc} repo MonitorRepository,
groupSvc GroupReader,
client TWHelpClient,
ennoblementsReqLimit int,
) *Monitor {
return &Monitor{
repo: repo,
client: client,
groupSvc: groupSvc,
ennoblementsReqLimit: ennoblementsReqLimit,
}
} }
func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) { func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) {
@ -107,3 +123,110 @@ func (m *Monitor) Delete(ctx context.Context, id, serverID string) error {
return nil return nil
} }
func (m *Monitor) Execute(ctx context.Context) error {
groups, err := m.groupSvc.List(ctx, domain.ListGroupsParams{})
if err != nil {
return fmt.Errorf("GroupService.List: %w", err)
}
ennoblements, err := m.preloadEnnoblements(ctx, groups)
if err != nil {
return err
}
for _, e := range ennoblements {
fmt.Println(e)
}
return nil
}
type listEnnoblementsResult struct {
versionCode string
serverKey string
ennoblements []twhelp.Ennoblement
err error
}
type preloadEnnoblementsResult struct {
VersionCode string
ServerKey string
Ennoblements []twhelp.Ennoblement
}
func (m *Monitor) preloadEnnoblements(ctx context.Context, groups []domain.Group) ([]preloadEnnoblementsResult, error) {
var wg sync.WaitGroup
ch := make(chan listEnnoblementsResult)
reqLimitCh := make(chan struct{}, m.ennoblementsReqLimit)
skip := make(map[string]struct{})
m.lastExecutionMu.RLock()
since := m.lastExecution
m.lastExecutionMu.RUnlock()
if since.IsZero() {
since = time.Now().Add(-1 * time.Minute)
}
since = since.Add(1 * time.Millisecond)
for _, g := range groups {
if g.ChannelGains == "" && g.ChannelLosses == "" {
continue
}
key := g.VersionCode + ":" + g.ServerKey
if _, ok := skip[key]; ok {
continue
}
skip[key] = struct{}{}
wg.Add(1)
go func(g domain.Group, since time.Time) {
reqLimitCh <- struct{}{}
defer func() {
<-reqLimitCh
}()
res := listEnnoblementsResult{
versionCode: g.VersionCode,
serverKey: g.ServerKey,
}
res.ennoblements, res.err = m.client.ListEnnoblements(
ctx,
g.VersionCode,
g.ServerKey,
twhelp.ListEnnoblementsQueryParams{
Since: since,
},
)
ch <- res
}(g, since)
}
go func() {
wg.Wait()
close(ch)
}()
//nolint:prealloc
var results []preloadEnnoblementsResult
for res := range ch {
wg.Done()
if res.err != nil {
continue
}
results = append(results, preloadEnnoblementsResult{
VersionCode: res.versionCode,
ServerKey: res.serverKey,
Ennoblements: res.ennoblements,
})
}
m.lastExecutionMu.Lock()
m.lastExecution = time.Now()
m.lastExecutionMu.Unlock()
return results, nil
}

View File

@ -30,7 +30,7 @@ func TestMonitor_Create(t *testing.T) {
}, nil }, nil
}) })
groupSvc := &mock.FakeGroupGetter{} groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{ return domain.Group{
ID: groupID, ID: groupID,
@ -55,7 +55,7 @@ func TestMonitor_Create(t *testing.T) {
groupID := uuid.NewString() groupID := uuid.NewString()
monitor, err := service.NewMonitor(repo, groupSvc, client). monitor, err := service.NewMonitor(repo, groupSvc, client, 10).
Create(context.Background(), groupID, uuid.NewString(), tribe.Tag) Create(context.Background(), groupID, uuid.NewString(), tribe.Tag)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotEmpty(t, monitor.ID) assert.NotEmpty(t, monitor.ID)
@ -68,10 +68,10 @@ func TestMonitor_Create(t *testing.T) {
t.Parallel() t.Parallel()
groupID := uuid.NewString() groupID := uuid.NewString()
groupSvc := &mock.FakeGroupGetter{} groupSvc := &mock.FakeGroupReader{}
groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID}) groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID})
monitor, err := service.NewMonitor(nil, groupSvc, nil). monitor, err := service.NewMonitor(nil, groupSvc, nil, 10).
Create(context.Background(), groupID, uuid.NewString(), "tag") Create(context.Background(), groupID, uuid.NewString(), "tag")
assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ assert.ErrorIs(t, err, domain.GroupDoesNotExistError{
ID: groupID, ID: groupID,
@ -87,7 +87,7 @@ func TestMonitor_Create(t *testing.T) {
repo := &mock.FakeMonitorRepository{} repo := &mock.FakeMonitorRepository{}
repo.ListReturns(make([]domain.Monitor, maxMonitorsPerGroup), nil) repo.ListReturns(make([]domain.Monitor, maxMonitorsPerGroup), nil)
groupSvc := &mock.FakeGroupGetter{} groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{ return domain.Group{
ID: groupID, ID: groupID,
@ -100,7 +100,7 @@ func TestMonitor_Create(t *testing.T) {
}, nil }, nil
}) })
monitor, err := service.NewMonitor(repo, groupSvc, nil). monitor, err := service.NewMonitor(repo, groupSvc, nil, 10).
Create(context.Background(), uuid.NewString(), uuid.NewString(), "TAG") Create(context.Background(), uuid.NewString(), uuid.NewString(), "TAG")
assert.ErrorIs(t, err, domain.MonitorLimitReachedError{ assert.ErrorIs(t, err, domain.MonitorLimitReachedError{
Current: maxMonitorsPerGroup, Current: maxMonitorsPerGroup,
@ -126,7 +126,7 @@ func TestMonitor_Create(t *testing.T) {
}, nil }, nil
}) })
groupSvc := &mock.FakeGroupGetter{} groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{ return domain.Group{
ID: groupID, ID: groupID,
@ -147,7 +147,7 @@ func TestMonitor_Create(t *testing.T) {
tag := "TAG" tag := "TAG"
monitor, err := service.NewMonitor(repo, groupSvc, client). monitor, err := service.NewMonitor(repo, groupSvc, client, 10).
Create(context.Background(), uuid.NewString(), uuid.NewString(), tag) Create(context.Background(), uuid.NewString(), uuid.NewString(), tag)
assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ assert.ErrorIs(t, err, domain.TribeDoesNotExistError{
Tag: tag, Tag: tag,
@ -161,7 +161,7 @@ func TestMonitor_Create(t *testing.T) {
repo := &mock.FakeMonitorRepository{} repo := &mock.FakeMonitorRepository{}
repo.ListReturns(nil, nil) repo.ListReturns(nil, nil)
groupSvc := &mock.FakeGroupGetter{} groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) { groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{ return domain.Group{
ID: groupID, ID: groupID,
@ -184,7 +184,7 @@ func TestMonitor_Create(t *testing.T) {
} }
client.GetTribeByTagReturns(tribe, nil) client.GetTribeByTagReturns(tribe, nil)
monitor, err := service.NewMonitor(repo, groupSvc, client). monitor, err := service.NewMonitor(repo, groupSvc, client, 10).
Create(context.Background(), uuid.NewString(), uuid.NewString(), tribe.Tag) Create(context.Background(), uuid.NewString(), uuid.NewString(), tribe.Tag)
assert.ErrorIs(t, err, domain.TribeDoesNotExistError{ assert.ErrorIs(t, err, domain.TribeDoesNotExistError{
Tag: tribe.Tag, Tag: tribe.Tag,

View File

@ -13,4 +13,9 @@ type TWHelpClient interface {
ListVersions(ctx context.Context) ([]twhelp.Version, error) ListVersions(ctx context.Context) ([]twhelp.Version, error)
GetServer(ctx context.Context, version, server string) (twhelp.Server, error) GetServer(ctx context.Context, version, server string) (twhelp.Server, error)
GetTribeByTag(ctx context.Context, version, server, tag string) (twhelp.Tribe, error) GetTribeByTag(ctx context.Context, version, server, tag string) (twhelp.Tribe, error)
ListEnnoblements(
ctx context.Context,
version, server string,
queryParams twhelp.ListEnnoblementsQueryParams,
) ([]twhelp.Ennoblement, error)
} }

View File

@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"time" "time"
) )
@ -14,9 +15,10 @@ const (
defaultUserAgent = "TWHelpDCBot/development" defaultUserAgent = "TWHelpDCBot/development"
defaultTimeout = 10 * time.Second defaultTimeout = 10 * time.Second
endpointListVersions = "/api/v1/versions" endpointListVersions = "/api/v1/versions"
endpointGetServer = "/api/v1/versions/%s/servers/%s" endpointGetServer = "/api/v1/versions/%s/servers/%s"
endpointGetTribeByTag = "/api/v1/versions/%s/servers/%s/tribes/tag/%s" endpointGetTribeByTag = "/api/v1/versions/%s/servers/%s/tribes/tag/%s"
endpointListEnnoblements = "/api/v1/versions/%s/servers/%s/ennoblements"
) )
type Client struct { type Client struct {
@ -77,6 +79,40 @@ func (c *Client) GetTribeByTag(ctx context.Context, version, server, tag string)
return resp.Data, nil return resp.Data, nil
} }
type ListEnnoblementsQueryParams struct {
Limit int32
Offset int32
Since time.Time
}
func (c *Client) ListEnnoblements(
ctx context.Context,
version, server string,
queryParams ListEnnoblementsQueryParams,
) ([]Ennoblement, error) {
q := url.Values{}
if queryParams.Limit >= 0 {
q.Set("limit", strconv.Itoa(int(queryParams.Limit)))
}
if queryParams.Offset >= 0 {
q.Set("offset", strconv.Itoa(int(queryParams.Offset)))
}
if !queryParams.Since.IsZero() {
q.Set("since", queryParams.Since.Format(time.RFC3339))
}
var resp listEnnoblementsResp
if err := c.getJSON(ctx, fmt.Sprintf(endpointListEnnoblements, version, server)+"?"+q.Encode(), &resp); err != nil {
return nil, err
}
return resp.Data, nil
}
func (c *Client) getJSON(ctx context.Context, urlStr string, v any) error { func (c *Client) getJSON(ctx context.Context, urlStr string, v any) error {
u, err := c.baseURL.Parse(urlStr) u, err := c.baseURL.Parse(urlStr)
if err != nil { if err != nil {

154
internal/twhelp/twhelp.go Normal file
View File

@ -0,0 +1,154 @@
package twhelp
import (
"encoding/json"
"time"
)
type ErrorCode string
const (
ErrorCodeInternalServerError ErrorCode = "internal-server-error"
ErrorCodeEntityNotFound ErrorCode = "entity-not-found"
ErrorCodeValidationError ErrorCode = "validation-error"
ErrorCodeRouteNotFound ErrorCode = "route-not-found"
ErrorCodeMethodNotAllowed ErrorCode = "method-not-allowed"
)
func (c ErrorCode) String() string {
return string(c)
}
type APIError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
func (e APIError) Error() string {
return e.Message + " (code=" + e.Code.String() + ")"
}
type errorResp struct {
Error APIError `json:"error"`
}
type Version struct {
Code string `json:"code"`
Host string `json:"host"`
Name string `json:"name"`
Timezone string `json:"timezone"`
}
type listVersionsResp struct {
Data []Version `json:"data"`
}
type Server struct {
Key string `json:"key"`
URL string `json:"url"`
Open bool `json:"open"`
}
type getServerResp struct {
Data Server `json:"data"`
}
type Tribe struct {
ID int64 `json:"id"`
Tag string `json:"tag"`
Name string `json:"name"`
ProfileURL string `json:"profileUrl"`
DeletedAt time.Time `json:"deletedAt"`
}
type getTribeResp struct {
Data Tribe `json:"data"`
}
type TribeMeta struct {
ID int64 `json:"id"`
Name string `json:"name"`
Tag string `json:"tag"`
ProfileURL string `json:"profileUrl"`
}
type NullTribeMeta struct {
Tribe TribeMeta
Valid bool
}
func (t NullTribeMeta) MarshalJSON() ([]byte, error) {
if !t.Valid {
return []byte("null"), nil
}
return json.Marshal(t.Tribe)
}
func (t *NullTribeMeta) UnmarshalJSON(data []byte) error {
// Ignore null, like in the main JSON package.
if string(data) == "null" {
return nil
}
if err := json.Unmarshal(data, &t.Tribe); err != nil {
return err
}
t.Valid = true
return nil
}
type PlayerMeta struct {
ID int64 `json:"id"`
Name string `json:"name"`
ProfileURL string `json:"profileUrl"`
Tribe NullTribeMeta `json:"tribe"`
}
type NullPlayerMeta struct {
Player PlayerMeta
Valid bool
}
func (p NullPlayerMeta) MarshalJSON() ([]byte, error) {
if !p.Valid {
return []byte("null"), nil
}
return json.Marshal(p.Player)
}
func (p *NullPlayerMeta) UnmarshalJSON(data []byte) error {
// Ignore null, like in the main JSON package.
if string(data) == "null" {
return nil
}
if err := json.Unmarshal(data, &p.Player); err != nil {
return err
}
p.Valid = true
return nil
}
type VillageMeta struct {
ID int64 `json:"id"`
FullName string `json:"fullName"`
ProfileURL string `json:"profileUrl"`
}
type Ennoblement struct {
ID int64 `json:"id"`
Village VillageMeta `json:"village"`
NewOwner NullPlayerMeta `json:"newOwner"`
OldOwner NullPlayerMeta `json:"oldOwner"`
CreatedAt time.Time `json:"createdAt"`
}
type listEnnoblementsResp struct {
Data []Ennoblement `json:"data"`
}

View File

@ -0,0 +1,258 @@
package twhelp_test
import (
"encoding/json"
"testing"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
"github.com/stretchr/testify/assert"
)
func TestNullTribeMeta_MarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tribe twhelp.NullTribeMeta
expectedJSON string
}{
{
name: "OK: null 1",
tribe: twhelp.NullTribeMeta{
Tribe: twhelp.TribeMeta{},
Valid: false,
},
expectedJSON: "null",
},
{
name: "OK: null 2",
tribe: twhelp.NullTribeMeta{
Tribe: twhelp.TribeMeta{ID: 1234},
Valid: false,
},
expectedJSON: "null",
},
{
name: "OK: valid struct",
tribe: twhelp.NullTribeMeta{
Tribe: twhelp.TribeMeta{
ID: 997,
Name: "name 997",
Tag: "tag 997",
ProfileURL: "profile-997",
},
Valid: true,
},
expectedJSON: `{"id":997,"name":"name 997","tag":"tag 997","profileUrl":"profile-997"}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b, err := json.Marshal(tt.tribe)
assert.NoError(t, err)
assert.JSONEq(t, tt.expectedJSON, string(b))
})
}
}
func TestNullTribeMeta_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
json string
expectedTribe twhelp.NullTribeMeta
expectedJSONSyntaxError bool
}{
{
name: "OK: null",
json: "null",
expectedTribe: twhelp.NullTribeMeta{
Tribe: twhelp.TribeMeta{},
Valid: false,
},
},
{
name: "OK: valid struct",
//nolint:lll
json: `{"id":997,"name":"name 997","tag":"tag 997","profileUrl":"profile-997"}`,
expectedTribe: twhelp.NullTribeMeta{
Tribe: twhelp.TribeMeta{
ID: 997,
Name: "name 997",
Tag: "tag 997",
ProfileURL: "profile-997",
},
Valid: true,
},
},
{
name: "ERR: invalid tribe 1",
json: "2022-07-30T14:13:12.0000005Z",
expectedTribe: twhelp.NullTribeMeta{},
expectedJSONSyntaxError: true,
},
{
name: "ERR: invalid tribe 2",
json: "hello world",
expectedTribe: twhelp.NullTribeMeta{},
expectedJSONSyntaxError: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var target twhelp.NullTribeMeta
err := json.Unmarshal([]byte(tt.json), &target)
if tt.expectedJSONSyntaxError {
var syntaxError *json.SyntaxError
assert.ErrorAs(t, err, &syntaxError)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedTribe, target)
})
}
}
func TestNullPlayerMeta_MarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
player twhelp.NullPlayerMeta
expectedJSON string
}{
{
name: "OK: null 1",
player: twhelp.NullPlayerMeta{
Player: twhelp.PlayerMeta{},
Valid: false,
},
expectedJSON: "null",
},
{
name: "OK: null 2",
player: twhelp.NullPlayerMeta{
Player: twhelp.PlayerMeta{ID: 1234},
Valid: false,
},
expectedJSON: "null",
},
{
name: "OK: valid struct",
player: twhelp.NullPlayerMeta{
Player: twhelp.PlayerMeta{
ID: 997,
Name: "name 997",
ProfileURL: "profile-997",
Tribe: twhelp.NullTribeMeta{
Valid: true,
Tribe: twhelp.TribeMeta{
ID: 1234,
Name: "name 997",
ProfileURL: "profile-997",
Tag: "tag 997",
},
},
},
Valid: true,
},
//nolint:lll
expectedJSON: `{"id":997,"name":"name 997","profileUrl":"profile-997","tribe":{"id":1234,"name":"name 997","profileUrl":"profile-997","tag":"tag 997"}}`,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
b, err := json.Marshal(tt.player)
assert.NoError(t, err)
assert.JSONEq(t, tt.expectedJSON, string(b))
})
}
}
func TestNullPlayerMeta_UnmarshalJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
json string
expectedPlayer twhelp.NullPlayerMeta
expectedJSONSyntaxError bool
}{
{
name: "OK: null",
json: "null",
expectedPlayer: twhelp.NullPlayerMeta{
Player: twhelp.PlayerMeta{},
Valid: false,
},
},
{
name: "OK: valid struct",
//nolint:lll
json: `{"id":997,"name":"name 997","profileUrl":"profile-997","tribe":{"id":1234,"name":"name 997","profileUrl":"profile-997","tag":"tag 997"}}`,
expectedPlayer: twhelp.NullPlayerMeta{
Player: twhelp.PlayerMeta{
ID: 997,
Name: "name 997",
ProfileURL: "profile-997",
Tribe: twhelp.NullTribeMeta{
Valid: true,
Tribe: twhelp.TribeMeta{
ID: 1234,
Name: "name 997",
ProfileURL: "profile-997",
Tag: "tag 997",
},
},
},
Valid: true,
},
},
{
name: "ERR: invalid tribe 1",
json: "2022-07-30T14:13:12.0000005Z",
expectedPlayer: twhelp.NullPlayerMeta{},
expectedJSONSyntaxError: true,
},
{
name: "ERR: invalid tribe 2",
json: "hello world",
expectedPlayer: twhelp.NullPlayerMeta{},
expectedJSONSyntaxError: true,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var target twhelp.NullPlayerMeta
err := json.Unmarshal([]byte(tt.json), &target)
if tt.expectedJSONSyntaxError {
var syntaxError *json.SyntaxError
assert.ErrorAs(t, err, &syntaxError)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedPlayer, target)
})
}
}

View File

@ -1,63 +0,0 @@
package twhelp
import "time"
type ErrorCode string
const (
ErrorCodeInternalServerError ErrorCode = "internal-server-error"
ErrorCodeEntityNotFound ErrorCode = "entity-not-found"
ErrorCodeValidationError ErrorCode = "validation-error"
ErrorCodeRouteNotFound ErrorCode = "route-not-found"
ErrorCodeMethodNotAllowed ErrorCode = "method-not-allowed"
)
func (c ErrorCode) String() string {
return string(c)
}
type APIError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
func (e APIError) Error() string {
return e.Message + " (code=" + e.Code.String() + ")"
}
type errorResp struct {
Error APIError `json:"error"`
}
type Version struct {
Code string `json:"code"`
Host string `json:"host"`
Name string `json:"name"`
Timezone string `json:"timezone"`
}
type listVersionsResp struct {
Data []Version `json:"data"`
}
type Server struct {
Key string `json:"key"`
URL string `json:"url"`
Open bool `json:"open"`
}
type getServerResp struct {
Data Server `json:"data"`
}
type Tribe struct {
ID int64 `json:"id"`
Tag string `json:"tag"`
Name string `json:"name"`
ProfileURL string `json:"profileUrl"`
DeletedAt time.Time `json:"deletedAt"`
}
type getTribeResp struct {
Data Tribe `json:"data"`
}

View File

@ -34,6 +34,8 @@ spec:
key: token key: token
- name: TWHELP_URL - name: TWHELP_URL
value: "https://tribalwarshelp.com" value: "https://tribalwarshelp.com"
- name: BOT_ENNOBLEMENTS_REQ_LIMIT
value: "4"
livenessProbe: livenessProbe:
exec: exec:
command: [ "cat", "/tmp/healthy" ] command: [ "cat", "/tmp/healthy" ]