package loyalty import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "time" "git.sr.ht/~ashkeel/strimertul/log" "git.sr.ht/~ashkeel/containers/sync" "git.sr.ht/~ashkeel/strimertul/database" twitchclient "git.sr.ht/~ashkeel/strimertul/twitch/client" "git.sr.ht/~ashkeel/strimertul/utils" ) 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 logger *slog.Logger cooldowns map[string]time.Time banlist map[string]bool ctx context.Context cancelFn context.CancelFunc cancelSub database.CancelFunc restartTwitchHandler chan struct{} twitchManager *twitchclient.Manager twitchIntegration *twitchIntegration } func NewManager(ctx context.Context, db database.Database, twitchManager *twitchclient.Manager) (*Manager, error) { loyaltyContext, cancelFn := context.WithCancel(ctx) loyalty := &Manager{ Config: sync.NewRWSync(Config{Enabled: false}), Rewards: sync.NewSlice[Reward](), Goals: sync.NewSlice[Goal](), Queue: sync.NewSlice[Redeem](), logger: log.GetLogger(ctx), db: db, points: sync.NewMap[string, PointsEntry](), cooldowns: make(map[string]time.Time), banlist: make(map[string]bool), ctx: loyaltyContext, cancelFn: cancelFn, restartTwitchHandler: make(chan struct{}), twitchManager: twitchManager, } // 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.Unmarshal([]byte(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 { loyalty.logger.Error("Could not setup loyalty reload subscription", log.Error(err)) } loyalty.SetBanList(config.BanList) // Start twitch handler if twitchManager.Client() != nil { loyalty.twitchIntegration = setupTwitchIntegration(loyaltyContext, loyalty, twitchManager.Client().Chat) } return loyalty, nil } func (m *Manager) Close() error { // Disable twitch integration if m.twitchIntegration != nil { m.twitchIntegration.Close() } // 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.Unmarshal([]byte(value), &redeem) if err == nil { err = m.AddRedeem(redeem) } case RemoveRedeemRPC: var redeem Redeem err = json.Unmarshal([]byte(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.Unmarshal([]byte(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), log.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 }