2022-11-30 18:15:47 +00:00
package loyalty
2021-09-16 13:55:43 +00:00
import (
2024-02-25 13:58:35 +00:00
"errors"
2021-09-16 13:55:43 +00:00
"fmt"
"strconv"
"strings"
"time"
2023-02-02 20:24:14 +00:00
"github.com/nicklaw5/helix/v2"
2023-11-10 20:36:15 +00:00
"git.sr.ht/~ashkeel/strimertul/twitch"
2022-11-30 18:15:47 +00:00
2023-11-10 20:36:15 +00:00
"git.sr.ht/~ashkeel/containers/sync"
2023-02-02 20:24:14 +00:00
irc "github.com/gempir/go-twitch-irc/v4"
2022-12-04 13:45:34 +00:00
"go.uber.org/zap"
2021-09-16 13:55:43 +00:00
)
2023-05-03 12:37:28 +00:00
const (
commandRedeem = "!redeem"
commandGoals = "!goals"
commandBalance = "!balance"
commandContribute = "!contribute"
)
2022-11-30 18:15:47 +00:00
func ( m * Manager ) SetupTwitch ( ) {
2022-12-03 15:16:59 +00:00
bot := m . twitchManager . Client ( ) . Bot
2022-11-30 18:15:47 +00:00
if bot == nil {
2023-04-19 13:27:13 +00:00
m . logger . Warn ( "Twitch bot is offline or not configured, could not setup commands" )
2022-11-30 18:15:47 +00:00
return
}
2021-09-16 13:55:43 +00:00
// Add loyalty-based commands
2023-05-03 12:37:28 +00:00
bot . RegisterCommand ( commandRedeem , twitch . BotCommand {
2021-09-16 13:55:43 +00:00
Description : "Redeem a reward with loyalty points" ,
2023-05-03 12:37:28 +00:00
Usage : fmt . Sprintf ( "%s <reward-id> [request text]" , commandRedeem ) ,
2022-11-30 18:15:47 +00:00
AccessLevel : twitch . ALTEveryone ,
Handler : m . cmdRedeemReward ,
2022-01-12 11:02:54 +00:00
Enabled : true ,
2022-11-30 18:15:47 +00:00
} )
2023-05-03 12:37:28 +00:00
bot . RegisterCommand ( commandBalance , twitch . BotCommand {
2021-09-16 13:55:43 +00:00
Description : "See your current point balance" ,
2023-05-03 12:37:28 +00:00
Usage : commandBalance ,
2022-11-30 18:15:47 +00:00
AccessLevel : twitch . ALTEveryone ,
Handler : m . cmdBalance ,
2022-01-12 11:02:54 +00:00
Enabled : true ,
2022-11-30 18:15:47 +00:00
} )
2023-05-03 12:37:28 +00:00
bot . RegisterCommand ( commandGoals , twitch . BotCommand {
2021-09-16 13:55:43 +00:00
Description : "Check currently active community goals" ,
2023-05-03 12:37:28 +00:00
Usage : commandGoals ,
2022-11-30 18:15:47 +00:00
AccessLevel : twitch . ALTEveryone ,
Handler : m . cmdGoalList ,
2022-01-12 11:02:54 +00:00
Enabled : true ,
2022-11-30 18:15:47 +00:00
} )
2023-05-03 12:37:28 +00:00
bot . RegisterCommand ( commandContribute , twitch . BotCommand {
2021-09-16 13:55:43 +00:00
Description : "Contribute points to a community goal" ,
2023-05-03 12:37:28 +00:00
Usage : fmt . Sprintf ( "%s <points> [<goal-id>]" , commandContribute ) ,
2022-11-30 18:15:47 +00:00
AccessLevel : twitch . ALTEveryone ,
Handler : m . cmdContributeGoal ,
2022-01-12 11:02:54 +00:00
Enabled : true ,
2022-11-30 18:15:47 +00:00
} )
// Setup message handler for tracking user activity
2023-06-01 08:50:46 +00:00
bot . OnMessage . Add ( m )
2021-09-16 13:55:43 +00:00
// Setup handler for adding points over time
2022-11-30 18:15:47 +00:00
go func ( ) {
config := m . Config . Get ( )
2022-12-03 15:40:50 +00:00
// 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 <- m . ctx . Done ( ) :
return
2022-12-04 17:03:05 +00:00
case <- m . restartTwitchHandler :
return
2022-12-03 15:40:50 +00:00
case <- time . After ( time . Duration ( config . Points . Interval ) * time . Second ) :
}
2023-02-02 20:24:14 +00:00
client := m . twitchManager . Client ( )
2022-12-03 15:40:50 +00:00
// If stream is confirmed offline, don't give points away!
2023-02-02 20:24:14 +00:00
isOnline := client . IsLive ( )
2022-12-03 15:40:50 +00:00
if ! isOnline {
continue
}
2023-02-02 20:24:14 +00:00
// Get user list
cursor := ""
var users [ ] string
for {
2023-03-05 19:11:19 +00:00
userClient , err := client . GetUserClient ( false )
2023-02-02 20:24:14 +00:00
if err != nil {
2023-04-19 13:27:13 +00:00
m . logger . Error ( "Could not get user api client for list of chatters" , zap . Error ( err ) )
2023-02-02 20:24:14 +00:00
return
}
res , err := userClient . GetChannelChatChatters ( & helix . GetChatChattersParams {
BroadcasterID : client . User . ID ,
ModeratorID : client . User . ID ,
First : "1000" ,
After : cursor ,
} )
if err != nil {
2023-04-19 13:27:13 +00:00
m . logger . Error ( "Could not retrieve list of chatters" , zap . Error ( err ) )
2023-02-02 20:24:14 +00:00
return
}
for _ , user := range res . Data . Chatters {
users = append ( users , user . UserLogin )
}
cursor = res . Data . Pagination . Cursor
if cursor == "" {
break
}
2022-12-03 15:40:50 +00:00
}
// Iterate for each user in the list
pointsToGive := make ( map [ string ] int64 )
for _ , user := range users {
// Check if user is blocked
if m . IsBanned ( user ) {
continue
}
// Check if user was active (chatting) for the bonus dingus
award := config . Points . Amount
if m . IsActive ( user ) {
award += config . Points . ActivityBonus
}
// Add to point pool if already on it, otherwise initialize
pointsToGive [ user ] = award
}
m . ResetActivity ( )
// If changes were made, save the pool!
if len ( users ) > 0 {
err := m . GivePoints ( pointsToGive )
if err != nil {
2023-04-19 13:27:13 +00:00
m . logger . Error ( "Error awarding loyalty points to user" , zap . Error ( err ) )
2021-09-16 13:55:43 +00:00
}
2022-01-22 12:05:24 +00:00
}
2022-11-30 18:15:47 +00:00
}
} ( )
2023-02-01 12:33:30 +00:00
2023-04-19 13:27:13 +00:00
m . logger . Info ( "Loyalty system integration with Twitch is ready" )
2022-11-30 18:15:47 +00:00
}
func ( m * Manager ) StopTwitch ( ) {
2022-12-03 15:16:59 +00:00
bot := m . twitchManager . Client ( ) . Bot
2022-11-30 18:15:47 +00:00
if bot != nil {
2023-05-03 12:37:28 +00:00
bot . RemoveCommand ( commandRedeem )
bot . RemoveCommand ( commandBalance )
bot . RemoveCommand ( commandGoals )
bot . RemoveCommand ( commandContribute )
2022-11-30 18:15:47 +00:00
// Remove message handler
2023-06-01 08:50:46 +00:00
bot . OnMessage . Remove ( m )
2022-11-30 18:15:47 +00:00
}
}
func ( m * Manager ) HandleBotMessage ( message irc . PrivateMessage ) {
m . activeUsers . SetKey ( message . User . Name , true )
2021-09-16 13:55:43 +00:00
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) SetBanList ( banned [ ] string ) {
m . banlist = make ( map [ string ] bool )
2021-09-16 13:55:43 +00:00
for _ , usr := range banned {
2022-11-30 18:15:47 +00:00
m . banlist [ usr ] = true
2021-09-16 13:55:43 +00:00
}
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) IsBanned ( user string ) bool {
banned , ok := m . banlist [ user ]
2021-09-16 13:55:43 +00:00
return ok && banned
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) IsActive ( user string ) bool {
active , ok := m . activeUsers . GetKey ( user )
2021-09-16 13:55:43 +00:00
return ok && active
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) ResetActivity ( ) {
2022-12-04 13:45:34 +00:00
m . activeUsers = sync . NewMap [ string , bool ] ( )
2021-09-16 13:55:43 +00:00
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) cmdBalance ( bot * twitch . Bot , message irc . PrivateMessage ) {
2021-09-16 13:55:43 +00:00
// Get user balance
2022-11-30 18:15:47 +00:00
balance := m . GetPoints ( message . User . Name )
bot . Client . Say ( message . Channel , fmt . Sprintf ( "%s: You have %d %s!" , message . User . DisplayName , balance , m . Config . Get ( ) . Currency ) )
2021-09-16 13:55:43 +00:00
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) cmdRedeemReward ( bot * twitch . Bot , message irc . PrivateMessage ) {
2021-09-16 13:55:43 +00:00
parts := strings . Fields ( message . Message )
if len ( parts ) < 2 {
return
}
redeemID := parts [ 1 ]
// Find reward
2022-11-30 18:15:47 +00:00
reward := m . GetReward ( redeemID )
2021-09-16 13:55:43 +00:00
if reward . ID == "" {
return
}
// Reward not active, return early
if ! reward . Enabled {
return
}
// Get user balance
2022-11-30 18:15:47 +00:00
balance := m . GetPoints ( message . User . Name )
config := m . Config . Get ( )
2021-09-16 13:55:43 +00:00
// Check if user can afford the reward
if balance - reward . Price < 0 {
bot . Client . Say ( message . Channel , fmt . Sprintf ( "I'm sorry %s but you cannot afford this (have %d %s, need %d)" , message . User . DisplayName , balance , config . Currency , reward . Price ) )
return
}
text := ""
if len ( parts ) > 2 {
text = strings . Join ( parts [ 2 : ] , " " )
}
// Perform redeem
2022-11-30 18:15:47 +00:00
if err := m . PerformRedeem ( Redeem {
2021-09-16 13:55:43 +00:00
Username : message . User . Name ,
DisplayName : message . User . DisplayName ,
When : time . Now ( ) ,
Reward : reward ,
RequestText : text ,
} ) ; err != nil {
2024-02-25 13:58:35 +00:00
if errors . Is ( err , ErrRedeemInCooldown ) {
2022-11-30 18:15:47 +00:00
nextAvailable := m . GetRewardCooldown ( reward . ID )
2021-09-16 13:55:43 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "%s: That reward is in cooldown (available in %s)" , message . User . DisplayName ,
time . Until ( nextAvailable ) . Truncate ( time . Second ) ) )
2024-02-25 13:58:35 +00:00
return
2021-09-16 13:55:43 +00:00
}
2024-02-25 13:58:35 +00:00
m . logger . Error ( "Error while performing redeem" , zap . Error ( err ) )
2021-09-16 13:55:43 +00:00
return
}
2022-11-30 18:15:47 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "HolidayPresent %s has redeemed %s! (new balance: %d %s)" , message . User . DisplayName , reward . Name , m . GetPoints ( message . User . Name ) , config . Currency ) )
2021-09-16 13:55:43 +00:00
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) cmdGoalList ( bot * twitch . Bot , message irc . PrivateMessage ) {
goals := m . Goals . Get ( )
2021-09-16 13:55:43 +00:00
if len ( 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 goals {
if ! goal . Enabled {
continue
}
2022-11-30 18:15:47 +00:00
msg += fmt . Sprintf ( "%s (%d/%d %s) [id: %s] | " , goal . Name , goal . Contributed , goal . TotalGoal , m . Config . Get ( ) . Currency , goal . ID )
2021-09-16 13:55:43 +00:00
}
msg += " Contribute with <!contribute POINTS GOALID>"
bot . Client . Say ( message . Channel , msg )
}
2022-11-30 18:15:47 +00:00
func ( m * Manager ) cmdContributeGoal ( bot * twitch . Bot , message irc . PrivateMessage ) {
goals := m . Goals . Get ( )
2021-09-16 13:55:43 +00:00
// 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 {
2023-02-06 09:36:00 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "%s: All active community goals have been reached already! NewRecord" , message . User . DisplayName ) )
2021-09-16 13:55:43 +00:00
} 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 {
2023-02-06 09:36:00 +00:00
newPoints , err := strconv . ParseInt ( parts [ 1 ] , 10 , 64 )
2021-09-16 13:55:43 +00:00
if err == nil {
2023-02-06 09:36:00 +00:00
if newPoints <= 0 {
2021-09-16 13:55:43 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "Nice try %s SoBayed" , message . User . DisplayName ) )
return
}
2023-02-06 09:36:00 +00:00
points = newPoints
2021-09-16 13:55:43 +00:00
}
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 {
bot . Client . Say ( message . Channel , fmt . Sprintf ( "%s: I couldn't find that goal ID :(" , message . User . DisplayName ) )
return
}
}
}
// Get goal
selectedGoal := 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
}
// Add points to goal
2023-02-06 09:36:00 +00:00
points , err := m . PerformContribution ( selectedGoal , message . User . Name , points )
if err != nil {
2023-04-23 00:26:46 +00:00
m . logger . Error ( "Error while contributing to goal" , zap . Error ( err ) )
2021-09-16 13:55:43 +00:00
return
}
2023-02-06 09:36:00 +00:00
if points == 0 {
bot . Client . Say ( message . Channel , fmt . Sprintf ( "%s: Sorry but you're broke" , message . User . DisplayName ) )
return
}
2021-09-16 13:55:43 +00:00
2023-02-06 09:36:00 +00:00
selectedGoal = m . Goals . Get ( ) [ goalIndex ]
2022-11-30 18:15:47 +00:00
config := m . Config . Get ( )
2021-09-16 13:55:43 +00:00
newRemaining := selectedGoal . TotalGoal - selectedGoal . Contributed
2023-02-06 09:36:00 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "NewRecord %s contributed %d %s to \"%s\"!! Only %d %s left!" , message . User . DisplayName , points , config . Currency , selectedGoal . Name , newRemaining , config . Currency ) )
2021-09-16 13:55:43 +00:00
// Check if goal was reached!
// TODO Replace this with sub from loyalty system or something?
if newRemaining <= 0 {
2023-02-06 09:36:00 +00:00
bot . Client . Say ( message . Channel , fmt . Sprintf ( "FallWinning The community goal \"%s\" was reached! FallWinning" , selectedGoal . Name ) )
2021-09-16 13:55:43 +00:00
}
}