package chat import ( "context" "errors" "fmt" "strconv" "strings" "time" "git.sr.ht/~ashkeel/strimertul/twitch" "git.sr.ht/~ashkeel/containers/sync" "git.sr.ht/~ashkeel/strimertul/loyalty" "github.com/nicklaw5/helix/v2" ) const ( commandRedeem = "!redeem" commandGoals = "!goals" commandBalance = "!balance" commandContribute = "!contribute" ) type loyaltyIntegration struct { ctx context.Context manager *loyalty.Manager module *Module activeUsers *sync.Map[string, bool] } func setupLoyaltyIntegration(ctx context.Context, mod *Module, manager *loyalty.Manager) *loyaltyIntegration { li := &loyaltyIntegration{ ctx: ctx, manager: manager, module: mod, activeUsers: sync.NewMap[string, bool](), } // Add loyalty-based commands mod.commands.SetKey(commandRedeem, Command{ Description: "Redeem a reward with loyalty points", Usage: fmt.Sprintf("%s [request text]", commandRedeem), AccessLevel: ALTEveryone, Handler: li.cmdRedeemReward, Enabled: true, }) mod.commands.SetKey(commandBalance, Command{ Description: "See your current point balance", Usage: commandBalance, AccessLevel: ALTEveryone, Handler: li.cmdBalance, Enabled: true, }) mod.commands.SetKey(commandGoals, Command{ Description: "Check currently active community goals", Usage: commandGoals, AccessLevel: ALTEveryone, Handler: li.cmdGoalList, Enabled: true, }) mod.commands.SetKey(commandContribute, Command{ Description: "Contribute points to a community goal", Usage: fmt.Sprintf("%s []", commandContribute), AccessLevel: ALTEveryone, Handler: li.cmdContributeGoal, Enabled: true, }) // Setup handler for adding points over time go func() { config := li.manager.Config.Get() // Stop handler if loyalty system is disabled or there is no valid point interval if !config.Enabled || config.Points.Interval <= 0 { return } for { // Wait for next poll select { case <-li.ctx.Done(): return case <-time.After(time.Duration(config.Points.Interval) * time.Second): } // If stream is confirmed offline, don't give points away! var streamInfos []helix.Stream err := mod.db.GetJSON(twitch.StreamInfoKey, &streamInfos) if err != nil { mod.logger.Error("Error retrieving stream info", "error", err) continue } if len(streamInfos) < 1 { continue } // Get user list cursor := "" var users []string for { userClient, err := twitch.GetUserClient(mod.db, false) if err != nil { mod.logger.Error("Could not get user api client for list of chatters", "error", err) return } res, err := userClient.GetChannelChatChatters(&helix.GetChatChattersParams{ BroadcasterID: mod.user.ID, ModeratorID: mod.user.ID, First: "1000", After: cursor, }) if err != nil { mod.logger.Error("Could not retrieve list of chatters", "error", err) return } for _, user := range res.Data.Chatters { users = append(users, user.UserLogin) } cursor = res.Data.Pagination.Cursor if cursor == "" { break } } // Iterate for each user in the list pointsToGive := make(map[string]int64) for _, user := range users { // Check if user is blocked if li.manager.IsBanned(user) { continue } // Check if user was active (chatting) for the bonus dingus award := config.Points.Amount if li.IsActive(user) { award += config.Points.ActivityBonus } // Add to point pool if already on it, otherwise initialize pointsToGive[user] = award } li.ResetActivity() // If changes were made, save the pool! if len(users) > 0 { err := li.manager.GivePoints(pointsToGive) if err != nil { mod.logger.Error("Error awarding loyalty points to user", "error", err) } } } }() mod.logger.Info("Loyalty system integration with Twitch is ready") return li } func (li *loyaltyIntegration) Close() { li.module.commands.DeleteKey(commandRedeem) li.module.commands.DeleteKey(commandBalance) li.module.commands.DeleteKey(commandGoals) li.module.commands.DeleteKey(commandContribute) } func (li *loyaltyIntegration) HandleMessage(message helix.EventSubChannelChatMessageEvent) { li.activeUsers.SetKey(message.ChatterUserLogin, true) } func (li *loyaltyIntegration) IsActive(user string) bool { active, ok := li.activeUsers.GetKey(user) return ok && active } func (li *loyaltyIntegration) ResetActivity() { li.activeUsers = sync.NewMap[string, bool]() } func (li *loyaltyIntegration) cmdBalance(message helix.EventSubChannelChatMessageEvent) { // Get user balance balance := li.manager.GetPoints(message.ChatterUserLogin) li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("You have %d %s!", balance, li.manager.Config.Get().Currency), ReplyTo: message.MessageID, }) } func (li *loyaltyIntegration) cmdRedeemReward(message helix.EventSubChannelChatMessageEvent) { parts := strings.Fields(message.Message.Text) if len(parts) < 2 { return } redeemID := parts[1] // Find reward reward := li.manager.GetReward(redeemID) if reward.ID == "" { return } // Reward not active, return early if !reward.Enabled { return } // Get user balance balance := li.manager.GetPoints(message.ChatterUserLogin) config := li.manager.Config.Get() // Check if user can afford the reward if balance-reward.Price < 0 { li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("I'm sorry but you cannot afford this (have %d %s, need %d)", balance, config.Currency, reward.Price), ReplyTo: message.MessageID, }) return } text := "" if len(parts) > 2 { text = strings.Join(parts[2:], " ") } // Perform redeem if err := li.manager.PerformRedeem(loyalty.Redeem{ Username: message.ChatterUserLogin, DisplayName: message.ChatterUserName, When: time.Now(), Reward: reward, RequestText: text, }); err != nil { if errors.Is(err, loyalty.ErrRedeemInCooldown) { nextAvailable := li.manager.GetRewardCooldown(reward.ID) li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("That reward is in cooldown (available in %s)", time.Until(nextAvailable).Truncate(time.Second)), ReplyTo: message.MessageID, }) return } li.module.logger.Error("Error while performing redeem", "error", err) return } li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("HolidayPresent %s has redeemed %s! (new balance: %d %s)", message.ChatterUserName, reward.Name, li.manager.GetPoints(message.ChatterUserLogin), config.Currency), }) } func (li *loyaltyIntegration) cmdGoalList(message helix.EventSubChannelChatMessageEvent) { goals := li.manager.Goals.Get() if len(goals) < 1 { li.module.WriteMessage(WriteMessageRequest{ Message: "There are no active community goals right now :(!", ReplyTo: message.MessageID, }) return } msg := "Current goals: " for _, goal := range goals { if !goal.Enabled { continue } msg += fmt.Sprintf("%s (%d/%d %s) [id: %s] | ", goal.Name, goal.Contributed, goal.TotalGoal, li.manager.Config.Get().Currency, goal.ID) } msg += " Contribute with " li.module.WriteMessage(WriteMessageRequest{ Message: msg, }) } func (li *loyaltyIntegration) cmdContributeGoal(message helix.EventSubChannelChatMessageEvent) { goals := li.manager.Goals.Get() // 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 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 { li.module.WriteMessage(WriteMessageRequest{ Message: "All active community goals have been reached already! NewRecord", ReplyTo: message.MessageID, }) } else { li.module.WriteMessage(WriteMessageRequest{ Message: "There are no active community goals right now :(!", ReplyTo: message.MessageID, }) } return } // Parse parameters if provided parts := strings.Fields(message.Message.Text) if len(parts) > 1 { newPoints, err := strconv.ParseInt(parts[1], 10, 64) if err == nil { if newPoints <= 0 { li.module.WriteMessage(WriteMessageRequest{ Message: "Nice try SoBayed", ReplyTo: message.MessageID, }) return } points = newPoints } if len(parts) > 2 { found := false goalID := parts[2] // Find Goal index for index, goal := range goals { if !goal.Enabled { continue } if goal.ID == goalID { goalIndex = index found = true break } } // Invalid goal ID provided if !found { li.module.WriteMessage(WriteMessageRequest{ Message: "I couldn't find that goal ID :(", ReplyTo: message.MessageID, }) return } } } // Get goal selectedGoal := goals[goalIndex] // Check if goal was reached already if selectedGoal.Contributed >= selectedGoal.TotalGoal { li.module.WriteMessage(WriteMessageRequest{ Message: "This goal was already reached! ヾ(•ω•`)o", ReplyTo: message.MessageID, }) return } // Add points to goal points, err := li.manager.PerformContribution(selectedGoal, message.ChatterUserLogin, points) if err != nil { li.module.logger.Error("Error while contributing to goal", "error", err) return } if points == 0 { li.module.WriteMessage(WriteMessageRequest{ Message: "Sorry but you're broke", ReplyTo: message.MessageID, }) return } selectedGoal = li.manager.Goals.Get()[goalIndex] config := li.manager.Config.Get() newRemaining := selectedGoal.TotalGoal - selectedGoal.Contributed li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!", message.ChatterUserName, points, config.Currency, selectedGoal.Name, newRemaining, config.Currency), }) // Check if goal was reached! if newRemaining <= 0 { li.module.WriteMessage(WriteMessageRequest{ Message: fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name), Announce: true, }) } }