From dfb36dcdf8fc8a383dda2718d27f834cdf267421 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Wed, 12 May 2021 19:19:09 +0200 Subject: [PATCH] Add community goals bot commands and start fixing race conditions --- modules/loyalty/manager.go | 58 +++++++++++--- modules/stulbe/client.go | 8 ++ twitchbot/commands.go | 153 ++++++++++++++++++++++++++++++++++--- 3 files changed, 196 insertions(+), 23 deletions(-) diff --git a/modules/loyalty/manager.go b/modules/loyalty/manager.go index ef0fa64..25250b2 100644 --- a/modules/loyalty/manager.go +++ b/modules/loyalty/manager.go @@ -3,6 +3,7 @@ package loyalty import ( "context" "errors" + "sync" "github.com/dgraph-io/badger/v3" jsoniter "github.com/json-iterator/go" @@ -14,13 +15,14 @@ import ( type Manager struct { Config Config - Points PointStorage Rewards RewardStorage Goals GoalStorage RedeemQueue RedeemQueueStorage - hub *kv.Hub - logger logrus.FieldLogger + points PointStorage + pointsmu sync.Mutex + hub *kv.Hub + logger logrus.FieldLogger } func NewManager(db *database.DB, hub *kv.Hub, log logrus.FieldLogger) (*Manager, error) { @@ -29,8 +31,9 @@ func NewManager(db *database.DB, hub *kv.Hub, log logrus.FieldLogger) (*Manager, } manager := &Manager{ - logger: log, - hub: hub, + logger: log, + hub: hub, + pointsmu: sync.Mutex{}, } // Ger data from DB if err := db.GetJSON(ConfigKey, &manager.Config); err != nil { @@ -40,9 +43,9 @@ func NewManager(db *database.DB, hub *kv.Hub, log logrus.FieldLogger) (*Manager, return nil, err } } - if err := db.GetJSON(PointsKey, &manager.Points); err != nil { + if err := db.GetJSON(PointsKey, &manager.points); err != nil { if err == badger.ErrKeyNotFound { - manager.Points = make(PointStorage) + manager.points = make(PointStorage) } else { return nil, err } @@ -78,7 +81,9 @@ func (m *Manager) update(kvs []database.ModifiedKV) error { case ConfigKey: err = jsoniter.ConfigFastest.Unmarshal(kv.Data, &m.Config) case PointsKey: - err = jsoniter.ConfigFastest.Unmarshal(kv.Data, &m.Points) + m.pointsmu.Lock() + err = jsoniter.ConfigFastest.Unmarshal(kv.Data, &m.points) + m.pointsmu.Unlock() case GoalsKey: err = jsoniter.ConfigFastest.Unmarshal(kv.Data, &m.Goals) case RewardsKey: @@ -111,14 +116,33 @@ func (m *Manager) update(kvs []database.ModifiedKV) error { } func (m *Manager) SavePoints() error { - data, _ := jsoniter.ConfigFastest.Marshal(m.Points) + m.pointsmu.Lock() + defer m.pointsmu.Unlock() + data, _ := jsoniter.ConfigFastest.Marshal(m.points) return m.hub.WriteKey(PointsKey, string(data)) } +func (m *Manager) GetPoints(user string) int64 { + m.pointsmu.Lock() + defer m.pointsmu.Unlock() + points, ok := m.points[user] + if ok { + return points + } + return 0 +} + +func (m *Manager) SetPoints(user string, points int64) { + m.pointsmu.Lock() + defer m.pointsmu.Unlock() + m.points[user] = points +} + func (m *Manager) GivePoints(pointsToGive map[string]int64) error { // Add points to each user for user, points := range pointsToGive { - m.Points[user] += points + balance := m.GetPoints(user) + m.SetPoints(user, balance+points) } // Save points @@ -128,7 +152,8 @@ func (m *Manager) GivePoints(pointsToGive map[string]int64) error { func (m *Manager) TakePoints(pointsToTake map[string]int64) error { // Add points to each user for user, points := range pointsToTake { - m.Points[user] -= points + balance := m.GetPoints(user) + m.SetPoints(user, balance-points) } // Save points @@ -165,3 +190,14 @@ func (m *Manager) RemoveRedeem(redeem Redeem) error { return errors.New("redeem not found") } + +func (m *Manager) SaveGoals() error { + data, _ := jsoniter.ConfigFastest.Marshal(m.Goals) + return m.hub.WriteKey(GoalsKey, string(data)) +} + +func (m *Manager) ContributeGoal(goal *Goal, user string, points int64) error { + goal.Contributed += points + goal.Contributors[user] += points + return m.SaveGoals() +} diff --git a/modules/stulbe/client.go b/modules/stulbe/client.go index 72070e2..83a4386 100644 --- a/modules/stulbe/client.go +++ b/modules/stulbe/client.go @@ -13,6 +13,7 @@ import ( type Manager struct { Client *stulbe.Client db *database.DB + logger logrus.FieldLogger } func Initialize(db *database.DB, logger logrus.FieldLogger) (*Manager, error) { @@ -35,6 +36,7 @@ func Initialize(db *database.DB, logger logrus.FieldLogger) (*Manager, error) { return &Manager{ Client: stulbeClient, db: db, + logger: logger, }, err } @@ -53,6 +55,9 @@ func (m *Manager) ReplicateKey(key string) error { if err != nil { return err } + m.logger.WithFields(logrus.Fields{ + "key": key, + }).Debug("set to remote") // Subscribe to local datastore and update remote on change return m.db.Subscribe(context.Background(), func(pairs []database.ModifiedKV) error { @@ -61,6 +66,9 @@ func (m *Manager) ReplicateKey(key string) error { if err != nil { return err } + m.logger.WithFields(logrus.Fields{ + "key": changed.Key, + }).Debug("replicated to remote") } return nil diff --git a/twitchbot/commands.go b/twitchbot/commands.go index 3644ac5..ca3cb43 100644 --- a/twitchbot/commands.go +++ b/twitchbot/commands.go @@ -2,6 +2,7 @@ package twitchbot import ( "fmt" + "strconv" "strings" "time" @@ -32,7 +33,7 @@ var commands = map[string]BotCommand{ Description: "Redeem a reward with loyalty points", Usage: "!redeem reward-id", AccessLevel: ALTEveryone, - Handler: cmdRedeem, + Handler: cmdRedeemReward, }, "!balance": { Description: "See your current point balance", @@ -40,19 +41,27 @@ var commands = map[string]BotCommand{ AccessLevel: ALTEveryone, Handler: cmdBalance, }, + "!goals": { + Description: "Check currently active community goals", + Usage: "!goals", + AccessLevel: ALTEveryone, + Handler: cmdGoalList, + }, + "!contribute": { + Description: "Contribute points to a community goal", + Usage: "!contribute []", + AccessLevel: ALTEveryone, + Handler: cmdContributeGoal, + }, } func cmdBalance(bot *TwitchBot, message irc.PrivateMessage) { // Get user balance - balance, ok := bot.Loyalty.Points[message.User.Name] - if !ok { - balance = 0 - } - + balance := bot.Loyalty.GetPoints(message.User.Name) bot.Client.Say(message.Channel, fmt.Sprintf("%s: You have %d %s!", message.User.DisplayName, balance, bot.Loyalty.Config.Currency)) } -func cmdRedeem(bot *TwitchBot, message irc.PrivateMessage) { +func cmdRedeemReward(bot *TwitchBot, message irc.PrivateMessage) { parts := strings.Fields(message.Message) if len(parts) < 2 { return @@ -71,10 +80,7 @@ func cmdRedeem(bot *TwitchBot, message irc.PrivateMessage) { } // Get user balance - balance, ok := bot.Loyalty.Points[message.User.Name] - if !ok { - balance = 0 - } + balance := bot.Loyalty.GetPoints(message.User.Name) // Check if user can afford the reward if balance-reward.Price < 0 { @@ -96,10 +102,133 @@ func cmdRedeem(bot *TwitchBot, message irc.PrivateMessage) { // Remove points from user if err := bot.Loyalty.TakePoints(map[string]int64{message.User.Name: reward.Price}); err != nil { bot.logger.WithError(err).Error("error while taking points for redeem") + return } - bot.Client.Say(message.Channel, fmt.Sprintf("%s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, bot.Loyalty.Points[message.User.Name], bot.Loyalty.Config.Currency)) + bot.Client.Say(message.Channel, fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)", message.User.DisplayName, reward.Name, bot.Loyalty.GetPoints(message.User.Name), bot.Loyalty.Config.Currency)) return } } + +func cmdGoalList(bot *TwitchBot, message irc.PrivateMessage) { + if len(bot.Loyalty.Goals) < 1 { + bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName)) + return + } + msg := "Current goals: " + for _, goal := range bot.Loyalty.Goals { + if !goal.Enabled { + continue + } + msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, bot.Loyalty.Config.Currency, goal.ID) + } + msg += " Contribute with " + bot.Client.Say(message.Channel, msg) +} + +func cmdContributeGoal(bot *TwitchBot, message irc.PrivateMessage) { + // Set defaults if user doesn't provide them + points := int64(100) + goalIndex := -1 + hasGoals := false + + // Get first unreached goal for default + for index, goal := range bot.Loyalty.Goals { + if !goal.Enabled { + continue + } + hasGoals = true + if goal.Contributed < goal.TotalGoal { + goalIndex = index + break + } + } + + // Do we not have any goal we can contribute to? Hooray I guess? + if goalIndex < 0 { + if hasGoals { + bot.Client.Say(message.Channel, fmt.Sprintf("%s: All active community goals have been reached already! ShowOfHands", message.User.DisplayName)) + } else { + bot.Client.Say(message.Channel, fmt.Sprintf("%s: There are no active community goals right now :(!", message.User.DisplayName)) + } + return + } + + // Parse parameters if provided + parts := strings.Fields(message.Message) + if len(parts) > 1 { + newpoints, err := strconv.ParseInt(parts[1], 10, 64) + if err == nil { + if newpoints <= 0 { + bot.Client.Say(message.Channel, fmt.Sprintf("Nice try %s SoBayed", message.User.DisplayName)) + return + } + points = newpoints + } + if len(parts) > 2 { + found := false + goalID := parts[2] + // Find Goal index + for index, goal := range bot.Loyalty.Goals { + if !goal.Enabled { + continue + } + if goal.ID == goalID { + goalIndex = index + found = true + break + } + } + // Invalid goal ID provided + if !found { + bot.Client.Say(message.Channel, fmt.Sprintf("%s: I couldn't find that goal ID :(", message.User.DisplayName)) + return + } + } + } + + // Get user balance + balance := bot.Loyalty.GetPoints(message.User.Name) + + // If user specified more points than they have, pick the maximum possible + if points > balance { + points = balance + } + + // Get goal + selectedGoal := &bot.Loyalty.Goals[goalIndex] + + // Check if goal was reached already + if selectedGoal.Contributed >= selectedGoal.TotalGoal { + bot.Client.Say(message.Channel, fmt.Sprintf("%s: This goal was already reached! ヾ(•ω•`)o", message.User.DisplayName)) + return + } + + // If remaining points are lower than what user is contributing, only take what's needed + remaining := selectedGoal.TotalGoal - selectedGoal.Contributed + if points > remaining { + points = remaining + } + + // Remove points from user + if err := bot.Loyalty.TakePoints(map[string]int64{message.User.Name: points}); err != nil { + bot.logger.WithError(err).Error("error while taking points for redeem") + return + } + + // Add points to goal + if err := bot.Loyalty.ContributeGoal(selectedGoal, message.User.Name, points); err != nil { + bot.logger.WithError(err).Error("error while contributing to goal") + return + } + + newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed + bot.Client.Say(message.Channel, fmt.Sprintf("ShowOfHands %s contributed %d %s to \"%s\"!! Only %d %s left ShowOfHands!", message.User.DisplayName, points, bot.Loyalty.Config.Currency, selectedGoal.Name, newRemaining, bot.Loyalty.Config.Currency)) + + // Check if goal was reached! + // TODO Replace this with sub from loyalty system or something? + if newRemaining <= 0 { + bot.Client.Say(message.Channel, fmt.Sprintf("ShowOfHands The community goal \"%s\" was reached! ShowOfHands", selectedGoal.Name)) + } +}