mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-30 02:40:33 +00:00
366 lines
8.5 KiB
Go
366 lines
8.5 KiB
Go
package loyalty
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sr.ht/~ashkeel/containers/sync"
|
|
"git.sr.ht/~ashkeel/strimertul/database"
|
|
"git.sr.ht/~ashkeel/strimertul/utils"
|
|
jsoniter "github.com/json-iterator/go"
|
|
)
|
|
|
|
var json = jsoniter.ConfigFastest
|
|
|
|
var (
|
|
ErrRedeemNotFound = errors.New("redeem not found")
|
|
ErrRedeemInCooldown = errors.New("redeem is on cooldown")
|
|
ErrGoalNotFound = errors.New("goal not found")
|
|
ErrGoalAlreadyReached = errors.New("goal already reached")
|
|
)
|
|
|
|
type Manager struct {
|
|
points *sync.Map[string, PointsEntry]
|
|
Config *sync.RWSync[Config]
|
|
Rewards *sync.Slice[Reward]
|
|
Goals *sync.Slice[Goal]
|
|
Queue *sync.Slice[Redeem]
|
|
db database.Database
|
|
cooldowns map[string]time.Time
|
|
banlist map[string]bool
|
|
ctx context.Context
|
|
cancelFn context.CancelFunc
|
|
cancelSub database.CancelFunc
|
|
restartTwitchHandler chan struct{}
|
|
}
|
|
|
|
func NewManager(db database.Database) (*Manager, error) {
|
|
ctx, cancelFn := context.WithCancel(context.Background())
|
|
loyalty := &Manager{
|
|
Config: sync.NewRWSync(Config{Enabled: false}),
|
|
Rewards: sync.NewSlice[Reward](),
|
|
Goals: sync.NewSlice[Goal](),
|
|
Queue: sync.NewSlice[Redeem](),
|
|
|
|
db: db,
|
|
points: sync.NewMap[string, PointsEntry](),
|
|
cooldowns: make(map[string]time.Time),
|
|
banlist: make(map[string]bool),
|
|
ctx: ctx,
|
|
cancelFn: cancelFn,
|
|
restartTwitchHandler: make(chan struct{}),
|
|
}
|
|
// Get data from DB
|
|
var config Config
|
|
if err := db.GetJSON(ConfigKey, &config); err == nil {
|
|
loyalty.Config.Set(config)
|
|
} else {
|
|
if !errors.Is(err, database.ErrEmptyKey) {
|
|
return nil, fmt.Errorf("could not retrieve loyalty config: %w", err)
|
|
}
|
|
}
|
|
|
|
// Retrieve configs
|
|
var rewards []Reward
|
|
if err := db.GetJSON(RewardsKey, &rewards); err == nil {
|
|
loyalty.Rewards.Set(rewards)
|
|
} else {
|
|
if !errors.Is(err, database.ErrEmptyKey) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var goals []Goal
|
|
if err := db.GetJSON(GoalsKey, &goals); err == nil {
|
|
loyalty.Goals.Set(goals)
|
|
} else {
|
|
if !errors.Is(err, database.ErrEmptyKey) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var queue []Redeem
|
|
if err := db.GetJSON(QueueKey, &queue); err == nil {
|
|
loyalty.Queue.Set(queue)
|
|
} else {
|
|
if !errors.Is(err, database.ErrEmptyKey) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Retrieve user points
|
|
points, err := db.GetAll(PointsPrefix)
|
|
if err != nil {
|
|
if !errors.Is(err, database.ErrEmptyKey) {
|
|
return nil, err
|
|
}
|
|
points = make(map[string]string)
|
|
}
|
|
|
|
for k, v := range points {
|
|
var entry PointsEntry
|
|
err := json.UnmarshalFromString(v, &entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
loyalty.points.SetKey(k[len(PointsPrefix):], entry)
|
|
}
|
|
|
|
// SubscribePrefix for changes
|
|
loyalty.cancelSub, err = db.SubscribePrefix(loyalty.update, "loyalty/")
|
|
if err != nil {
|
|
slog.Error("Could not setup loyalty reload subscription", "error", err)
|
|
}
|
|
|
|
loyalty.SetBanList(config.BanList)
|
|
|
|
return loyalty, nil
|
|
}
|
|
|
|
func (m *Manager) Close() error {
|
|
// Stop subscription
|
|
if m.cancelSub != nil {
|
|
m.cancelSub()
|
|
}
|
|
|
|
// Send cancellation
|
|
m.cancelFn()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) update(key, value string) {
|
|
var err error
|
|
// Check for config changes/RPC
|
|
switch key {
|
|
case ConfigKey:
|
|
err = utils.LoadJSONToWrapped[Config](value, m.Config)
|
|
if err == nil {
|
|
m.SetBanList(m.Config.Get().BanList)
|
|
m.restartTwitchHandler <- struct{}{}
|
|
}
|
|
case GoalsKey:
|
|
err = utils.LoadJSONToWrapped[[]Goal](value, m.Goals)
|
|
case RewardsKey:
|
|
err = utils.LoadJSONToWrapped[[]Reward](value, m.Rewards)
|
|
case QueueKey:
|
|
err = utils.LoadJSONToWrapped[[]Redeem](value, m.Queue)
|
|
case CreateRedeemRPC:
|
|
var redeem Redeem
|
|
err = json.UnmarshalFromString(value, &redeem)
|
|
if err == nil {
|
|
err = m.AddRedeem(redeem)
|
|
}
|
|
case RemoveRedeemRPC:
|
|
var redeem Redeem
|
|
err = json.UnmarshalFromString(value, &redeem)
|
|
if err == nil {
|
|
err = m.RemoveRedeem(redeem)
|
|
}
|
|
default:
|
|
// Check for prefix changes
|
|
switch {
|
|
// User point changed
|
|
case strings.HasPrefix(key, PointsPrefix):
|
|
var entry PointsEntry
|
|
err = json.UnmarshalFromString(value, &entry)
|
|
user := key[len(PointsPrefix):]
|
|
m.points.SetKey(user, entry)
|
|
}
|
|
}
|
|
if err != nil {
|
|
slog.Error("Subscribe error: invalid JSON received on key", slog.String("key", key), "error", err)
|
|
} else {
|
|
slog.Debug("Updated key", slog.String("key", key))
|
|
}
|
|
}
|
|
|
|
func (m *Manager) GetPoints(user string) int64 {
|
|
points, ok := m.points.GetKey(user)
|
|
if ok {
|
|
return points.Points
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (m *Manager) setPoints(user string, points int64) error {
|
|
entry := PointsEntry{
|
|
Points: points,
|
|
}
|
|
m.points.SetKey(user, entry)
|
|
return m.db.PutJSON(PointsPrefix+user, entry)
|
|
}
|
|
|
|
func (m *Manager) GivePoints(pointsToGive map[string]int64) error {
|
|
// Add points to each user
|
|
for user, points := range pointsToGive {
|
|
balance := m.GetPoints(user)
|
|
if err := m.setPoints(user, balance+points); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) TakePoints(pointsToTake map[string]int64) error {
|
|
// Add points to each user
|
|
for user, points := range pointsToTake {
|
|
balance := m.GetPoints(user)
|
|
if err := m.setPoints(user, balance-points); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) saveQueue() error {
|
|
return m.db.PutJSON(QueueKey, m.Queue.Get())
|
|
}
|
|
|
|
func (m *Manager) GetRewardCooldown(rewardID string) time.Time {
|
|
cooldown, ok := m.cooldowns[rewardID]
|
|
if !ok {
|
|
// Return zero time for a reward with no cooldown
|
|
return time.Time{}
|
|
}
|
|
|
|
return cooldown
|
|
}
|
|
|
|
func (m *Manager) AddRedeem(redeem Redeem) error {
|
|
// Add to local list
|
|
m.Queue.Set(append(m.Queue.Get(), redeem))
|
|
|
|
// Send redeem event
|
|
if err := m.db.PutJSON(RedeemEvent, redeem); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add cooldown if applicable
|
|
if redeem.Reward.Cooldown > 0 {
|
|
m.cooldowns[redeem.Reward.ID] = time.Now().Add(time.Second * time.Duration(redeem.Reward.Cooldown))
|
|
}
|
|
|
|
// Save points
|
|
return m.saveQueue()
|
|
}
|
|
|
|
func (m *Manager) PerformRedeem(redeem Redeem) error {
|
|
// Check cooldown
|
|
if time.Now().Before(m.GetRewardCooldown(redeem.Reward.ID)) {
|
|
return ErrRedeemInCooldown
|
|
}
|
|
|
|
// Add redeem
|
|
err := m.AddRedeem(redeem)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Remove points from user
|
|
return m.TakePoints(map[string]int64{redeem.Username: redeem.Reward.Price})
|
|
}
|
|
|
|
func (m *Manager) RemoveRedeem(redeem Redeem) error {
|
|
queue := m.Queue.Get()
|
|
for index, queued := range queue {
|
|
if queued.When == redeem.When && queued.Username == redeem.Username && queued.Reward.ID == redeem.Reward.ID {
|
|
// Remove redemption from list
|
|
m.Queue.Set(append(queue[:index], queue[index+1:]...))
|
|
|
|
// Save points
|
|
return m.saveQueue()
|
|
}
|
|
}
|
|
|
|
return ErrRedeemNotFound
|
|
}
|
|
|
|
func (m *Manager) SaveGoals() error {
|
|
return m.db.PutJSON(GoalsKey, m.Goals.Get())
|
|
}
|
|
|
|
func (m *Manager) ContributeGoal(goal Goal, user string, points int64) error {
|
|
goals := m.Goals.Get()
|
|
for i, savedGoal := range goals {
|
|
if savedGoal.ID != goal.ID {
|
|
continue
|
|
}
|
|
goals[i].Contributed += points
|
|
goals[i].Contributors[user] += points
|
|
m.Goals.Set(goals)
|
|
return m.SaveGoals()
|
|
}
|
|
return ErrGoalNotFound
|
|
}
|
|
|
|
func (m *Manager) PerformContribution(goal Goal, user string, points int64) (int64, error) {
|
|
// Get user balance
|
|
balance := m.GetPoints(user)
|
|
|
|
// If user specified more points than they have, pick the maximum possible
|
|
if points > balance {
|
|
points = balance
|
|
}
|
|
|
|
// Check if goal was reached already
|
|
if goal.Contributed >= goal.TotalGoal {
|
|
return 0, ErrGoalAlreadyReached
|
|
}
|
|
|
|
// If remaining points are lower than what user is contributing, only take what's needed
|
|
remaining := goal.TotalGoal - goal.Contributed
|
|
if points > remaining {
|
|
points = remaining
|
|
}
|
|
|
|
// Remove points from user
|
|
if err := m.TakePoints(map[string]int64{user: points}); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Add points to goal
|
|
return points, m.ContributeGoal(goal, user, points)
|
|
}
|
|
|
|
func (m *Manager) GetReward(id string) Reward {
|
|
for _, reward := range m.Rewards.Get() {
|
|
if reward.ID == id {
|
|
return reward
|
|
}
|
|
}
|
|
return Reward{}
|
|
}
|
|
|
|
func (m *Manager) GetGoal(id string) Goal {
|
|
for _, goal := range m.Goals.Get() {
|
|
if goal.ID == id {
|
|
return goal
|
|
}
|
|
}
|
|
return Goal{}
|
|
}
|
|
|
|
func (m *Manager) Equals(c utils.Comparable) bool {
|
|
if manager, ok := c.(*Manager); ok {
|
|
return m == manager
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Manager) SetBanList(banned []string) {
|
|
m.banlist = make(map[string]bool)
|
|
for _, usr := range banned {
|
|
m.banlist[usr] = true
|
|
}
|
|
}
|
|
|
|
func (m *Manager) IsBanned(user string) bool {
|
|
banned, ok := m.banlist[user]
|
|
return ok && banned
|
|
}
|