refactor: group & monitor refactor (#107)
continuous-integration/drone/push Build is passing Details

Reviewed-on: #107
This commit is contained in:
Dawid Wysokiński 2023-06-18 06:47:51 +00:00
parent e6d15ac950
commit e110807619
30 changed files with 2885 additions and 2403 deletions

View File

@ -47,36 +47,23 @@ func New() *cli.Command {
}
groupRepo := bundb.NewGroup(db)
monitorRepo := bundb.NewMonitor(db)
choiceSvc := service.NewChoice(client)
groupSvc := service.NewGroup(
groupRepo,
client,
logger,
cfg.MaxGroupsPerServer,
)
monitorSvc := service.NewMonitor(
monitorRepo,
groupRepo,
client,
logger,
cfg.MaxMonitorsPerGroup,
)
groupSvc := service.NewGroup(groupRepo, client, logger, cfg.MaxGroupsPerServer, cfg.MaxMonitorsPerGroup)
bot, err := discord.NewBot(cfg.Token, groupSvc, monitorSvc, choiceSvc, logger)
bot, err := discord.NewBot(cfg.Token, groupSvc, choiceSvc, logger)
if err != nil {
return fmt.Errorf("discord.NewBot: %w", err)
}
defer func() {
_ = bot.Close()
}()
go func() {
if runErr := bot.Run(); runErr != nil {
logger.Fatal("something went wrong while starting the bot", zap.Error(runErr))
}
}()
defer func() {
_ = bot.Close()
}()
if err = os.WriteFile(healthyFilePath, []byte("healthy"), healthyFilePerm); err != nil {
return fmt.Errorf("couldn't create file (path=%s): %w", healthyFilePath, err)
@ -93,7 +80,7 @@ func New() *cli.Command {
type botConfig struct {
Token string `envconfig:"TOKEN" required:"true"`
MaxGroupsPerServer int `envconfig:"MAX_GROUPS_PER_SERVER" default:"10"`
MaxGroupsPerServer int `envconfig:"MAX_GROUPS_PER_SERVER" default:"5"`
MaxMonitorsPerGroup int `envconfig:"MAX_MONITORS_PER_GROUP" default:"10"`
}

View File

@ -8,11 +8,15 @@ import (
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
"github.com/kelseyhightower/envconfig"
"golang.org/x/time/rate"
)
type twhelpClientConfig struct {
URL *url.URL `envconfig:"URL" required:"true"`
Timeout time.Duration `envconfig:"TIMEOUT" default:"10s"`
URL *url.URL `envconfig:"URL" required:"true"`
Timeout time.Duration `envconfig:"TIMEOUT" default:"10s"`
RateLimiterEnabled bool `envconfig:"RATE_LIMITER_ENABLED" default:"false"`
RateLimiterMaxRequestsPerSecond float64 `envconfig:"RATE_LIMITER_MAX_REQUESTS_PER_SECOND" default:"10"`
RateLimiterRequestBurst int `envconfig:"RATE_LIMITER_REQUEST_BURST" default:"5"`
}
func NewTWHelpClient(version string) (*twhelp.Client, error) {
@ -21,11 +25,16 @@ func NewTWHelpClient(version string) (*twhelp.Client, error) {
return nil, fmt.Errorf("envconfig.Process: %w", err)
}
return twhelp.NewClient(
cfg.URL,
opts := []twhelp.ClientOption{
twhelp.WithHTTPClient(&http.Client{
Timeout: cfg.Timeout,
}),
twhelp.WithUserAgent("TWHelpDCBot/"+version),
), nil
twhelp.WithUserAgent("TWHelpDCBot/" + version),
}
if cfg.RateLimiterEnabled {
opts = append(opts, twhelp.WithRateLimiter(rate.NewLimiter(rate.Limit(cfg.RateLimiterMaxRequestsPerSecond), cfg.RateLimiterRequestBurst)))
}
return twhelp.NewClient(cfg.URL, opts...), nil
}

1
go.mod
View File

@ -18,6 +18,7 @@ require (
github.com/uptrace/bun/driver/pgdriver v1.1.14
github.com/urfave/cli/v2 v2.25.5
go.uber.org/zap v1.24.0
golang.org/x/time v0.3.0
)
require (

2
go.sum
View File

@ -147,6 +147,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=

View File

@ -149,7 +149,7 @@ func loadFixtures(tb testing.TB, bunDB *bun.DB) *bunfixture {
return &bunfixture{fixture}
}
func (f *bunfixture) group(tb testing.TB, id string) domain.Group {
func (f *bunfixture) group(tb testing.TB, id string) domain.GroupWithMonitors {
tb.Helper()
row, err := f.Row("Group." + id)
@ -159,13 +159,13 @@ func (f *bunfixture) group(tb testing.TB, id string) domain.Group {
return g.ToDomain()
}
func (f *bunfixture) groups(tb testing.TB) []domain.Group {
func (f *bunfixture) groups(tb testing.TB) []domain.GroupWithMonitors {
tb.Helper()
//nolint:lll
ids := []string{"group-1-server-1", "group-2-server-1", "group-1-server-2", "group-2-server-2", "group-3-server-1", "group-3-server-2"}
groups := make([]domain.Group, 0, len(ids))
groups := make([]domain.GroupWithMonitors, 0, len(ids))
for _, id := range ids {
groups = append(groups, f.group(tb, id))
}
@ -173,6 +173,16 @@ func (f *bunfixture) groups(tb testing.TB) []domain.Group {
return groups
}
func (f *bunfixture) monitor(tb testing.TB, id string) domain.Monitor {
tb.Helper()
row, err := f.Row("Monitor." + id)
require.NoError(tb, err)
m, ok := row.(*model.Monitor)
require.True(tb, ok)
return m.ToDomain()
}
func generateSchema() string {
return strings.TrimFunc(strings.ReplaceAll(uuid.NewString(), "-", "_"), unicode.IsNumber)
}

View File

@ -9,7 +9,9 @@ import (
"gitea.dwysokinski.me/twhelp/dcbot/internal/bundb/internal/model"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/uptrace/bun"
"github.com/uptrace/bun/driver/pgdriver"
)
type Group struct {
@ -20,7 +22,7 @@ func NewGroup(db *bun.DB) *Group {
return &Group{db: db}
}
func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) {
func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) {
group := model.Group{
ServerID: params.ServerID(),
VersionCode: params.VersionCode(),
@ -35,52 +37,103 @@ func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (do
Model(&group).
Returning("*").
Exec(ctx); err != nil {
return domain.Group{}, fmt.Errorf("something went wrong while inserting group into the db: %w", err)
return domain.GroupWithMonitors{}, fmt.Errorf("something went wrong while inserting group into the db: %w", err)
}
return group.ToDomain(), nil
}
func (g *Group) Update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error) {
func (g *Group) Update(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) {
if params.IsZero() {
return domain.Group{}, domain.ErrNothingToUpdate
return domain.GroupWithMonitors{}, domain.ErrNothingToUpdate
}
if _, err := uuid.Parse(id); err != nil {
return domain.Group{}, domain.GroupNotFoundError{ID: id}
return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id}
}
var group model.Group
res, err := g.db.NewUpdate().
Model(&group).
Returning("*").
Returning("NULL").
Where("id = ?", id).
Where("server_id = ?", serverID).
Apply(updateGroupsParamsApplier{params}.apply).
Exec(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return domain.Group{}, fmt.Errorf("couldn't update group (id=%s,serverID=%s): %w", id, serverID, err)
return domain.GroupWithMonitors{}, fmt.Errorf("couldn't update group (id=%s): %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return domain.Group{}, domain.GroupNotFoundError{ID: id}
return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id}
}
return group.ToDomain(), nil
return g.Get(ctx, id)
}
func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) {
func (g *Group) AddMonitor(ctx context.Context, id string, tribeID int64) (domain.GroupWithMonitors, error) {
parsedID, err := uuid.Parse(id)
if err != nil {
return domain.GroupWithMonitors{}, domain.GroupDoesNotExistError{ID: id}
}
monitor := model.Monitor{
GroupID: parsedID,
TribeID: tribeID,
}
if _, err = g.db.NewInsert().
Model(&monitor).
Returning("NULL").
Exec(ctx); err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf(
"something went wrong while inserting monitor into the db: %w",
mapAddMonitorError(err, id, tribeID),
)
}
return g.Get(ctx, id)
}
func (g *Group) DeleteMonitors(ctx context.Context, id string, monitorIDs ...string) (domain.GroupWithMonitors, error) {
if _, err := uuid.Parse(id); err != nil {
return domain.GroupWithMonitors{}, domain.GroupNotFoundError{ID: id}
}
for _, monitorID := range monitorIDs {
if _, err := uuid.Parse(monitorID); err != nil {
return domain.GroupWithMonitors{}, domain.MonitorNotFoundError{ID: monitorID}
}
}
_, err := g.db.NewDelete().
Model(&model.Monitor{}).
Returning("NULL").
Where("id IN (?)", bun.In(monitorIDs)).
Where("group_id = ?", id).
Exec(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return domain.GroupWithMonitors{}, fmt.Errorf("couldn't delete monitors: %w", err)
}
return g.Get(ctx, id)
}
func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.GroupWithMonitors, error) {
var groups []model.Group
if err := g.db.NewSelect().
Model(&groups).
Order("created_at ASC").
Relation("Monitors", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("created_at ASC")
}).
Apply(listGroupsParamsApplier{params}.apply).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select groups from the db: %w", err)
}
result := make([]domain.Group, 0, len(groups))
result := make([]domain.GroupWithMonitors, 0, len(groups))
for _, group := range groups {
result = append(result, group.ToDomain())
}
@ -88,29 +141,41 @@ func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]dom
return result, nil
}
func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) {
func (g *Group) Get(ctx context.Context, id string) (domain.GroupWithMonitors, error) {
group, err := g.get(ctx, id, true)
if err != nil {
return domain.GroupWithMonitors{}, err
}
return group.ToDomain(), nil
}
func (g *Group) get(ctx context.Context, id string, withMonitors bool) (model.Group, error) {
if _, err := uuid.Parse(id); err != nil {
return domain.Group{}, domain.GroupNotFoundError{ID: id}
return model.Group{}, domain.GroupNotFoundError{ID: id}
}
var group model.Group
err := g.db.NewSelect().
q := g.db.NewSelect().
Model(&group).
Where("id = ?", id).
Where("server_id = ?", serverID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Group{}, domain.GroupNotFoundError{ID: id}
}
return domain.Group{}, fmt.Errorf("couldn't select group (id=%s,serverID=%s) from the db: %w", id, serverID, err)
Where("id = ?", id)
if withMonitors {
q = q.Relation("Monitors", func(q *bun.SelectQuery) *bun.SelectQuery {
return q.Order("created_at ASC")
})
}
return group.ToDomain(), nil
if err := q.Scan(ctx); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Group{}, domain.GroupNotFoundError{ID: id}
}
return model.Group{}, fmt.Errorf("couldn't select group (id=%s) from the db: %w", id, err)
}
return group, nil
}
func (g *Group) Delete(ctx context.Context, id, serverID string) error {
func (g *Group) Delete(ctx context.Context, id string) error {
if _, err := uuid.Parse(id); err != nil {
return domain.GroupNotFoundError{ID: id}
}
@ -119,14 +184,14 @@ func (g *Group) Delete(ctx context.Context, id, serverID string) error {
Model(&model.Group{}).
Returning("NULL").
Where("id = ?", id).
Where("server_id = ?", serverID).
Exec(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("couldn't delete group (id=%s,serverID=%s): %w", id, serverID, err)
return fmt.Errorf("couldn't delete group (id=%s): %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return domain.GroupNotFoundError{ID: id}
}
return nil
}
@ -183,7 +248,7 @@ type listGroupsParamsApplier struct {
}
func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
if l.params.ServerIDs != nil {
if len(l.params.ServerIDs) > 0 {
q = q.Where("server_id IN (?)", bun.In(l.params.ServerIDs))
}
@ -202,7 +267,7 @@ func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
q = q.Where("version_code = ?", l.params.VersionCode.String)
}
if l.params.ServerKeys != nil {
if len(l.params.ServerKeys) > 0 {
q = q.Where("server_key IN (?)", bun.In(l.params.ServerKeys))
}
@ -212,3 +277,26 @@ func (l listGroupsParamsApplier) apply(q *bun.SelectQuery) *bun.SelectQuery {
return q
}
func mapAddMonitorError(err error, groupID string, tribeID int64) error {
var pgError pgdriver.Error
if !errors.As(err, &pgError) {
return err
}
code := pgError.Field('C')
constraint := pgError.Field('n')
switch {
case code == pgerrcode.ForeignKeyViolation && constraint == "monitors_group_id_fkey":
return domain.GroupDoesNotExistError{
ID: groupID,
}
case code == pgerrcode.UniqueViolation && constraint == "monitors_group_id_tribe_id_key":
return domain.MonitorAlreadyExistsError{
TribeID: tribeID,
GroupID: groupID,
}
default:
return err
}
}

View File

@ -37,8 +37,7 @@ func TestGroup_Create(t *testing.T) {
group, err := repo.Create(context.Background(), params)
assert.NoError(t, err)
_, err = uuid.Parse(group.ID)
assert.NoError(t, err)
assert.NotZero(t, group.ID)
assert.Equal(t, params.ServerID(), group.ServerID)
assert.Equal(t, params.ServerKey(), group.ServerKey)
assert.Equal(t, params.VersionCode(), group.VersionCode)
@ -84,7 +83,7 @@ func TestGroup_Update(t *testing.T) {
},
}
updatedGroup, err := repo.Update(context.Background(), group.ID, group.ServerID, params)
updatedGroup, err := repo.Update(context.Background(), group.ID, params)
assert.NoError(t, err)
assert.Equal(t, params.ChannelGains.String, updatedGroup.ChannelGains)
assert.Equal(t, params.ChannelLosses.String, updatedGroup.ChannelLosses)
@ -95,7 +94,7 @@ func TestGroup_Update(t *testing.T) {
t.Run("ERR: nothing to update", func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.Update(context.Background(), "", "", domain.UpdateGroupParams{})
updatedGroup, err := repo.Update(context.Background(), "", domain.UpdateGroupParams{})
assert.ErrorIs(t, err, domain.ErrNothingToUpdate)
assert.Zero(t, updatedGroup)
})
@ -104,24 +103,16 @@ func TestGroup_Update(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
serverID string
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
{
name: "ServerID - random UUID",
id: group.ID,
serverID: uuid.NewString(),
name: "ID - random UUID",
id: uuid.NewString(),
},
}
@ -131,7 +122,7 @@ func TestGroup_Update(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.Update(context.Background(), tt.id, tt.serverID, domain.UpdateGroupParams{
updatedGroup, err := repo.Update(context.Background(), tt.id, domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: "update",
Valid: true,
@ -144,6 +135,168 @@ func TestGroup_Update(t *testing.T) {
})
}
func TestGroup_AddMonitor(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewGroup(db)
group := fixture.group(t, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
var tribeID int64 = 91283718
updatedGroup, err := repo.AddMonitor(context.Background(), group.ID, tribeID)
assert.NoError(t, err)
var found bool
for _, m := range updatedGroup.Monitors {
if m.GroupID == group.ID && m.TribeID == tribeID {
found = true
break
}
}
assert.True(t, found)
})
t.Run("ERR: group doesn't exist", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.AddMonitor(context.Background(), tt.id, 125)
assert.ErrorIs(t, err, domain.GroupDoesNotExistError{ID: tt.id})
assert.Zero(t, updatedGroup)
})
}
})
t.Run("ERR: monitor already exists", func(t *testing.T) {
t.Parallel()
monitor := fixture.monitor(t, "monitor-2-group-1-server-1")
updatedGroup, err := repo.AddMonitor(context.Background(), monitor.GroupID, monitor.TribeID)
assert.ErrorIs(t, err, domain.MonitorAlreadyExistsError{
TribeID: monitor.TribeID,
GroupID: monitor.GroupID,
})
assert.Zero(t, updatedGroup)
})
}
func TestGroup_DeleteMonitors(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewGroup(db)
t.Run("OK", func(t *testing.T) {
t.Parallel()
monitor := fixture.monitor(t, "monitor-1-group-1-server-1")
updatedGroup, err := repo.DeleteMonitors(context.Background(), monitor.GroupID, monitor.ID)
assert.NoError(t, err)
var found bool
for _, m := range updatedGroup.Monitors {
if m.ID == monitor.ID {
found = true
break
}
}
assert.False(t, found)
})
t.Run("ERR: group not found", func(t *testing.T) {
t.Parallel()
monitor := fixture.monitor(t, "monitor-2-group-1-server-1")
tests := []struct {
name string
id string
}{
{
name: "group ID - not UUID",
id: "id",
},
{
name: "group ID - not UUID",
id: uuid.NewString(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.DeleteMonitors(context.Background(), tt.id, monitor.ID)
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id})
assert.Zero(t, updatedGroup)
})
}
})
t.Run("ERR: monitor not found", func(t *testing.T) {
t.Parallel()
monitor := fixture.monitor(t, "monitor-2-group-1-server-1")
tests := []struct {
name string
monitorID string
}{
{
name: "monitor ID - not UUID",
monitorID: "id",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
updatedGroup, err := repo.DeleteMonitors(context.Background(), monitor.GroupID, tt.monitorID)
assert.ErrorIs(t, err, domain.MonitorNotFoundError{ID: tt.monitorID})
assert.Zero(t, updatedGroup)
})
}
})
}
func TestGroup_List(t *testing.T) {
t.Parallel()
@ -299,33 +452,25 @@ func TestGroup_Get(t *testing.T) {
t.Run("OK", func(t *testing.T) {
t.Parallel()
g, err := repo.Get(context.Background(), group.ID, group.ServerID)
g, err := repo.Get(context.Background(), group.ID)
assert.NoError(t, err)
assert.Equal(t, group, g)
assert.Equal(t, group.ID, g.ID)
})
t.Run("ERR: group not found (unknown ID)", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
serverID string
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
{
name: "ServerID - random UUID",
id: group.ID,
serverID: uuid.NewString(),
name: "ID - random UUID",
id: uuid.NewString(),
},
}
@ -335,7 +480,7 @@ func TestGroup_Get(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g, err := repo.Get(context.Background(), tt.id, tt.serverID)
g, err := repo.Get(context.Background(), tt.id)
assert.ErrorIs(t, err, domain.GroupNotFoundError{ID: tt.id})
assert.Zero(t, g)
})
@ -353,7 +498,6 @@ func TestGroup_Delete(t *testing.T) {
db := newDB(t)
fixture := loadFixtures(t, db)
groupRepo := bundb.NewGroup(db)
monitorRepo := bundb.NewMonitor(db)
group := fixture.group(t, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
@ -364,42 +508,29 @@ func TestGroup_Delete(t *testing.T) {
})
assert.NoError(t, err)
assert.NoError(t, groupRepo.Delete(context.Background(), group.ID, group.ServerID))
assert.NoError(t, groupRepo.Delete(context.Background(), group.ID))
afterDelete, err := groupRepo.List(context.Background(), domain.ListGroupsParams{
ServerIDs: []string{group.ServerID},
})
assert.NoError(t, err)
assert.Len(t, afterDelete, len(beforeDelete)-1)
// monitors should also be deleted
monitors, err := monitorRepo.List(context.Background(), group.ID)
assert.NoError(t, err)
assert.Len(t, monitors, 0)
})
t.Run("ERR: group not found", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
serverID string
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
serverID: group.ServerID,
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
serverID: group.ServerID,
},
{
name: "ServerID - random UUID",
id: group.ID,
serverID: uuid.NewString(),
name: "ID - random UUID",
id: uuid.NewString(),
},
}
@ -409,11 +540,7 @@ func TestGroup_Delete(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.ErrorIs(
t,
groupRepo.Delete(context.Background(), tt.id, tt.serverID),
domain.GroupNotFoundError{ID: tt.id},
)
assert.ErrorIs(t, groupRepo.Delete(context.Background(), tt.id), domain.GroupNotFoundError{ID: tt.id})
})
}
})
@ -429,7 +556,6 @@ func TestGroup_DeleteMany(t *testing.T) {
db := newDB(t)
fixture := loadFixtures(t, db)
groupRepo := bundb.NewGroup(db)
monitorRepo := bundb.NewMonitor(db)
group1 := fixture.group(t, "group-1-server-1")
group2 := fixture.group(t, "group-2-server-1")
ids := []string{group1.ID, group2.ID}
@ -450,13 +576,6 @@ func TestGroup_DeleteMany(t *testing.T) {
})
assert.NoError(t, err)
assert.Len(t, afterDelete, len(beforeDelete)-len(ids))
// monitors should also be deleted
for _, id := range ids {
monitors, err := monitorRepo.List(context.Background(), id)
assert.NoError(t, err)
assert.Len(t, monitors, 0)
}
})
t.Run("OK: 0 ids", func(t *testing.T) {

View File

@ -20,18 +20,26 @@ type Group struct {
ServerKey string `bun:"server_key,nullzero"`
VersionCode string `bun:"version_code,nullzero"`
CreatedAt time.Time `bun:"created_at,nullzero"`
Monitors []Monitor `bun:"monitors,rel:has-many,join:id=group_id"`
}
func (g Group) ToDomain() domain.Group {
return domain.Group{
ID: g.ID.String(),
ServerID: g.ServerID,
ChannelGains: g.ChannelGains,
ChannelLosses: g.ChannelLosses,
Internals: g.Internals,
Barbarians: g.Barbarians,
ServerKey: g.ServerKey,
VersionCode: g.VersionCode,
CreatedAt: g.CreatedAt,
func (g Group) ToDomain() domain.GroupWithMonitors {
monitors := make([]domain.Monitor, 0, len(g.Monitors))
for _, m := range g.Monitors {
monitors = append(monitors, m.ToDomain())
}
return domain.GroupWithMonitors{
Group: domain.Group{
ID: g.ID.String(),
ServerID: g.ServerID,
ChannelGains: g.ChannelGains,
ChannelLosses: g.ChannelLosses,
Internals: g.Internals,
Barbarians: g.Barbarians,
ServerKey: g.ServerKey,
VersionCode: g.VersionCode,
CreatedAt: g.CreatedAt,
},
Monitors: monitors,
}
}

View File

@ -1,137 +0,0 @@
package bundb
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.dwysokinski.me/twhelp/dcbot/internal/bundb/internal/model"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/google/uuid"
"github.com/jackc/pgerrcode"
"github.com/uptrace/bun"
"github.com/uptrace/bun/driver/pgdriver"
)
type Monitor struct {
db *bun.DB
}
func NewMonitor(db *bun.DB) *Monitor {
return &Monitor{db: db}
}
func (m *Monitor) Create(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) {
groupID, err := uuid.Parse(params.GroupID())
if err != nil {
return domain.Monitor{}, domain.GroupDoesNotExistError{
ID: params.GroupID(),
}
}
monitor := model.Monitor{
GroupID: groupID,
TribeID: params.TribeID(),
}
if _, err = m.db.NewInsert().
Model(&monitor).
Returning("*").
Exec(ctx); err != nil {
return domain.Monitor{}, fmt.Errorf(
"something went wrong while inserting monitor into the db: %w",
mapCreateMonitorError(err, params),
)
}
return monitor.ToDomain(), nil
}
func (m *Monitor) List(ctx context.Context, groupID string) ([]domain.Monitor, error) {
if _, err := uuid.Parse(groupID); err != nil {
return nil, nil
}
var monitors []model.Monitor
if err := m.db.NewSelect().
Model(&monitors).
Order("created_at ASC").
Where("group_id = ?", groupID).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("couldn't select monitors from the db: %w", err)
}
result := make([]domain.Monitor, 0, len(monitors))
for _, monitor := range monitors {
result = append(result, monitor.ToDomain())
}
return result, nil
}
func (m *Monitor) Get(ctx context.Context, id string) (domain.Monitor, error) {
if _, err := uuid.Parse(id); err != nil {
return domain.Monitor{}, domain.MonitorNotFoundError{ID: id}
}
var monitor model.Monitor
err := m.db.NewSelect().
Model(&monitor).
Where("id = ?", id).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Monitor{}, domain.MonitorNotFoundError{ID: id}
}
return domain.Monitor{}, fmt.Errorf("couldn't select monitor from the db (id=%s): %w", id, err)
}
return monitor.ToDomain(), nil
}
func (m *Monitor) Delete(ctx context.Context, id string) error {
if _, err := uuid.Parse(id); err != nil {
return domain.MonitorNotFoundError{ID: id}
}
res, err := m.db.NewDelete().
Model(&model.Monitor{}).
Returning("NULL").
Where("id = ?", id).
Exec(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("couldn't delete monitor (id=%s): %w", id, err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return domain.MonitorNotFoundError{ID: id}
}
return nil
}
func mapCreateMonitorError(err error, params domain.CreateMonitorParams) error {
var pgError pgdriver.Error
if !errors.As(err, &pgError) {
return err
}
code := pgError.Field('C')
constraint := pgError.Field('n')
switch {
case code == pgerrcode.ForeignKeyViolation && constraint == "monitors_group_id_fkey":
return domain.GroupDoesNotExistError{
ID: params.GroupID(),
}
case code == pgerrcode.UniqueViolation && constraint == "monitors_group_id_tribe_id_key":
return domain.MonitorAlreadyExistsError{
TribeID: params.TribeID(),
GroupID: params.GroupID(),
}
default:
return err
}
}

View File

@ -1,262 +0,0 @@
package bundb_test
import (
"context"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/bundb"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMonitor_Create(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewMonitor(db)
t.Run("OK", func(t *testing.T) {
t.Parallel()
group := fixture.group(t, "group-1-server-1")
params, err := domain.NewCreateMonitorParams(group.ID, 1234)
require.Zero(t, err)
monitor, err := repo.Create(context.Background(), params)
assert.NoError(t, err)
_, err = uuid.Parse(monitor.ID)
assert.NoError(t, err)
assert.Equal(t, params.GroupID(), monitor.GroupID)
assert.Equal(t, params.TribeID(), monitor.TribeID)
assert.WithinDuration(t, time.Now(), monitor.CreatedAt, 1*time.Second)
// check unique index
monitor, err = repo.Create(context.Background(), params)
assert.ErrorIs(t, err, domain.MonitorAlreadyExistsError{
TribeID: params.TribeID(),
GroupID: params.GroupID(),
})
assert.Zero(t, monitor)
})
t.Run("ERR: group doesn't exist", func(t *testing.T) {
t.Parallel()
t.Run("foreign key violation", func(t *testing.T) {
t.Parallel()
groupID := uuid.NewString()
params, err := domain.NewCreateMonitorParams(groupID, 1234)
require.Zero(t, err)
monitor, err := repo.Create(context.Background(), params)
assert.ErrorIs(t, err, domain.GroupDoesNotExistError{
ID: groupID,
})
assert.Zero(t, monitor)
})
t.Run("not UUID", func(t *testing.T) {
t.Parallel()
groupID := "592292203234328587"
params, err := domain.NewCreateMonitorParams(groupID, 1234)
require.Zero(t, err)
monitor, err := repo.Create(context.Background(), params)
assert.ErrorIs(t, err, domain.GroupDoesNotExistError{
ID: groupID,
})
assert.Zero(t, monitor)
})
})
}
func TestMonitor_List(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewMonitor(db)
tests := []struct {
name string
groupID string
expectedMonitors []string
}{
{
name: "group-1-server-1",
groupID: fixture.group(t, "group-1-server-1").ID,
expectedMonitors: []string{
"e3017ba8-4fba-4bb1-ac8d-e4e9477a04d2",
"89719460-58ab-46b8-8682-46f161546949",
},
},
{
name: "group-2-server-2",
groupID: fixture.group(t, "group-2-server-2").ID,
expectedMonitors: []string{
"c7d63c3d-55f6-432e-b9d8-006f8c5ab407",
},
},
{
name: "random UUID",
groupID: uuid.NewString(),
expectedMonitors: nil,
},
{
name: "not UUID",
groupID: "test",
expectedMonitors: nil,
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
res, err := repo.List(context.Background(), tt.groupID)
assert.NoError(t, err)
assert.Len(t, res, len(tt.expectedMonitors))
for _, id := range tt.expectedMonitors {
found := false
for _, m := range res {
if m.ID == id {
found = true
break
}
}
assert.True(t, found, "monitor (id=%s) not found", id)
}
})
}
}
func TestMonitor_Get(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewMonitor(db)
group := fixture.group(t, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
monitors, err := repo.List(context.Background(), group.ID)
require.NoError(t, err)
require.Greater(t, len(monitors), 0)
m, err := repo.Get(context.Background(), monitors[0].ID)
assert.NoError(t, err)
assert.Equal(t, monitors[0], m)
})
t.Run("ERR: monitor not found", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
monitor, err := repo.Get(context.Background(), tt.id)
assert.ErrorIs(t, err, domain.MonitorNotFoundError{ID: tt.id})
assert.Zero(t, monitor)
})
}
})
}
func TestMonitor_Delete(t *testing.T) {
t.Parallel()
if testing.Short() {
t.Skip("skipping long-running test")
}
db := newDB(t)
fixture := loadFixtures(t, db)
repo := bundb.NewMonitor(db)
group := fixture.group(t, "group-1-server-1")
t.Run("OK", func(t *testing.T) {
t.Parallel()
beforeDelete, err := repo.List(context.Background(), group.ID)
assert.NoError(t, err)
assert.Greater(t, len(beforeDelete), 0)
assert.NoError(t, repo.Delete(context.Background(), beforeDelete[0].ID))
afterDelete, err := repo.List(context.Background(), group.ID)
assert.NoError(t, err)
assert.Len(t, afterDelete, len(beforeDelete)-1)
})
t.Run("ERR: monitor not found", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
id string
}{
{
name: "ID - not UUID",
id: "test",
},
{
name: "ID - random UUID",
id: uuid.NewString(),
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.ErrorIs(
t,
repo.Delete(context.Background(), tt.id),
domain.MonitorNotFoundError{ID: tt.id},
)
})
}
})
}

View File

@ -12,20 +12,17 @@ import (
)
type GroupService interface {
Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error)
SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.Group, error)
SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.Group, error)
SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.Group, error)
SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.Group, error)
CleanUp(ctx context.Context) error
List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error)
Delete(ctx context.Context, id, serverID string) error
}
type MonitorService interface {
Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error)
List(ctx context.Context, groupID, serverID string) ([]domain.MonitorWithTribe, error)
Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error)
AddMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error)
DeleteMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error)
SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error)
SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error)
SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.GroupWithMonitors, error)
SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.GroupWithMonitors, error)
Execute(ctx context.Context) ([]domain.EnnoblementNotification, error)
CleanUp(ctx context.Context) error
ListServer(ctx context.Context, serverID string) ([]domain.GroupWithMonitors, error)
GetWithTribes(ctx context.Context, id, serverID string) (domain.GroupWithMonitorsAndTribes, error)
Delete(ctx context.Context, id, serverID string) error
}
@ -34,18 +31,16 @@ type ChoiceService interface {
}
type Bot struct {
s *discordgo.Session
c *cron.Cron
groupSvc GroupService
monitorSvc MonitorService
choiceSvc ChoiceService
logger *zap.Logger
s *discordgo.Session
c *cron.Cron
groupSvc GroupService
choiceSvc ChoiceService
logger *zap.Logger
}
func NewBot(
token string,
groupSvc GroupService,
monitorSvc MonitorService,
client ChoiceService,
logger *zap.Logger,
) (*Bot, error) {
@ -64,10 +59,9 @@ func NewBot(
cron.SkipIfStillRunning(cron.DiscardLogger),
),
),
groupSvc: groupSvc,
monitorSvc: monitorSvc,
choiceSvc: client,
logger: logger,
groupSvc: groupSvc,
choiceSvc: client,
logger: logger,
}
b.s.AddHandler(b.handleSessionReady)
@ -103,7 +97,6 @@ type command interface {
func (b *Bot) registerCommands() error {
commands := []command{
&groupCommand{groupSvc: b.groupSvc, choiceSvc: b.choiceSvc},
&monitorCommand{svc: b.monitorSvc},
}
for _, c := range commands {
@ -129,7 +122,7 @@ func (b *Bot) initCron() error {
}{
{
spec: "@every 1m",
job: &executeMonitorsJob{svc: b.monitorSvc, s: b.s, logger: b.logger},
job: &executeMonitorsJob{svc: b.groupSvc, s: b.s, logger: b.logger},
},
{
spec: "0 */8 * * *",
@ -159,7 +152,8 @@ func (b *Bot) logCommands(_ *discordgo.Session, i *discordgo.InteractionCreate)
cmdData := i.ApplicationCommandData()
cmd := cmdData.Name
options := cmdData.Options
for len(options) > 0 && options[0].Type == discordgo.ApplicationCommandOptionSubCommand {
for len(options) > 0 &&
(options[0].Type == discordgo.ApplicationCommandOptionSubCommand || options[0].Type == discordgo.ApplicationCommandOptionSubCommandGroup) {
cmd += " " + options[0].Name
options = options[0].Options
}

View File

@ -3,12 +3,17 @@ package discord
import (
"context"
"fmt"
"strings"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"github.com/bwmarrin/discordgo"
)
const (
tribeTagMaxLength = 10
)
type groupCommand struct {
groupSvc GroupService
choiceSvc ChoiceService
@ -98,9 +103,69 @@ func (c *groupCommand) create(s *discordgo.Session) error {
Description: "Lists all created groups",
Type: discordgo.ApplicationCommandOptionSubCommand,
},
{
Name: "details",
Description: "Displays group details (including tribes added to the group)",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "group",
Description: "Group ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
},
},
{
Name: "monitor",
Description: "Manages monitors",
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "add",
Description: "Adds a new monitor to a group",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "group",
Description: "Group ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
{
Name: "tag",
Description: "Tribe tag",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
MaxLength: tribeTagMaxLength,
},
},
},
{
Name: "delete",
Description: "Deletes a monitor",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "group",
Description: "Group ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
{
Name: "tag",
Description: "Tribe tag",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
MaxLength: tribeTagMaxLength,
},
},
},
},
},
{
Name: "set",
Description: "Sets various properties in a group configuration",
Description: "Sets various properties in group configuration",
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
Options: []*discordgo.ApplicationCommandOption{
{
@ -266,29 +331,27 @@ func (c *groupCommand) handle(s *discordgo.Session, i *discordgo.InteractionCrea
return
}
ctx := context.Background()
switch cmdData.Options[0].Name {
case "create":
c.handleCreate(s, i)
return
c.handleCreate(ctx, s, i)
case "list":
c.handleList(s, i)
return
c.handleList(ctx, s, i)
case "details":
c.handleDetails(ctx, s, i)
case "set":
c.handleSet(s, i)
return
c.handleSet(ctx, s, i)
case "unset":
c.handleUnset(s, i)
return
c.handleUnset(ctx, s, i)
case "monitor":
c.handleMonitor(ctx, s, i)
case "delete":
c.handleDelete(s, i)
return
default:
c.handleDelete(ctx, s, i)
}
}
func (c *groupCommand) handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleCreate(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
version := ""
server := ""
channelGains := ""
@ -345,17 +408,13 @@ func (c *groupCommand) handleCreate(s *discordgo.Session, i *discordgo.Interacti
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "group has been successfully created (ID=" + group.ID + ")",
Content: "group has been successfully created (id=" + group.ID + ")",
},
})
}
func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
groups, err := c.groupSvc.List(ctx, domain.ListGroupsParams{
ServerIDs: []string{i.GuildID},
})
func (c *groupCommand) handleList(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
groups, err := c.groupSvc.ListServer(ctx, i.GuildID)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
@ -379,12 +438,13 @@ func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.Interaction
fields = append(fields, &discordgo.MessageEmbedField{
Name: g.ID,
Value: fmt.Sprintf(
"**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s",
"**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s\n **Number of monitored monitors**: %d",
g.ServerKey,
channelGains,
channelLosses,
boolToEmoji(g.Internals),
boolToEmoji(g.Barbarians),
len(g.Monitors),
),
Inline: false,
})
@ -405,27 +465,75 @@ func (c *groupCommand) handleList(s *discordgo.Session, i *discordgo.Interaction
})
}
func (c *groupCommand) handleSet(s *discordgo.Session, i *discordgo.InteractionCreate) {
func (c *groupCommand) handleDetails(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
g, err := c.groupSvc.GetWithTribes(ctx, i.ApplicationCommandData().Options[0].Options[0].StringValue(), i.GuildID)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
var builderMonitors strings.Builder
for i, m := range g.Monitors {
if i > 0 {
builderMonitors.WriteString(" ")
}
builderMonitors.WriteString(buildLink(m.Tribe.Tag, m.Tribe.ProfileURL))
}
if builderMonitors.Len() == 0 {
builderMonitors.WriteString("None")
}
channelGains := "Not set"
if g.ChannelGains != "" {
channelGains = buildChannelMention(g.ChannelGains)
}
channelLosses := "Not set"
if g.ChannelLosses != "" {
channelLosses = buildChannelMention(g.ChannelLosses)
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Type: discordgo.EmbedTypeRich,
Title: g.ID,
Description: fmt.Sprintf(
"**Server**: %s\n**Channel gains**: %s\n**Channel losses**: %s\n**Internals**: %s\n **Barbarians**: %s\n **Monitored tribes**: %s",
g.ServerKey,
channelGains,
channelLosses,
boolToEmoji(g.Internals),
boolToEmoji(g.Barbarians),
builderMonitors.String(),
),
Timestamp: formatTimestamp(time.Now()),
},
},
},
})
}
func (c *groupCommand) handleSet(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.ApplicationCommandData().Options[0].Options[0].Name {
case "channel-gains":
c.handleSetChannelGains(s, i)
return
c.handleSetChannelGains(ctx, s, i)
case "channel-losses":
c.handleSetChannelLosses(s, i)
return
c.handleSetChannelLosses(ctx, s, i)
case "internals":
c.handleSetInternals(s, i)
return
c.handleSetInternals(ctx, s, i)
case "barbarians":
c.handleSetBarbarians(s, i)
return
default:
c.handleSetBarbarians(ctx, s, i)
}
}
func (c *groupCommand) handleSetChannelGains(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleSetChannelGains(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
channel := ""
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
@ -459,9 +567,7 @@ func (c *groupCommand) handleSetChannelGains(s *discordgo.Session, i *discordgo.
})
}
func (c *groupCommand) handleSetChannelLosses(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleSetChannelLosses(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
channel := ""
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
@ -495,9 +601,7 @@ func (c *groupCommand) handleSetChannelLosses(s *discordgo.Session, i *discordgo
})
}
func (c *groupCommand) handleSetInternals(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleSetInternals(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
internals := false
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
@ -531,9 +635,7 @@ func (c *groupCommand) handleSetInternals(s *discordgo.Session, i *discordgo.Int
})
}
func (c *groupCommand) handleSetBarbarians(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleSetBarbarians(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
barbarians := false
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
@ -567,21 +669,16 @@ func (c *groupCommand) handleSetBarbarians(s *discordgo.Session, i *discordgo.In
})
}
func (c *groupCommand) handleUnset(s *discordgo.Session, i *discordgo.InteractionCreate) {
func (c *groupCommand) handleUnset(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.ApplicationCommandData().Options[0].Options[0].Name {
case "channel-gains":
c.handleUnsetChannelGains(s, i)
return
c.handleUnsetChannelGains(ctx, s, i)
case "channel-losses":
c.handleUnsetChannelLosses(s, i)
return
default:
c.handleUnsetChannelLosses(ctx, s, i)
}
}
func (c *groupCommand) handleUnsetChannelGains(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleUnsetChannelGains(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := i.ApplicationCommandData().Options[0].Options[0].Options[0].StringValue()
_, err := c.groupSvc.SetChannelGains(ctx, group, i.GuildID, "")
if err != nil {
@ -602,9 +699,7 @@ func (c *groupCommand) handleUnsetChannelGains(s *discordgo.Session, i *discordg
})
}
func (c *groupCommand) handleUnsetChannelLosses(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleUnsetChannelLosses(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := i.ApplicationCommandData().Options[0].Options[0].Options[0].StringValue()
_, err := c.groupSvc.SetChannelLosses(ctx, group, i.GuildID, "")
if err != nil {
@ -625,9 +720,84 @@ func (c *groupCommand) handleUnsetChannelLosses(s *discordgo.Session, i *discord
})
}
func (c *groupCommand) handleDelete(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
func (c *groupCommand) handleMonitor(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.ApplicationCommandData().Options[0].Options[0].Name {
case "add":
c.handleMonitorAdd(ctx, s, i)
case "delete":
c.handleMonitorDelete(ctx, s, i)
}
}
func (c *groupCommand) handleMonitorAdd(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
tag := ""
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
if opt == nil {
continue
}
switch opt.Name {
case "group":
group = opt.StringValue()
case "tag":
tag = opt.StringValue()
}
}
_, err := c.groupSvc.AddMonitor(ctx, group, i.GuildID, tag)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "monitor has been successfully added",
},
})
}
func (c *groupCommand) handleMonitorDelete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := ""
tag := ""
for _, opt := range i.ApplicationCommandData().Options[0].Options[0].Options {
if opt == nil {
continue
}
switch opt.Name {
case "group":
group = opt.StringValue()
case "tag":
tag = opt.StringValue()
}
}
_, err := c.groupSvc.DeleteMonitor(ctx, group, i.GuildID, tag)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "monitor has been successfully deleted",
},
})
}
func (c *groupCommand) handleDelete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
group := i.ApplicationCommandData().Options[0].Options[0].StringValue()
if err := c.groupSvc.Delete(ctx, group, i.GuildID); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{

View File

@ -1,215 +0,0 @@
package discord
import (
"context"
"fmt"
"time"
"github.com/bwmarrin/discordgo"
)
const (
tribeTagMaxLength = 10
)
type monitorCommand struct {
svc MonitorService
}
func (c *monitorCommand) name() string {
return "monitor"
}
func (c *monitorCommand) register(s *discordgo.Session) error {
if err := c.create(s); err != nil {
return err
}
s.AddHandler(c.handle)
return nil
}
func (c *monitorCommand) create(s *discordgo.Session) error {
var perm int64 = discordgo.PermissionAdministrator
dm := false
_, err := s.ApplicationCommandCreate(s.State.User.ID, "", &discordgo.ApplicationCommand{
Name: c.name(),
Description: "Manages monitors",
DefaultMemberPermissions: &perm,
DMPermission: &dm,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "create",
Description: "Creates a new monitor",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "group",
Description: "Group ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
{
Name: "tag",
Description: "Tribe tag",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
MaxLength: tribeTagMaxLength,
},
},
},
{
Name: "list",
Description: "Lists all monitors associated with a particular group",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "group",
Description: "Group ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
},
},
{
Name: "delete",
Description: "Deletes a monitor",
Type: discordgo.ApplicationCommandOptionSubCommand,
Options: []*discordgo.ApplicationCommandOption{
{
Name: "monitor",
Description: "Monitor ID",
Type: discordgo.ApplicationCommandOptionString,
Required: true,
},
},
},
},
})
if err != nil {
return fmt.Errorf("s.ApplicationCommandCreate: %w", err)
}
return nil
}
func (c *monitorCommand) handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
cmdData := i.ApplicationCommandData()
if cmdData.Name != c.name() {
return
}
switch cmdData.Options[0].Name {
case "create":
c.handleCreate(s, i)
return
case "list":
c.handleList(s, i)
return
case "delete":
c.handleDelete(s, i)
return
default:
}
}
func (c *monitorCommand) handleCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
group := ""
tag := ""
for _, opt := range i.ApplicationCommandData().Options[0].Options {
if opt == nil {
continue
}
switch opt.Name {
case "group":
group = opt.StringValue()
case "tag":
tag = opt.StringValue()
}
}
monitor, err := c.svc.Create(ctx, group, i.GuildID, tag)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "monitor has been successfully created (ID=" + monitor.ID + ")",
},
})
}
func (c *monitorCommand) handleList(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
group := i.ApplicationCommandData().Options[0].Options[0].StringValue()
monitors, err := c.svc.List(ctx, group, i.GuildID)
if err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
fields := make([]*discordgo.MessageEmbedField, 0, len(monitors))
for _, m := range monitors {
fields = append(fields, &discordgo.MessageEmbedField{
Name: m.ID,
Value: fmt.Sprintf("**Tribe**: %s", buildLink(m.Tribe.Tag, m.Tribe.ProfileURL)),
Inline: false,
})
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: []*discordgo.MessageEmbed{
{
Type: discordgo.EmbedTypeRich,
Title: "Monitor list",
Description: "**Group**: " + group,
Fields: fields,
Timestamp: formatTimestamp(time.Now()),
},
},
},
})
}
func (c *monitorCommand) handleDelete(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx := context.Background()
monitor := i.ApplicationCommandData().Options[0].Options[0].StringValue()
if err := c.svc.Delete(ctx, monitor, i.GuildID); err != nil {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: messageFromError(err),
},
})
return
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "monitor has been successfully deleted",
},
})
}

View File

@ -19,7 +19,7 @@ const (
type executeMonitorsJob struct {
s *discordgo.Session
svc MonitorService
svc GroupService
logger *zap.Logger
}

View File

@ -3,7 +3,7 @@ package domain
type EnnoblementNotificationType uint8
const (
EnnoblementNotificationTypeGain = iota
EnnoblementNotificationTypeGain EnnoblementNotificationType = iota
EnnoblementNotificationTypeLoss
)

View File

@ -17,6 +17,18 @@ type Group struct {
CreatedAt time.Time
}
type GroupWithMonitors struct {
Group
Monitors []Monitor
}
type GroupWithMonitorsAndTribes struct {
Group
Monitors []MonitorWithTribe
}
type CreateGroupParams struct {
serverID string
serverKey string
@ -135,7 +147,7 @@ type GroupNotFoundError struct {
}
func (e GroupNotFoundError) Error() string {
return fmt.Sprintf("group (ID=%s) not found", e.ID)
return fmt.Sprintf("group (id=%s) not found", e.ID)
}
func (e GroupNotFoundError) UserError() string {
@ -151,7 +163,7 @@ type GroupDoesNotExistError struct {
}
func (e GroupDoesNotExistError) Error() string {
return fmt.Sprintf("group (ID=%s) doesn't exist", e.ID)
return fmt.Sprintf("group (id=%s) doesn't exist", e.ID)
}
func (e GroupDoesNotExistError) UserError() string {

View File

@ -204,7 +204,7 @@ func TestGroupNotFoundError(t *testing.T) {
ID: uuid.NewString(),
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("group (ID=%s) not found", err.ID), err.Error())
assert.Equal(t, fmt.Sprintf("group (id=%s) not found", err.ID), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code())
}
@ -216,7 +216,7 @@ func TestGroupDoesNotExistError(t *testing.T) {
ID: uuid.NewString(),
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("group (ID=%s) doesn't exist", err.ID), err.Error())
assert.Equal(t, fmt.Sprintf("group (id=%s) doesn't exist", err.ID), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}

View File

@ -5,10 +5,6 @@ import (
"time"
)
const (
tribeIDMin = 1
)
type Monitor struct {
ID string
TribeID int64
@ -21,46 +17,13 @@ type MonitorWithTribe struct {
Tribe TribeMeta
}
type CreateMonitorParams struct {
tribeID int64
groupID string
}
func NewCreateMonitorParams(groupID string, tribeID int64) (CreateMonitorParams, error) {
if groupID == "" {
return CreateMonitorParams{}, ValidationError{
Field: "GroupID",
Err: ErrRequired,
}
}
if tribeID < tribeIDMin {
return CreateMonitorParams{}, ValidationError{
Field: "TribeID",
Err: MinError{
Min: tribeIDMin,
},
}
}
return CreateMonitorParams{tribeID: tribeID, groupID: groupID}, nil
}
func (c CreateMonitorParams) TribeID() int64 {
return c.tribeID
}
func (c CreateMonitorParams) GroupID() string {
return c.groupID
}
type MonitorAlreadyExistsError struct {
TribeID int64
GroupID string
}
func (e MonitorAlreadyExistsError) Error() string {
return fmt.Sprintf("monitor (GroupID=%s,TribeID=%d) already exists", e.GroupID, e.TribeID)
return fmt.Sprintf("monitor (groupID=%s,tribeID=%d) already exists", e.GroupID, e.TribeID)
}
func (e MonitorAlreadyExistsError) UserError() string {
@ -93,7 +56,7 @@ type MonitorNotFoundError struct {
}
func (e MonitorNotFoundError) Error() string {
return fmt.Sprintf("monitor (ID=%s) not found", e.ID)
return fmt.Sprintf("monitor (id=%s) not found", e.ID)
}
func (e MonitorNotFoundError) UserError() string {

View File

@ -9,63 +9,6 @@ import (
"github.com/stretchr/testify/assert"
)
func TestNewCreateMonitorParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tribeID int64
groupID string
err error
}{
{
name: "OK",
tribeID: 15,
groupID: uuid.NewString(),
},
{
name: "ERR: GroupID cannot be blank",
groupID: "",
err: domain.ValidationError{
Field: "GroupID",
Err: domain.ErrRequired,
},
},
{
name: "ERR: TribeID < 1",
groupID: uuid.NewString(),
tribeID: 0,
err: domain.ValidationError{
Field: "TribeID",
Err: domain.MinError{
Min: 1,
},
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
res, err := domain.NewCreateMonitorParams(
tt.groupID,
tt.tribeID,
)
if tt.err != nil {
assert.ErrorIs(t, err, tt.err)
assert.Zero(t, res)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.groupID, res.GroupID())
assert.Equal(t, tt.tribeID, res.TribeID())
})
}
}
func TestMonitorAlreadyExistsError(t *testing.T) {
t.Parallel()
@ -74,7 +17,7 @@ func TestMonitorAlreadyExistsError(t *testing.T) {
TribeID: 1234,
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("monitor (GroupID=%s,TribeID=%d) already exists", err.GroupID, err.TribeID), err.Error())
assert.Equal(t, fmt.Sprintf("monitor (groupID=%s,tribeID=%d) already exists", err.GroupID, err.TribeID), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeAlreadyExists, err.Code())
}
@ -99,7 +42,7 @@ func TestMonitorNotFoundError(t *testing.T) {
ID: uuid.NewString(),
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("monitor (ID=%s) not found", err.ID), err.Error())
assert.Equal(t, fmt.Sprintf("monitor (id=%s) not found", err.ID), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code())
}

View File

@ -49,7 +49,7 @@ type ServerDoesNotExistError struct {
}
func (e ServerDoesNotExistError) Error() string {
return fmt.Sprintf("server (VersionCode=%s,Key=%s) doesn't exist", e.VersionCode, e.Key)
return fmt.Sprintf("server (versionCode=%s,key=%s) doesn't exist", e.VersionCode, e.Key)
}
func (e ServerDoesNotExistError) UserError() string {
@ -66,7 +66,7 @@ type ServerIsClosedError struct {
}
func (e ServerIsClosedError) Error() string {
return fmt.Sprintf("server (VersionCode=%s,Key=%s) is closed", e.VersionCode, e.Key)
return fmt.Sprintf("server (versionCode=%s,key=%s) is closed", e.VersionCode, e.Key)
}
func (e ServerIsClosedError) UserError() string {
@ -82,7 +82,7 @@ type TribeDoesNotExistError struct {
}
func (e TribeDoesNotExistError) Error() string {
return fmt.Sprintf("tribe (Tag=%s) doesn't exist", e.Tag)
return fmt.Sprintf("tribe (tag=%s) doesn't exist", e.Tag)
}
func (e TribeDoesNotExistError) UserError() string {
@ -92,3 +92,19 @@ func (e TribeDoesNotExistError) UserError() string {
func (e TribeDoesNotExistError) Code() ErrorCode {
return ErrorCodeValidationError
}
type TribeNotFoundError struct {
Tag string
}
func (e TribeNotFoundError) Error() string {
return fmt.Sprintf("tribe (tag=%s) not found", e.Tag)
}
func (e TribeNotFoundError) UserError() string {
return e.Error()
}
func (e TribeNotFoundError) Code() ErrorCode {
return ErrorCodeEntityNotFound
}

View File

@ -16,7 +16,7 @@ func TestServerDoesNotExistError(t *testing.T) {
Key: "pl151",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("server (VersionCode=%s,Key=%s) doesn't exist", err.VersionCode, err.Key), err.Error())
assert.Equal(t, fmt.Sprintf("server (versionCode=%s,key=%s) doesn't exist", err.VersionCode, err.Key), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
@ -29,7 +29,7 @@ func TestServerIsClosedError(t *testing.T) {
Key: "pl151",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("server (VersionCode=%s,Key=%s) is closed", err.VersionCode, err.Key), err.Error())
assert.Equal(t, fmt.Sprintf("server (versionCode=%s,key=%s) is closed", err.VersionCode, err.Key), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
@ -41,7 +41,19 @@ func TestTribeDoesNotExistError(t *testing.T) {
Tag: "*TAG*",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("tribe (Tag=%s) doesn't exist", err.Tag), err.Error())
assert.Equal(t, fmt.Sprintf("tribe (tag=%s) doesn't exist", err.Tag), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeValidationError, err.Code())
}
func TestTribeNotFoundError(t *testing.T) {
t.Parallel()
err := domain.TribeNotFoundError{
Tag: "*TAG*",
}
var _ domain.Error = err
assert.Equal(t, fmt.Sprintf("tribe (tag=%s) not found", err.Tag), err.Error())
assert.Equal(t, err.Error(), err.UserError())
assert.Equal(t, domain.ErrorCodeEntityNotFound, err.Code())
}

View File

@ -0,0 +1,148 @@
package service
import (
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
)
type ennoblementNotificationBuilder struct {
groups []domain.GroupWithMonitors
ennoblements listEnnoblementsResult
}
func (b ennoblementNotificationBuilder) build() []domain.EnnoblementNotification {
var notifications []domain.EnnoblementNotification
for _, g := range b.groups {
notifications = append(notifications, b.buildGroup(g)...)
}
return notifications
}
func (b ennoblementNotificationBuilder) buildGroup(g domain.GroupWithMonitors) []domain.EnnoblementNotification {
var notifications []domain.EnnoblementNotification
for _, e := range b.ennoblements.find(g.VersionCode, g.ServerKey) {
if b.canSendEnnoblementNotificationTypeGain(g, e) {
notifications = append(notifications, domain.EnnoblementNotification{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: g.ServerID,
ChannelID: g.ChannelGains,
Ennoblement: b.ennoblementToDomainModel(e),
})
}
if b.canSendEnnoblementNotificationTypeLoss(g, e) {
notifications = append(notifications, domain.EnnoblementNotification{
Type: domain.EnnoblementNotificationTypeLoss,
ServerID: g.ServerID,
ChannelID: g.ChannelLosses,
Ennoblement: b.ennoblementToDomainModel(e),
})
}
}
return notifications
}
func (b ennoblementNotificationBuilder) canSendEnnoblementNotificationTypeGain(g domain.GroupWithMonitors, e twhelp.Ennoblement) bool {
if g.ChannelGains == "" {
return false
}
if !g.Barbarians && b.isBarbarian(e) {
return false
}
if !g.Internals && b.isInternal(e, g.Monitors) {
return false
}
return b.isGain(e, g.Monitors)
}
func (b ennoblementNotificationBuilder) canSendEnnoblementNotificationTypeLoss(g domain.GroupWithMonitors, e twhelp.Ennoblement) bool {
if g.ChannelLosses == "" {
return false
}
if b.isInternal(e, g.Monitors) {
return false
}
return b.isLoss(e, g.Monitors)
}
func (b ennoblementNotificationBuilder) isInternal(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var n, o bool
for _, m := range monitors {
if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID {
n = true
}
if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID {
o = true
}
}
return n && o
}
func (b ennoblementNotificationBuilder) isBarbarian(e twhelp.Ennoblement) bool {
return !e.Village.Player.Valid
}
func (b ennoblementNotificationBuilder) isGain(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var n bool
for _, m := range monitors {
if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID {
n = true
break
}
}
return n && e.NewOwner.Player.ID != e.Village.Player.Player.ID
}
func (b ennoblementNotificationBuilder) isLoss(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var o bool
for _, m := range monitors {
if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID {
o = true
break
}
}
return o && e.NewOwner.Player.ID != e.Village.Player.Player.ID
}
func (b ennoblementNotificationBuilder) ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement {
return domain.Ennoblement{
ID: e.ID,
Village: domain.VillageMeta{
ID: e.Village.ID,
FullName: e.Village.FullName,
ProfileURL: e.Village.ProfileURL,
Player: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.Village.Player.Player.ID,
Name: e.Village.Player.Player.Name,
ProfileURL: e.Village.Player.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe),
Valid: e.Village.Player.Player.Tribe.Valid,
},
},
Valid: e.Village.Player.Valid,
},
},
NewOwner: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.NewOwner.Player.ID,
Name: e.NewOwner.Player.Name,
ProfileURL: e.NewOwner.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe),
Valid: e.NewOwner.Player.Tribe.Valid,
},
},
Valid: e.NewOwner.Valid,
},
CreatedAt: e.CreatedAt,
}
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
@ -13,52 +14,58 @@ import (
//counterfeiter:generate -o internal/mock/group_repository.gen.go . GroupRepository
type GroupRepository interface {
Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error)
Update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error)
List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error)
Get(ctx context.Context, id, serverID string) (domain.Group, error)
Delete(ctx context.Context, id, serverID string) error
Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error)
Update(ctx context.Context, id string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error)
AddMonitor(ctx context.Context, id string, tribeID int64) (domain.GroupWithMonitors, error)
DeleteMonitors(ctx context.Context, id string, monitorID ...string) (domain.GroupWithMonitors, error)
List(ctx context.Context, params domain.ListGroupsParams) ([]domain.GroupWithMonitors, error)
Get(ctx context.Context, id string) (domain.GroupWithMonitors, error)
Delete(ctx context.Context, id string) error
DeleteMany(ctx context.Context, id ...string) error
}
type Group struct {
repo GroupRepository
client TWHelpClient
logger *zap.Logger
maxGroupsPerServer int
repo GroupRepository
client TWHelpClient
logger *zap.Logger
maxGroupsPerServer int
maxMonitorsPerGroup int
ennoblementsMu sync.Mutex // ennoblementsMu is used by listEnnoblements
ennoblementsSince map[string]time.Time // ennoblementsSince is used by listEnnoblements
}
func NewGroup(repo GroupRepository, client TWHelpClient, logger *zap.Logger, maxGroupsPerServer int) *Group {
func NewGroup(repo GroupRepository, client TWHelpClient, logger *zap.Logger, maxGroupsPerServer, maxMonitorsPerGroup int) *Group {
return &Group{
repo: repo,
client: client,
logger: logger,
maxGroupsPerServer: maxGroupsPerServer,
repo: repo,
client: client,
logger: logger,
maxGroupsPerServer: maxGroupsPerServer,
maxMonitorsPerGroup: maxMonitorsPerGroup,
}
}
func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.Group, error) {
func (g *Group) Create(ctx context.Context, params domain.CreateGroupParams) (domain.GroupWithMonitors, error) {
groups, err := g.repo.List(ctx, domain.ListGroupsParams{
ServerIDs: []string{params.ServerID()},
})
if err != nil {
return domain.Group{}, fmt.Errorf("GroupRepository.List: %w", err)
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.List: %w", err)
}
if len(groups) >= g.maxGroupsPerServer {
return domain.Group{}, domain.GroupLimitReachedError{
return domain.GroupWithMonitors{}, domain.GroupLimitReachedError{
Current: len(groups),
Limit: g.maxGroupsPerServer,
}
}
if err = g.checkTWServer(ctx, params.VersionCode(), params.ServerKey()); err != nil {
return domain.Group{}, err
return domain.GroupWithMonitors{}, err
}
group, err := g.repo.Create(ctx, params)
if err != nil {
return domain.Group{}, fmt.Errorf("GroupRepository.Create: %w", err)
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Create: %w", err)
}
return group, nil
@ -68,7 +75,7 @@ func (g *Group) checkTWServer(ctx context.Context, versionCode, serverKey string
server, err := g.client.GetServer(ctx, versionCode, serverKey)
if err != nil {
var apiErr twhelp.APIError
if !errors.As(err, &apiErr) {
if !errors.As(err, &apiErr) || apiErr.Code != twhelp.ErrorCodeEntityNotFound {
return fmt.Errorf("TWHelpClient.GetServer: %w", err)
}
return domain.ServerDoesNotExistError{
@ -87,7 +94,86 @@ func (g *Group) checkTWServer(ctx context.Context, versionCode, serverKey string
return nil
}
func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.Group, error) {
func (g *Group) AddMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) {
// check if group exists
groupBeforeUpdate, err := g.Get(ctx, id, serverID)
if err != nil {
if errors.Is(err, domain.GroupNotFoundError{ID: id}) {
return domain.GroupWithMonitors{}, domain.GroupDoesNotExistError{ID: id}
}
return domain.GroupWithMonitors{}, err
}
// check limit
if len(groupBeforeUpdate.Monitors) >= g.maxMonitorsPerGroup {
return domain.GroupWithMonitors{}, domain.MonitorLimitReachedError{
Current: len(groupBeforeUpdate.Monitors),
Limit: g.maxMonitorsPerGroup,
}
}
tribes, err := g.client.ListTribes(ctx, groupBeforeUpdate.VersionCode, groupBeforeUpdate.ServerKey, twhelp.ListTribesQueryParams{
Limit: 1,
Tags: []string{tribeTag},
Deleted: twhelp.NullBool{
Valid: true,
Bool: false,
},
})
if err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf("TWHelpClient.ListTribes: %w", err)
}
if len(tribes) == 0 {
return domain.GroupWithMonitors{}, domain.TribeDoesNotExistError{
Tag: tribeTag,
}
}
group, err := g.repo.AddMonitor(ctx, groupBeforeUpdate.ID, tribes[0].ID)
if err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.AddMonitor: %w", err)
}
return group, nil
}
func (g *Group) DeleteMonitor(ctx context.Context, id, serverID, tribeTag string) (domain.GroupWithMonitors, error) {
// check if group exists
groupBeforeUpdate, err := g.Get(ctx, id, serverID)
if err != nil {
return domain.GroupWithMonitors{}, err
}
tribes, err := g.client.ListTribes(ctx, groupBeforeUpdate.VersionCode, groupBeforeUpdate.ServerKey, twhelp.ListTribesQueryParams{
Tags: []string{tribeTag},
})
if err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf("TWHelpClient.ListTribes: %w", err)
}
if len(tribes) == 0 {
return domain.GroupWithMonitors{}, domain.TribeNotFoundError{
Tag: tribeTag,
}
}
var ids []string
for _, t := range tribes {
for _, m := range groupBeforeUpdate.Monitors {
if m.TribeID == t.ID {
ids = append(ids, m.ID)
}
}
}
group, err := g.repo.DeleteMonitors(ctx, groupBeforeUpdate.ID, ids...)
if err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.DeleteMonitors: %w", err)
}
return group, nil
}
func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) {
return g.update(ctx, id, serverID, domain.UpdateGroupParams{
ChannelGains: domain.NullString{
String: channel,
@ -96,7 +182,7 @@ func (g *Group) SetChannelGains(ctx context.Context, id, serverID, channel strin
})
}
func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.Group, error) {
func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel string) (domain.GroupWithMonitors, error) {
return g.update(ctx, id, serverID, domain.UpdateGroupParams{
ChannelLosses: domain.NullString{
String: channel,
@ -105,7 +191,7 @@ func (g *Group) SetChannelLosses(ctx context.Context, id, serverID, channel stri
})
}
func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.Group, error) {
func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals bool) (domain.GroupWithMonitors, error) {
return g.update(ctx, id, serverID, domain.UpdateGroupParams{
Internals: domain.NullBool{
Bool: internals,
@ -114,7 +200,7 @@ func (g *Group) SetInternals(ctx context.Context, id, serverID string, internals
})
}
func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.Group, error) {
func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbarians bool) (domain.GroupWithMonitors, error) {
return g.update(ctx, id, serverID, domain.UpdateGroupParams{
Barbarians: domain.NullBool{
Bool: barbarians,
@ -123,37 +209,249 @@ func (g *Group) SetBarbarians(ctx context.Context, id, serverID string, barbaria
})
}
func (g *Group) update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.Group, error) {
group, err := g.repo.Update(ctx, id, serverID, params)
if err != nil {
return domain.Group{}, fmt.Errorf("GroupRepository.Update: %w", err)
func (g *Group) update(ctx context.Context, id, serverID string, params domain.UpdateGroupParams) (domain.GroupWithMonitors, error) {
// check if group exists
if _, err := g.Get(ctx, id, serverID); err != nil {
return domain.GroupWithMonitors{}, err
}
group, err := g.repo.Update(ctx, id, params)
if err != nil {
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Update: %w", err)
}
return group, nil
}
func (g *Group) List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error) {
groups, err := g.repo.List(ctx, params)
func (g *Group) ListServer(ctx context.Context, serverID string) ([]domain.GroupWithMonitors, error) {
groups, err := g.repo.List(ctx, domain.ListGroupsParams{
ServerIDs: []string{serverID},
})
if err != nil {
return nil, fmt.Errorf("GroupRepository.List: %w", err)
}
return groups, nil
}
func (g *Group) Get(ctx context.Context, id, serverID string) (domain.Group, error) {
group, err := g.repo.Get(ctx, id, serverID)
func (g *Group) Get(ctx context.Context, id, serverID string) (domain.GroupWithMonitors, error) {
group, err := g.repo.Get(ctx, id)
if err != nil {
return domain.Group{}, fmt.Errorf("GroupRepository.Get: %w", err)
return domain.GroupWithMonitors{}, fmt.Errorf("GroupRepository.Get: %w", err)
}
if group.ServerID != serverID {
return domain.GroupWithMonitors{}, domain.GroupNotFoundError{
ID: id,
}
}
return group, nil
}
type getTribeResult struct {
index int
monitor domain.Monitor
tribe twhelp.Tribe
err error
}
func (g *Group) GetWithTribes(ctx context.Context, id, serverID string) (domain.GroupWithMonitorsAndTribes, error) {
group, err := g.Get(ctx, id, serverID)
if err != nil {
return domain.GroupWithMonitorsAndTribes{}, err
}
var wg sync.WaitGroup
ch := make(chan getTribeResult)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for i, monitor := range group.Monitors {
wg.Add(1)
go func(i int, monitor domain.Monitor) {
defer wg.Done()
res := getTribeResult{
index: i,
monitor: monitor,
}
res.tribe, res.err = g.client.GetTribeByID(
ctx,
group.VersionCode,
group.ServerKey,
monitor.TribeID,
)
ch <- res
}(i, monitor)
}
go func() {
wg.Wait()
close(ch)
}()
var firstErr error
monitors := make([]domain.MonitorWithTribe, len(group.Monitors))
for res := range ch {
if res.err != nil && firstErr == nil {
firstErr = fmt.Errorf("couldn't load tribe (monitorID=%s): %w", res.monitor.ID, res.err)
cancel()
continue
}
if res.err != nil {
continue
}
monitors[res.index] = domain.MonitorWithTribe{
Monitor: res.monitor,
Tribe: domain.TribeMeta{
ID: res.tribe.ID,
Name: res.tribe.Name,
Tag: res.tribe.Tag,
ProfileURL: res.tribe.ProfileURL,
},
}
}
if firstErr != nil {
return domain.GroupWithMonitorsAndTribes{}, firstErr
}
return domain.GroupWithMonitorsAndTribes{
Group: group.Group,
Monitors: monitors,
}, nil
}
func (g *Group) Delete(ctx context.Context, id, serverID string) error {
if err := g.repo.Delete(ctx, id, serverID); err != nil {
// check if group exists
if _, err := g.Get(ctx, id, serverID); err != nil {
return err
}
if err := g.repo.Delete(ctx, id); err != nil {
return fmt.Errorf("GroupRepository.Delete: %w", err)
}
return nil
}
func (g *Group) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) {
groups, err := g.repo.List(ctx, domain.ListGroupsParams{
EnabledNotifications: domain.NullBool{
Bool: true,
Valid: true,
},
})
if err != nil {
return nil, fmt.Errorf("GroupRepository.List: %w", err)
}
res, err := g.listEnnoblements(ctx, groups)
if err != nil {
return nil, err
}
return ennoblementNotificationBuilder{
groups: groups,
ennoblements: res,
}.build(), nil
}
type listEnnoblementsSingleResult struct {
versionCode string
serverKey string
ennoblements []twhelp.Ennoblement
}
type listEnnoblementsResult []listEnnoblementsSingleResult
func (r listEnnoblementsResult) find(versionCode, serverKey string) []twhelp.Ennoblement {
for _, res := range r {
if res.serverKey == serverKey && res.versionCode == versionCode {
return res.ennoblements
}
}
return nil
}
func (g *Group) listEnnoblements(ctx context.Context, groups []domain.GroupWithMonitors) (listEnnoblementsResult, error) {
g.ennoblementsMu.Lock()
defer g.ennoblementsMu.Unlock()
var wg sync.WaitGroup
ch := make(chan listEnnoblementsSingleResult)
ennoblementsSince := g.ennoblementsSince
skip := make(map[string]struct{}, len(ennoblementsSince))
for _, group := range groups {
key := g.buildEnnoblementsSinceKey(group.VersionCode, group.ServerKey)
if _, ok := skip[key]; ok {
continue
}
skip[key] = struct{}{}
since := ennoblementsSince[key]
if since.IsZero() {
since = time.Now().Add(-1 * time.Minute)
}
wg.Add(1)
go func(group domain.GroupWithMonitors, since time.Time) {
defer wg.Done()
ennoblements, err := g.client.ListEnnoblements(
ctx,
group.VersionCode,
group.ServerKey,
twhelp.ListEnnoblementsQueryParams{
Since: since,
Sort: []twhelp.ListEnnoblementsSort{twhelp.ListEnnoblementsSortCreatedAtASC},
},
)
if err != nil {
g.logger.Warn(
"couldn't list ennoblements",
zap.String("versionCode", group.VersionCode),
zap.String("serverKey", group.ServerKey),
zap.Error(err),
)
return
}
ch <- listEnnoblementsSingleResult{
versionCode: group.VersionCode,
serverKey: group.ServerKey,
ennoblements: ennoblements,
}
}(group, since)
}
go func() {
wg.Wait()
close(ch)
}()
// reinitialize ennoblementsSince
g.ennoblementsSince = make(map[string]time.Time)
results := make(listEnnoblementsResult, 0, len(skip))
for res := range ch {
key := g.buildEnnoblementsSinceKey(res.versionCode, res.serverKey)
if l := len(res.ennoblements); l > 0 {
g.ennoblementsSince[key] = res.ennoblements[l-1].CreatedAt.Add(time.Second)
} else {
g.ennoblementsSince[key] = ennoblementsSince[key]
}
results = append(results, res)
}
return results, nil
}
func (g *Group) buildEnnoblementsSinceKey(versionCode, serverKey string) string {
return versionCode + ":" + serverKey
}
func (g *Group) CleanUp(ctx context.Context) error {
if err := g.deleteAllWithDisabledNotifications(ctx); err != nil {
return err
@ -175,7 +473,7 @@ func (g *Group) deleteAllWithDisabledNotifications(ctx context.Context) error {
CreatedAtLTE: time.Now().Add(-24 * time.Hour),
})
if err != nil {
return fmt.Errorf("GroupRepository.List: %w", err)
return fmt.Errorf("couldn't find groups with disabled notifications: %w", err)
}
ids := make([]string, 0, len(groups))
@ -184,7 +482,7 @@ func (g *Group) deleteAllWithDisabledNotifications(ctx context.Context) error {
}
if err = g.repo.DeleteMany(ctx, ids...); err != nil {
return fmt.Errorf("GroupRepository.DeleteMany: %w", err)
return fmt.Errorf("couldn't delete groups with disabled notifications: %w", err)
}
return nil
@ -204,7 +502,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error {
},
})
if err != nil {
g.logger.Warn("failed to fetch closed servers", zap.Error(err), zap.String("versionCode", v.Code))
g.logger.Warn("couldn't list closed servers", zap.Error(err), zap.String("versionCode", v.Code))
continue
}
@ -225,7 +523,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error {
groups, err := g.repo.List(ctx, params)
if err != nil {
g.logger.Warn("failed to list groups", zap.Error(err), zap.String("versionCode", v.Code))
g.logger.Warn("couldn't list groups", zap.Error(err), zap.String("versionCode", v.Code))
continue
}
@ -239,11 +537,7 @@ func (g *Group) deleteAllWithClosedTWServers(ctx context.Context) error {
}
if err = g.repo.DeleteMany(ctx, ids...); err != nil {
g.logger.Warn(
"failed to delete groups",
zap.Error(err),
zap.String("versionCode", v.Code),
)
g.logger.Warn("couldn't delete groups", zap.Error(err), zap.String("versionCode", v.Code))
continue
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,474 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"sync"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
"go.uber.org/zap"
)
//counterfeiter:generate -o internal/mock/monitor_repository.gen.go . MonitorRepository
type MonitorRepository interface {
Create(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error)
List(ctx context.Context, groupID string) ([]domain.Monitor, error)
Get(ctx context.Context, id string) (domain.Monitor, error)
Delete(ctx context.Context, id string) error
}
//counterfeiter:generate -o internal/mock/group_reader.gen.go . GroupReader
type GroupReader interface {
Get(ctx context.Context, id, serverID string) (domain.Group, error)
List(ctx context.Context, params domain.ListGroupsParams) ([]domain.Group, error)
}
type Monitor struct {
repo MonitorRepository
client TWHelpClient
groupSvc GroupReader
maxMonitorsPerGroup int
logger *zap.Logger
ennoblementsMu sync.Mutex // ennoblementsMu is used by Monitor.fetchEnnoblements
ennoblementsSince map[string]time.Time // ennoblementsSince is used by Monitor.fetchEnnoblements
}
func NewMonitor(
repo MonitorRepository,
groupSvc GroupReader,
client TWHelpClient,
logger *zap.Logger,
maxMonitorsPerGroup int,
) *Monitor {
return &Monitor{
repo: repo,
client: client,
groupSvc: groupSvc,
logger: logger,
maxMonitorsPerGroup: maxMonitorsPerGroup,
ennoblementsSince: make(map[string]time.Time),
}
}
func (m *Monitor) Create(ctx context.Context, groupID, serverID, tribeTag string) (domain.Monitor, error) {
// check if group exists
group, err := m.groupSvc.Get(ctx, groupID, serverID)
if err != nil {
if errors.Is(err, domain.GroupNotFoundError{ID: groupID}) {
return domain.Monitor{}, domain.GroupDoesNotExistError{ID: groupID}
}
return domain.Monitor{}, fmt.Errorf("GroupService.Get: %w", err)
}
// check limit
monitors, err := m.repo.List(ctx, group.ID)
if err != nil {
return domain.Monitor{}, fmt.Errorf("MonitorRepository.List: %w", err)
}
if len(monitors) >= m.maxMonitorsPerGroup {
return domain.Monitor{}, domain.MonitorLimitReachedError{
Current: len(monitors),
Limit: m.maxMonitorsPerGroup,
}
}
tribes, err := m.client.ListTribes(ctx, group.VersionCode, group.ServerKey, twhelp.ListTribesQueryParams{
Limit: 1,
Tags: []string{tribeTag},
Deleted: twhelp.NullBool{
Valid: true,
Bool: false,
},
})
if err != nil {
var apiErr twhelp.APIError
if !errors.As(err, &apiErr) {
return domain.Monitor{}, fmt.Errorf("TWHelpClient.GetServer: %w", err)
}
return domain.Monitor{}, domain.TribeDoesNotExistError{
Tag: tribeTag,
}
}
if len(tribes) == 0 {
return domain.Monitor{}, domain.TribeDoesNotExistError{
Tag: tribeTag,
}
}
tribe := tribes[0]
params, err := domain.NewCreateMonitorParams(group.ID, tribe.ID)
if err != nil {
return domain.Monitor{}, fmt.Errorf("domain.NewCreateMonitorParams: %w", err)
}
monitor, err := m.repo.Create(ctx, params)
if err != nil {
return domain.Monitor{}, fmt.Errorf("MonitorRepository.Create: %w", err)
}
return monitor, nil
}
type getTribeResult struct {
index int
monitor domain.Monitor
tribe twhelp.Tribe
err error
}
func (m *Monitor) List(ctx context.Context, groupID, serverID string) ([]domain.MonitorWithTribe, error) {
group, err := m.groupSvc.Get(ctx, groupID, serverID)
if err != nil {
return nil, fmt.Errorf("GroupService.Get: %w", err)
}
monitors, err := m.repo.List(ctx, group.ID)
if err != nil {
return nil, fmt.Errorf("MonitorRepository.Delete: %w", err)
}
var wg sync.WaitGroup
ch := make(chan getTribeResult)
for i, monitor := range monitors {
wg.Add(1)
go func(i int, monitor domain.Monitor) {
res := getTribeResult{
index: i,
monitor: monitor,
}
res.tribe, res.err = m.client.GetTribeByID(
ctx,
group.VersionCode,
group.ServerKey,
monitor.TribeID,
)
ch <- res
}(i, monitor)
}
go func() {
wg.Wait()
close(ch)
}()
var firstErr error
results := make([]domain.MonitorWithTribe, len(monitors))
for res := range ch {
wg.Done()
if res.err != nil && firstErr == nil {
firstErr = fmt.Errorf("couldn't load tribe (monitorID=%s): %w", res.monitor.ID, res.err)
continue
}
results[res.index] = domain.MonitorWithTribe{
Monitor: res.monitor,
Tribe: domain.TribeMeta{
ID: res.tribe.ID,
Name: res.tribe.Name,
Tag: res.tribe.Tag,
ProfileURL: res.tribe.ProfileURL,
},
}
}
if firstErr != nil {
return nil, firstErr
}
return results, nil
}
func (m *Monitor) Delete(ctx context.Context, id, serverID string) error {
monitor, err := m.repo.Get(ctx, id)
if err != nil {
return fmt.Errorf("MonitorRepository.Get: %w", err)
}
_, err = m.groupSvc.Get(ctx, monitor.GroupID, serverID)
if err != nil {
return fmt.Errorf("GroupService.Get: %w", err)
}
if err = m.repo.Delete(ctx, id); err != nil {
return fmt.Errorf("MonitorRepository.Delete: %w", err)
}
return nil
}
func (m *Monitor) Execute(ctx context.Context) ([]domain.EnnoblementNotification, error) {
groups, err := m.groupSvc.List(ctx, domain.ListGroupsParams{
EnabledNotifications: domain.NullBool{
Bool: true,
Valid: true,
},
})
if err != nil {
return nil, fmt.Errorf("GroupService.List: %w", err)
}
res, err := m.fetchEnnoblements(ctx, groups)
if err != nil {
return nil, err
}
//nolint:prealloc
var notifications []domain.EnnoblementNotification
for _, g := range groups {
for _, r := range res {
if g.ServerKey != r.serverKey || g.VersionCode != r.versionCode {
continue
}
ns, err := m.executeGroup(ctx, g, r.ennoblements)
if err != nil {
m.logger.Warn(
"something went wrong while executing group",
zap.String("group", g.ID),
zap.Error(err),
)
continue
}
notifications = append(notifications, ns...)
}
}
return notifications, nil
}
func (m *Monitor) executeGroup(
ctx context.Context,
g domain.Group,
ennoblements []twhelp.Ennoblement,
) ([]domain.EnnoblementNotification, error) {
monitors, err := m.repo.List(ctx, g.ID)
if err != nil {
return nil, fmt.Errorf("MonitorRepository.List: %w", err)
}
//nolint:prealloc
var notifications []domain.EnnoblementNotification
for _, e := range ennoblements {
if canSendEnnoblementNotificationTypeGain(g, e, monitors) {
notifications = append(notifications, domain.EnnoblementNotification{
Type: domain.EnnoblementNotificationTypeGain,
ServerID: g.ServerID,
ChannelID: g.ChannelGains,
Ennoblement: ennoblementToDomainModel(e),
})
}
if canSendEnnoblementNotificationTypeLoss(g, e, monitors) {
notifications = append(notifications, domain.EnnoblementNotification{
Type: domain.EnnoblementNotificationTypeLoss,
ServerID: g.ServerID,
ChannelID: g.ChannelLosses,
Ennoblement: ennoblementToDomainModel(e),
})
}
}
return notifications, nil
}
type listEnnoblementsResult struct {
versionCode string
serverKey string
ennoblements []twhelp.Ennoblement
err error
}
type fetchEnnoblementsResult struct {
versionCode string
serverKey string
ennoblements []twhelp.Ennoblement
}
func (m *Monitor) fetchEnnoblements(ctx context.Context, groups []domain.Group) ([]fetchEnnoblementsResult, error) {
m.ennoblementsMu.Lock()
defer m.ennoblementsMu.Unlock()
var wg sync.WaitGroup
ch := make(chan listEnnoblementsResult)
ennoblementsSince := m.ennoblementsSince
skip := make(map[string]struct{}, len(ennoblementsSince))
for _, g := range groups {
key := g.VersionCode + ":" + g.ServerKey
if _, ok := skip[key]; ok {
continue
}
skip[key] = struct{}{}
since := ennoblementsSince[key]
if since.IsZero() {
since = time.Now().Add(-1 * time.Minute)
}
wg.Add(1)
go func(g domain.Group, since time.Time) {
res := listEnnoblementsResult{
versionCode: g.VersionCode,
serverKey: g.ServerKey,
}
res.ennoblements, res.err = m.client.ListEnnoblements(
ctx,
g.VersionCode,
g.ServerKey,
twhelp.ListEnnoblementsQueryParams{
Since: since,
Sort: []twhelp.ListEnnoblementsSort{twhelp.ListEnnoblementsSortCreatedAtASC},
},
)
ch <- res
}(g, since)
}
go func() {
wg.Wait()
close(ch)
}()
// reinitialize ennoblementsSince
m.ennoblementsSince = make(map[string]time.Time)
//nolint:prealloc
var results []fetchEnnoblementsResult
for res := range ch {
wg.Done()
key := res.versionCode + ":" + res.serverKey
if l := len(res.ennoblements); l > 0 {
m.ennoblementsSince[key] = res.ennoblements[l-1].CreatedAt.Add(time.Second)
} else {
m.ennoblementsSince[key] = ennoblementsSince[key]
}
if res.err != nil {
m.logger.Warn(
"failed to fetch ennoblements",
zap.String("versionCode", res.versionCode),
zap.String("serverKey", res.serverKey),
zap.Error(res.err),
)
continue
}
results = append(results, fetchEnnoblementsResult{
versionCode: res.versionCode,
serverKey: res.serverKey,
ennoblements: res.ennoblements,
})
}
return results, nil
}
func canSendEnnoblementNotificationTypeGain(g domain.Group, e twhelp.Ennoblement, monitors []domain.Monitor) bool {
if g.ChannelGains == "" {
return false
}
if !g.Barbarians && isBarbarian(e) {
return false
}
if !g.Internals && isInternal(e, monitors) {
return false
}
return isGain(e, monitors)
}
func canSendEnnoblementNotificationTypeLoss(g domain.Group, e twhelp.Ennoblement, monitors []domain.Monitor) bool {
if g.ChannelLosses == "" {
return false
}
if isInternal(e, monitors) {
return false
}
return isLoss(e, monitors)
}
func isInternal(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var n, o bool
for _, m := range monitors {
if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID {
n = true
}
if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID {
o = true
}
}
return n && o
}
func isBarbarian(e twhelp.Ennoblement) bool {
return !e.Village.Player.Valid
}
func isGain(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var n bool
for _, m := range monitors {
if m.TribeID == e.NewOwner.Player.Tribe.Tribe.ID {
n = true
break
}
}
return n && e.NewOwner.Player.ID != e.Village.Player.Player.ID
}
func isLoss(e twhelp.Ennoblement, monitors []domain.Monitor) bool {
var o bool
for _, m := range monitors {
if m.TribeID == e.Village.Player.Player.Tribe.Tribe.ID {
o = true
break
}
}
return o && e.NewOwner.Player.ID != e.Village.Player.Player.ID
}
func ennoblementToDomainModel(e twhelp.Ennoblement) domain.Ennoblement {
return domain.Ennoblement{
ID: e.ID,
Village: domain.VillageMeta{
ID: e.Village.ID,
FullName: e.Village.FullName,
ProfileURL: e.Village.ProfileURL,
Player: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.Village.Player.Player.ID,
Name: e.Village.Player.Player.Name,
ProfileURL: e.Village.Player.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe),
Valid: e.Village.Player.Player.Tribe.Valid,
},
},
Valid: e.Village.Player.Valid,
},
},
NewOwner: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.NewOwner.Player.ID,
Name: e.NewOwner.Player.Name,
ProfileURL: e.NewOwner.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe),
Valid: e.NewOwner.Player.Tribe.Valid,
},
},
Valid: e.NewOwner.Valid,
},
CreatedAt: e.CreatedAt,
}
}

View File

@ -1,811 +0,0 @@
package service_test
import (
"context"
"errors"
"testing"
"time"
"gitea.dwysokinski.me/twhelp/dcbot/internal/domain"
"gitea.dwysokinski.me/twhelp/dcbot/internal/service"
"gitea.dwysokinski.me/twhelp/dcbot/internal/service/internal/mock"
"gitea.dwysokinski.me/twhelp/dcbot/internal/twhelp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
)
func TestMonitor_Create(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
repo := &mock.FakeMonitorRepository{}
repo.ListReturns(nil, nil)
repo.CreateCalls(func(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) {
return domain.Monitor{
ID: uuid.NewString(),
TribeID: params.TribeID(),
GroupID: params.GroupID(),
CreatedAt: time.Now(),
}, nil
})
groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{
ID: groupID,
ServerID: serverID,
ChannelGains: "",
ChannelLosses: "",
ServerKey: "pl151",
VersionCode: "pl",
CreatedAt: time.Now(),
}, nil
})
client := &mock.FakeTWHelpClient{}
tribe := twhelp.Tribe{
ID: 150,
Tag: "*TAG*",
Name: uuid.NewString(),
ProfileURL: "https://pl151.plemiona.pl/game.php?screen=info_player&id=150",
DeletedAt: time.Time{},
}
client.ListTribesReturns([]twhelp.Tribe{tribe}, nil)
groupID := uuid.NewString()
monitor, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10).
Create(context.Background(), groupID, uuid.NewString(), tribe.Tag)
assert.NoError(t, err)
assert.NotEmpty(t, monitor.ID)
assert.Equal(t, groupID, monitor.GroupID)
assert.Equal(t, tribe.ID, monitor.TribeID)
assert.NotEmpty(t, monitor.CreatedAt)
})
t.Run("ERR: group doesn't exist", func(t *testing.T) {
t.Parallel()
groupID := uuid.NewString()
groupSvc := &mock.FakeGroupReader{}
groupSvc.GetReturns(domain.Group{}, domain.GroupNotFoundError{ID: groupID})
monitor, err := service.NewMonitor(nil, groupSvc, nil, zap.NewNop(), 10).
Create(context.Background(), groupID, uuid.NewString(), "tag")
assert.ErrorIs(t, err, domain.GroupDoesNotExistError{
ID: groupID,
})
assert.Zero(t, monitor)
})
t.Run("ERR: monitor limit has been reached", func(t *testing.T) {
t.Parallel()
const maxMonitorsPerGroup = 10
repo := &mock.FakeMonitorRepository{}
repo.ListReturns(make([]domain.Monitor, maxMonitorsPerGroup), nil)
groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{
ID: groupID,
ServerID: serverID,
ChannelGains: "",
ChannelLosses: "",
ServerKey: "pl151",
VersionCode: "pl",
CreatedAt: time.Now(),
}, nil
})
monitor, err := service.NewMonitor(repo, groupSvc, nil, zap.NewNop(), maxMonitorsPerGroup).
Create(context.Background(), uuid.NewString(), uuid.NewString(), "TAG")
assert.ErrorIs(t, err, domain.MonitorLimitReachedError{
Current: maxMonitorsPerGroup,
Limit: maxMonitorsPerGroup,
})
assert.Zero(t, monitor)
})
t.Run("ERR: tribe doesn't exist", func(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
}{
{
name: "err=null",
},
{
name: "err!=null",
err: twhelp.APIError{
Code: twhelp.ErrorCodeInternalServerError,
Message: "internal server error",
},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
repo := &mock.FakeMonitorRepository{}
repo.ListReturns(nil, nil)
repo.CreateCalls(func(ctx context.Context, params domain.CreateMonitorParams) (domain.Monitor, error) {
return domain.Monitor{
ID: uuid.NewString(),
TribeID: params.TribeID(),
GroupID: params.GroupID(),
CreatedAt: time.Now(),
}, nil
})
groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{
ID: groupID,
ServerID: serverID,
ChannelGains: "",
ChannelLosses: "",
ServerKey: "pl151",
VersionCode: "pl",
CreatedAt: time.Now(),
}, nil
})
client := &mock.FakeTWHelpClient{}
client.ListTribesReturns(nil, tt.err)
tag := "TAG"
monitor, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10).
Create(context.Background(), uuid.NewString(), uuid.NewString(), tag)
assert.ErrorIs(t, err, domain.TribeDoesNotExistError{
Tag: tag,
})
assert.Zero(t, monitor)
})
}
})
}
func TestMonitor_List(t *testing.T) {
t.Parallel()
groupID := uuid.NewString()
groupSvc := &mock.FakeGroupReader{}
groupSvc.GetCalls(func(ctx context.Context, groupID, serverID string) (domain.Group, error) {
return domain.Group{
ID: groupID,
ServerID: serverID,
ChannelGains: "",
ChannelLosses: "",
ServerKey: "pl151",
VersionCode: "pl",
CreatedAt: time.Now(),
}, nil
})
repo := &mock.FakeMonitorRepository{}
monitors := []domain.Monitor{
{
ID: uuid.NewString(),
TribeID: 124,
GroupID: groupID,
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
TribeID: 125,
GroupID: groupID,
CreatedAt: time.Now(),
},
}
repo.ListReturns(monitors, nil)
client := &mock.FakeTWHelpClient{}
client.GetTribeByIDCalls(func(ctx context.Context, _ string, _ string, id int64) (twhelp.Tribe, error) {
return twhelp.Tribe{
ID: id,
Tag: uuid.NewString(),
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
DeletedAt: time.Time{},
}, nil
})
res, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10).
List(context.Background(), groupID, uuid.NewString())
assert.NoError(t, err)
assert.Len(t, res, len(monitors))
for i, m := range monitors {
assert.Equal(t, m, res[i].Monitor)
assert.Equal(t, m.TribeID, res[i].Tribe.ID)
}
}
func TestMonitor_Execute(t *testing.T) {
t.Parallel()
client := &mock.FakeTWHelpClient{}
tribes := map[string][]twhelp.TribeMeta{
"pl:pl181": {
{
ID: 1,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 2,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 3,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"en:en130": {
{
ID: 100,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 101,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"pl:pl180": {
{
ID: 200,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"de:de200": {
{
ID: 300,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 301,
Name: uuid.NewString(),
Tag: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
}
players := map[string][]twhelp.PlayerMeta{
"pl:pl181": {
{
ID: 1,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["pl:pl181"][0],
Valid: true,
},
},
{
ID: 2,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["pl:pl181"][1],
Valid: true,
},
},
{
ID: 3,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["pl:pl181"][2],
Valid: true,
},
},
{
ID: 4,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{},
},
},
"en:en130": {
{
ID: 100,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["en:en130"][0],
Valid: true,
},
},
{
ID: 101,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["en:en130"][1],
Valid: true,
},
},
{
ID: 102,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{},
},
},
"pl:pl180": {
{
ID: 200,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["pl:pl180"][0],
Valid: true,
},
},
},
"de:de200": {
{
ID: 300,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["de:de200"][0],
Valid: true,
},
},
{
ID: 301,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["de:de200"][0],
Valid: true,
},
},
{
ID: 302,
Name: uuid.NewString(),
ProfileURL: uuid.NewString(),
Tribe: twhelp.NullTribeMeta{
Tribe: tribes["de:de200"][1],
Valid: true,
},
},
},
}
villages := map[string][]twhelp.VillageMeta{
"pl:pl181": {
{
ID: 1,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][1],
Valid: true,
},
},
{
ID: 2,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
},
{
ID: 3,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][2],
Valid: true,
},
},
{
ID: 4,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 5,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
},
},
"en:en130": {
{
ID: 100,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 101,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["en:en130"][0],
Valid: true,
},
},
{
ID: 102,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["en:en130"][2],
Valid: true,
},
},
},
"pl:pl180": {
{
ID: 200,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
},
"de:de200": {
{
ID: 300,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
},
{
ID: 301,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["de:de200"][1],
Valid: true,
},
},
{
ID: 302,
FullName: uuid.NewString(),
ProfileURL: uuid.NewString(),
Player: twhelp.NullPlayerMeta{
Player: players["de:de200"][2],
Valid: true,
},
},
},
}
ennoblements := map[string][]twhelp.Ennoblement{
"pl:pl181": {
{
ID: 1,
Village: villages["pl:pl181"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 2, // self conquer, should be skipped
Village: villages["pl:pl181"][1],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-4 * time.Minute),
},
{
ID: 3, // internal, should be skipped (internals disabled)
Village: villages["pl:pl181"][2],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-3 * time.Minute),
},
{
ID: 4, // barbarian, shouldn't be skipped (barbarians enabled)
Village: villages["pl:pl181"][3],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 5, // disabled notifications about gains, should be skipped
Village: villages["pl:pl181"][4],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][1],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 6, // disabled notifications about losses, should be skipped
Village: villages["pl:pl181"][4],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl181"][3],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
},
"en:en130": {
{
ID: 100, // no monitor for these tribes, should be skipped
Village: villages["en:en130"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 101, // no monitor for these tribes, should be skipped
Village: villages["en:en130"][1],
NewOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][2],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 102,
Village: villages["en:en130"][2],
NewOwner: twhelp.NullPlayerMeta{
Player: players["en:en130"][1],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
},
"pl:pl180": {
{
ID: 200, // api error, should be skipped
Village: villages["pl:pl180"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["pl:pl180"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
},
"de:de200": {
{
ID: 300, // barbarian, should be skipped (barbarians disabled)
Village: villages["de:de200"][0],
NewOwner: twhelp.NullPlayerMeta{
Player: players["de:de200"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 301, // internal, shouldn't be skipped (internals enabled)
Village: villages["de:de200"][1],
NewOwner: twhelp.NullPlayerMeta{
Player: players["de:de200"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
{
ID: 302, // internal, shouldn't be skipped (internals enabled)
Village: villages["de:de200"][2],
NewOwner: twhelp.NullPlayerMeta{
Player: players["de:de200"][0],
Valid: true,
},
CreatedAt: time.Now().Add(-5 * time.Minute),
},
},
}
client.ListEnnoblementsCalls(
func(
ctx context.Context,
version string,
server string,
_ twhelp.ListEnnoblementsQueryParams,
) ([]twhelp.Ennoblement, error) {
if version == "pl" && server == "pl180" {
return nil, errors.New("random error")
}
return ennoblements[version+":"+server], nil
},
)
groupSvc := &mock.FakeGroupReader{}
groups := []domain.Group{
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: uuid.NewString(),
ChannelLosses: "",
Barbarians: true,
ServerKey: "pl181",
VersionCode: "pl",
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: "",
ChannelLosses: uuid.NewString(),
ServerKey: "pl181",
VersionCode: "pl",
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: uuid.NewString(),
ChannelLosses: uuid.NewString(),
ServerKey: "en130",
VersionCode: "en",
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: uuid.NewString(),
ChannelLosses: uuid.NewString(),
Barbarians: true,
ServerKey: "pl180",
VersionCode: "pl",
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
ServerID: uuid.NewString(),
ChannelGains: uuid.NewString(),
ChannelLosses: uuid.NewString(),
Barbarians: false,
Internals: true,
ServerKey: "de200",
VersionCode: "de",
CreatedAt: time.Now(),
},
}
groupSvc.ListReturns(groups, nil)
repo := &mock.FakeMonitorRepository{}
monitors := map[string][]domain.Monitor{
groups[0].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][0].ID,
GroupID: groups[0].ID,
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][2].ID,
GroupID: groups[0].ID,
CreatedAt: time.Now(),
},
},
groups[1].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl181"][1].ID,
GroupID: groups[1].ID,
CreatedAt: time.Now(),
},
},
groups[2].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["en:en130"][1].ID,
GroupID: groups[2].ID,
CreatedAt: time.Now(),
},
},
groups[3].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["pl:pl180"][0].ID,
GroupID: groups[3].ID,
CreatedAt: time.Now(),
},
},
groups[4].ID: {
{
ID: uuid.NewString(),
TribeID: tribes["de:de200"][0].ID,
GroupID: groups[4].ID,
CreatedAt: time.Now(),
},
{
ID: uuid.NewString(),
TribeID: tribes["de:de200"][1].ID,
GroupID: groups[4].ID,
CreatedAt: time.Now(),
},
},
}
repo.ListCalls(func(ctx context.Context, groupID string) ([]domain.Monitor, error) {
return monitors[groupID], nil
})
notifications, err := service.NewMonitor(repo, groupSvc, client, zap.NewNop(), 10).
Execute(context.Background())
assert.NoError(t, err)
expectedNotifications := []domain.EnnoblementNotification{
newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][0]),
newNotification(domain.EnnoblementNotificationTypeGain, groups[0].ServerID, groups[0].ChannelGains, ennoblements["pl:pl181"][3]),
newNotification(domain.EnnoblementNotificationTypeLoss, groups[1].ServerID, groups[1].ChannelLosses, ennoblements["pl:pl181"][0]),
newNotification(domain.EnnoblementNotificationTypeGain, groups[2].ServerID, groups[2].ChannelGains, ennoblements["en:en130"][2]),
newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][1]),
newNotification(domain.EnnoblementNotificationTypeGain, groups[4].ServerID, groups[4].ChannelGains, ennoblements["de:de200"][2]),
}
assert.Len(t, notifications, len(expectedNotifications))
for _, n := range expectedNotifications {
assert.Contains(t, notifications, n)
}
}
func newNotification(typ domain.EnnoblementNotificationType, serverID, channelID string, e twhelp.Ennoblement) domain.EnnoblementNotification {
return domain.EnnoblementNotification{
Type: typ,
ServerID: serverID,
ChannelID: channelID,
Ennoblement: domain.Ennoblement{
ID: e.ID,
Village: domain.VillageMeta{
ID: e.Village.ID,
FullName: e.Village.FullName,
ProfileURL: e.Village.ProfileURL,
Player: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.Village.Player.Player.ID,
Name: e.Village.Player.Player.Name,
ProfileURL: e.Village.Player.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.Village.Player.Player.Tribe.Tribe),
Valid: e.Village.Player.Player.Tribe.Valid,
},
},
Valid: e.Village.Player.Valid,
},
},
NewOwner: domain.NullPlayerMeta{
Player: domain.PlayerMeta{
ID: e.NewOwner.Player.ID,
Name: e.NewOwner.Player.Name,
ProfileURL: e.NewOwner.Player.ProfileURL,
Tribe: domain.NullTribeMeta{
Tribe: domain.TribeMeta(e.NewOwner.Player.Tribe.Tribe),
Valid: e.NewOwner.Player.Tribe.Valid,
},
},
Valid: e.NewOwner.Valid,
},
CreatedAt: e.CreatedAt,
},
}
}

View File

@ -9,6 +9,8 @@ import (
"net/url"
"strconv"
"time"
"golang.org/x/time/rate"
)
const (
@ -24,9 +26,10 @@ const (
)
type Client struct {
userAgent string
client *http.Client
baseURL *url.URL
userAgent string
client *http.Client
baseURL *url.URL
rateLimiter *rate.Limiter
}
type ClientOption func(c *Client)
@ -43,6 +46,12 @@ func WithUserAgent(ua string) ClientOption {
}
}
func WithRateLimiter(rl *rate.Limiter) ClientOption {
return func(c *Client) {
c.rateLimiter = rl
}
}
func NewClient(baseURL *url.URL, opts ...ClientOption) *Client {
c := &Client{
baseURL: baseURL,
@ -216,6 +225,13 @@ func (c *Client) getJSON(ctx context.Context, urlStr string, v any) error {
// headers
req.Header.Set("User-Agent", c.userAgent)
// rate limiter
if c.rateLimiter != nil {
if err = c.rateLimiter.Wait(ctx); err != nil {
return err
}
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("client.Do: %w", err)

View File

@ -34,8 +34,10 @@ spec:
key: token
- name: TWHELP_URL
value: "https://tribalwarshelp.com"
- name: TWHELP_RATE_LIMITER_ENABLED
value: "false"
- name: BOT_MAX_GROUPS_PER_SERVER
value: "10"
value: "5"
- name: BOT_MAX_MONITORS_PER_GROUP
value: "10"
livenessProbe:
@ -49,4 +51,4 @@ spec:
memory: 100Mi
limits:
cpu: 250m
memory: 300Mi
memory: 300Mi

39
k8s/overlays/dev/bot.yml Normal file
View File

@ -0,0 +1,39 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: twhelp-dcbot-deployment
spec:
template:
spec:
containers:
- name: twhelp-dcbot
image: dcbot
env:
- name: APP_MODE
value: development
- name: DB_DSN
valueFrom:
secretKeyRef:
name: twhelp-dcbot-secret
key: db-dsn
- name: DB_MAX_OPEN_CONNECTIONS
value: "10"
- name: DB_MAX_IDLE_CONNECTIONS
value: "3"
- name: BOT_TOKEN
valueFrom:
secretKeyRef:
name: twhelp-dcbot-secret
key: token
- name: TWHELP_URL
value: "https://tribalwarshelp.com"
- name: TWHELP_RATE_LIMITER_ENABLED
value: "true"
- name: TWHELP_RATE_LIMITER_MAX_REQUESTS_PER_SECOND
value: "10"
- name: TWHELP_RATE_LIMITER_REQUEST_BURST
value: "5"
- name: BOT_MAX_GROUPS_PER_SERVER
value: "5"
- name: BOT_MAX_MONITORS_PER_GROUP
value: "10"

View File

@ -3,4 +3,6 @@ kind: Kustomization
nameSuffix: -dev
resources:
- secret.yml
- ../../base
- ../../base
patchesStrategicMerge:
- bot.yml