strimertul/loyalty/chat.go

372 lines
9.6 KiB
Go

package loyalty
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"git.sr.ht/~ashkeel/strimertul/log"
"github.com/nicklaw5/helix/v2"
"git.sr.ht/~ashkeel/containers/sync"
"git.sr.ht/~ashkeel/strimertul/twitch"
"git.sr.ht/~ashkeel/strimertul/twitch/chat"
)
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
type twitchIntegration struct {
ctx context.Context
manager *Manager
module *chat.Module
logger *slog.Logger
activeUsers *sync.Map[string, bool]
}
func setupTwitchIntegration(ctx context.Context, m *Manager, mod *chat.Module) *twitchIntegration {
li := &twitchIntegration{
ctx: ctx,
manager: m,
module: mod,
logger: log.GetLogger(ctx),
activeUsers: sync.NewMap[string, bool](),
}
// Add loyalty-based commands
mod.RegisterCommand(commandRedeem, chat.Command{
Description: "Redeem a reward with loyalty points",
Usage: fmt.Sprintf("%s <reward-id> [request text]", commandRedeem),
AccessLevel: chat.ALTEveryone,
Handler: li.cmdRedeemReward,
Enabled: true,
})
mod.RegisterCommand(commandBalance, chat.Command{
Description: "See your current point balance",
Usage: commandBalance,
AccessLevel: chat.ALTEveryone,
Handler: li.cmdBalance,
Enabled: true,
})
mod.RegisterCommand(commandGoals, chat.Command{
Description: "Check currently active community goals",
Usage: commandGoals,
AccessLevel: chat.ALTEveryone,
Handler: li.cmdGoalList,
Enabled: true,
})
mod.RegisterCommand(commandContribute, chat.Command{
Description: "Contribute points to a community goal",
Usage: fmt.Sprintf("%s <points> [<goal-id>]", commandContribute),
AccessLevel: chat.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 := m.db.GetJSON(twitch.StreamInfoKey, &streamInfos)
if err != nil {
li.logger.Error("Error retrieving stream info", log.Error(err))
continue
}
if len(streamInfos) < 1 {
continue
}
// Get user list
users := mod.GetChatters()
// 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 {
li.logger.Error("Error awarding loyalty points to user", log.Error(err))
}
}
}
}()
li.logger.Info("Loyalty system integration with Twitch is ready")
return li
}
func (li *twitchIntegration) Close() {
li.module.UnregisterCommand(commandRedeem)
li.module.UnregisterCommand(commandBalance)
li.module.UnregisterCommand(commandGoals)
li.module.UnregisterCommand(commandContribute)
}
func (li *twitchIntegration) HandleMessage(message helix.EventSubChannelChatMessageEvent) {
li.activeUsers.SetKey(message.ChatterUserLogin, true)
}
func (li *twitchIntegration) IsActive(user string) bool {
active, ok := li.activeUsers.GetKey(user)
return ok && active
}
func (li *twitchIntegration) ResetActivity() {
li.activeUsers = sync.NewMap[string, bool]()
}
func (li *twitchIntegration) cmdBalance(message helix.EventSubChannelChatMessageEvent) {
// Get user balance
balance := li.manager.GetPoints(message.ChatterUserLogin)
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("You have %d %s!", balance, li.manager.Config.Get().Currency),
ReplyTo: message.MessageID,
})
}
func (li *twitchIntegration) 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(chat.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(Redeem{
Username: message.ChatterUserLogin,
DisplayName: message.ChatterUserName,
When: time.Now(),
Reward: reward,
RequestText: text,
}); err != nil {
if errors.Is(err, ErrRedeemInCooldown) {
nextAvailable := li.manager.GetRewardCooldown(reward.ID)
li.module.WriteMessage(chat.WriteMessageRequest{
Message: fmt.Sprintf("That reward is in cooldown (available in %s)",
time.Until(nextAvailable).Truncate(time.Second)),
ReplyTo: message.MessageID,
})
return
}
li.logger.Error("Error while performing redeem", log.Error(err))
return
}
li.module.WriteMessage(chat.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 *twitchIntegration) cmdGoalList(message helix.EventSubChannelChatMessageEvent) {
goals := li.manager.Goals.Get()
if len(goals) < 1 {
li.module.WriteMessage(chat.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 <!contribute POINTS GOALID>"
li.module.WriteMessage(chat.WriteMessageRequest{
Message: msg,
})
}
func (li *twitchIntegration) 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(chat.WriteMessageRequest{
Message: "All active community goals have been reached already! NewRecord",
ReplyTo: message.MessageID,
})
} else {
li.module.WriteMessage(chat.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(chat.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(chat.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(chat.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.logger.Error("Error while contributing to goal", log.Error(err))
return
}
if points == 0 {
li.module.WriteMessage(chat.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(chat.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(chat.WriteMessageRequest{
Message: fmt.Sprintf("FallWinning The community goal \"%s\" was reached! FallWinning", selectedGoal.Name),
Announce: true,
})
}
}