diff --git a/.env.example b/.env.example index 3803ce9..43f3ac6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ # Discord Configuration DISCORD_TOKEN="" +ADMIN_USER_IDS="83679718401904640,123456789012345678" # Container configuration IMAGE="" diff --git a/command/himbucks.go b/command/himbucks.go index 49ef33e..e3ed67f 100644 --- a/command/himbucks.go +++ b/command/himbucks.go @@ -87,3 +87,61 @@ func BalanceSendCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (s return fmt.Sprintf("💸 Successfully sent %d Himbucks to %s! 💸", amount, recipient.Username), nil } + +func GiveCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) { + if !isAdmin(i.Member.User.ID) { + s.InteractionResponseDelete(i.Interaction) + + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: "You do not have permission to use this command.", + Flags: discordgo.MessageFlagsEphemeral, + }) + + return "", nil + } + + options := i.ApplicationCommandData().Options + var recipientID string + var amount int + + for _, opt := range options { + switch opt.Name { + case "user": + recipientID = opt.UserValue(nil).ID + case "amount": + amount = int(opt.IntValue()) + } + } + + recipient, err := s.User(recipientID) + if err != nil { + return "", fmt.Errorf("failed to get recipient info: %w", err) + } + + _, err = lib.GetOrCreateUserWithGuild(recipientID, recipient.Username, i.GuildID) + if err != nil { + return "", fmt.Errorf("failed to initialize recipient profile: %w", err) + } + + err = lib.GiveHimbucks(recipientID, i.GuildID, amount) + if err != nil { + return "", fmt.Errorf("failed to give himbucks: %w", err) + } + + action := "given to" + if amount < 0 { + action = "taken from" + amount = -amount + } + + return fmt.Sprintf("✅ Successfully %s %s %d Himbucks.", action, recipient.Username, amount), nil +} + +func isAdmin(userID string) bool { + for _, adminID := range lib.AppConfig.AdminIDs { + if userID == adminID { + return true + } + } + return false +} diff --git a/command/shop.go b/command/shop.go new file mode 100644 index 0000000..651fad6 --- /dev/null +++ b/command/shop.go @@ -0,0 +1,59 @@ +package command + +import ( + "fmt" + "himbot/lib" + + "github.com/bwmarrin/discordgo" +) + +func ShopCommand(s *discordgo.Session, i *discordgo.InteractionCreate) (string, error) { + options := i.ApplicationCommandData().Options + + if len(options) == 0 { + return "Welcome to the Himbucks Shop! Use `/shop buy` to purchase items.", nil + } + + if options[0].Name == "buy" { + subOptions := options[0].Options + if len(subOptions) == 0 { + return "Please specify an item to buy.", nil + } + + item := subOptions[0].StringValue() + user, err := lib.GetUser(i) + if err != nil { + return "", err + } + + switch item { + case "pizza": + cost, newBalance, err := lib.BuyPizza(user.ID, i.GuildID) + if err != nil { + return fmt.Sprintf("❌ %s", err.Error()), nil + } + return fmt.Sprintf("Here is your pizza, %s!\n**Cost:** %d himbucks\n**Remaining Balance:** %d himbucks\n```\n%s\n```", user.Username, cost, newBalance, getPizzaArt()), nil + + case "multiplier": + newMult, cost, newBalance, err := lib.BuyMultiplier(user.ID, i.GuildID) + if err != nil { + return fmt.Sprintf("❌ %s", err.Error()), nil + } + return fmt.Sprintf("Multiplier upgraded!\nYou spent **%d** himbucks.\nYour new earning multiplier is **%.1fx**!\n**Remaining Balance:** %d himbucks", cost, newMult, newBalance), nil + + default: + return fmt.Sprintf("Unknown item: %s", item), nil + } + } + + return "Unknown subcommand.", nil +} + +func getPizzaArt() string { + return ` + // ""--.._ + || (_) _ "-._ + || _ (_) '-. + || (_) __..-' + \__..--"` +} diff --git a/lib/command.go b/lib/command.go index f0044ee..e9d3abf 100644 --- a/lib/command.go +++ b/lib/command.go @@ -43,7 +43,13 @@ func HandleCommand(commandName string, cooldownDuration time.Duration, handler C response, handlerErr := handler(s, i) if handlerErr != nil { - RespondWithError(s, i, "Error processing command: "+handlerErr.Error()) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: "Error processing command: " + handlerErr.Error(), + }) + return + } + + if response == "" { return } diff --git a/lib/config.go b/lib/config.go index b9ca8c6..0a19e23 100644 --- a/lib/config.go +++ b/lib/config.go @@ -10,6 +10,7 @@ import ( type Config struct { // Discord settings DiscordToken string + AdminIDs []string // Himbucks settings HimbucksPerReward int @@ -36,6 +37,8 @@ type Config struct { HimbucksCooldown int HimboardCooldown int SendbucksCooldown int + ShopCooldown int + GivebucksCooldown int } var AppConfig *Config @@ -45,6 +48,7 @@ func LoadConfig() *Config { config := &Config{ // Discord settings DiscordToken: getEnv("DISCORD_TOKEN", ""), + AdminIDs: getEnvSlice("ADMIN_USER_IDS", []string{}), // Himbucks settings HimbucksPerReward: getEnvInt("HIMBUCKS_PER_REWARD", 10), @@ -71,6 +75,8 @@ func LoadConfig() *Config { HimbucksCooldown: getEnvInt("HIMBUCKS_COOLDOWN_SECONDS", 5), HimboardCooldown: getEnvInt("HIMBOARD_COOLDOWN_SECONDS", 5), SendbucksCooldown: getEnvInt("SENDBUCKS_COOLDOWN_SECONDS", 1800), + ShopCooldown: getEnvInt("SHOP_COOLDOWN_SECONDS", 5), + GivebucksCooldown: getEnvInt("GIVEBUCKS_COOLDOWN_SECONDS", 1), } AppConfig = config @@ -93,4 +99,29 @@ func getEnvInt(key string, defaultValue int) int { } } return defaultValue -} \ No newline at end of file +} + +func getEnvSlice(key string, defaultValue []string) []string { + if value := os.Getenv(key); value != "" { + return importStrings(value) + } + return defaultValue +} + +func importStrings(s string) []string { + var strings []string + current := "" + for _, c := range s { + if c == ',' { + strings = append(strings, current) + current = "" + } else { + current += string(c) + } + } + if current != "" { + strings = append(strings, current) + } + return strings +} + \ No newline at end of file diff --git a/lib/himbucks.go b/lib/himbucks.go index 9995a38..80cf448 100644 --- a/lib/himbucks.go +++ b/lib/himbucks.go @@ -24,12 +24,13 @@ func ProcessHimbucks(s *discordgo.Session, m *discordgo.MessageCreate, ctx *Proc // Get current state var messageCount int var lastRewardAt sql.NullTime + var multiplier float64 err = tx.QueryRow(` - SELECT message_count, last_reward_at + SELECT message_count, last_reward_at, COALESCE(multiplier, 1.0) FROM guild_profiles WHERE user_id = ? AND guild_id = ?`, - ctx.UserID, ctx.GuildID).Scan(&messageCount, &lastRewardAt) + ctx.UserID, ctx.GuildID).Scan(&messageCount, &lastRewardAt, &multiplier) if err != nil { return fmt.Errorf("failed to get message count: %w", err) } @@ -39,13 +40,14 @@ func ProcessHimbucks(s *discordgo.Session, m *discordgo.MessageCreate, ctx *Proc (!lastRewardAt.Valid || time.Since(lastRewardAt.Time) >= AppConfig.CooldownPeriod) if shouldReward { + reward := int(float64(AppConfig.HimbucksPerReward) * multiplier) _, err = tx.Exec(` UPDATE guild_profiles SET currency_balance = currency_balance + ?, message_count = 0, last_reward_at = CURRENT_TIMESTAMP WHERE user_id = ? AND guild_id = ?`, - AppConfig.HimbucksPerReward, ctx.UserID, ctx.GuildID) + reward, ctx.UserID, ctx.GuildID) } else { _, err = tx.Exec(` UPDATE guild_profiles @@ -179,3 +181,138 @@ func GetLeaderboard(guildID string, limit int) ([]HimbucksEntry, error) { } return entries, nil } + +func BuyMultiplier(discordID, guildID string) (float64, int, int, error) { + tx, err := DBClient.Begin() + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + var userID int + err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID) + if err != nil { + return 0, 0, 0, fmt.Errorf("user not found: %w", err) + } + + var balance int + var currentMultiplier float64 + err = tx.QueryRow(` + SELECT currency_balance, COALESCE(multiplier, 1.0) + FROM guild_profiles + WHERE user_id = ? AND guild_id = ?`, + userID, guildID).Scan(&balance, ¤tMultiplier) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to get profile: %w", err) + } + + cost := int(1000 * currentMultiplier) + if balance < cost { + return 0, 0, 0, fmt.Errorf("insufficient funds: have %d, need %d", balance, cost) + } + + newBalance := balance - cost + newMultiplier := currentMultiplier + 0.5 + _, err = tx.Exec(` + UPDATE guild_profiles + SET currency_balance = ?, + multiplier = ? + WHERE user_id = ? AND guild_id = ?`, + newBalance, newMultiplier, userID, guildID) + if err != nil { + return 0, 0, 0, fmt.Errorf("failed to update profile: %w", err) + } + + if err = tx.Commit(); err != nil { + return 0, 0, 0, fmt.Errorf("failed to commit: %w", err) + } + + return newMultiplier, cost, newBalance, nil +} + +func BuyPizza(discordID, guildID string) (int, int, error) { + tx, err := DBClient.Begin() + if err != nil { + return 0, 0, fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + var userID int + err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID) + if err != nil { + return 0, 0, fmt.Errorf("user not found: %w", err) + } + + var balance int + err = tx.QueryRow(` + SELECT currency_balance + FROM guild_profiles + WHERE user_id = ? AND guild_id = ?`, + userID, guildID).Scan(&balance) + if err != nil { + return 0, 0, fmt.Errorf("failed to get profile: %w", err) + } + + cost := 100 + if balance < cost { + return 0, 0, fmt.Errorf("insufficient funds: have %d, need %d", balance, cost) + } + + newBalance := balance - cost + _, err = tx.Exec(` + UPDATE guild_profiles + SET currency_balance = ? + WHERE user_id = ? AND guild_id = ?`, + newBalance, userID, guildID) + if err != nil { + return 0, 0, fmt.Errorf("failed to update balance: %w", err) + } + + if err = tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("failed to commit transaction: %w", err) + } + + return cost, newBalance, nil +} + +func GiveHimbucks(discordID, guildID string, amount int) error { + tx, err := DBClient.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer tx.Rollback() + + var userID int + // Get user ID + err = tx.QueryRow("SELECT id FROM users WHERE discord_id = ?", discordID).Scan(&userID) + if err != nil { + return fmt.Errorf("user not found: %w", err) + } + + result, err := tx.Exec(` + UPDATE guild_profiles + SET currency_balance = currency_balance + ? + WHERE user_id = ? AND guild_id = ?`, + amount, userID, guildID) + + if err != nil { + return fmt.Errorf("failed to update balance: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected: %w", err) + } + + if rows == 0 { + _, err = tx.Exec(` + INSERT INTO guild_profiles (user_id, guild_id, currency_balance, message_count) + VALUES (?, ?, ?, 0)`, + userID, guildID, amount) + if err != nil { + return fmt.Errorf("failed to create profile: %w", err) + } + } + + return tx.Commit() +} diff --git a/main.go b/main.go index 511b029..b7afccc 100644 --- a/main.go +++ b/main.go @@ -251,6 +251,53 @@ func initCommands(config *lib.Config) { }, }, }, + { + Name: "shop", + Description: "Spend your himbucks on cool stuff", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "buy", + Description: "Buy an item", + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "item", + Description: "The item to buy", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "pizza", + Value: "pizza", + }, + { + Name: "multiplier", + Value: "multiplier", + }, + }, + }, + }, + }, + }, + }, + { + Name: "givebucks", + Description: "Admin: Give himbucks to a user", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "The user to give himbucks to", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "amount", + Description: "Amount of himbucks to give (negative to take)", + Required: true, + }, + }, + }, } } @@ -264,5 +311,7 @@ func initCommandHandlers(config *lib.Config) { "himbucks": lib.HandleCommand("himbucks", time.Duration(config.HimbucksCooldown)*time.Second, command.BalanceGetCommand), "himboard": lib.HandleCommand("himboard", time.Duration(config.HimboardCooldown)*time.Second, command.LeaderboardCommand), "sendbucks": lib.HandleCommand("sendbucks", time.Duration(config.SendbucksCooldown)*time.Second, command.BalanceSendCommand), + "shop": lib.HandleCommand("shop", time.Duration(config.ShopCooldown)*time.Second, command.ShopCommand), + "givebucks": lib.HandleCommand("givebucks", time.Duration(config.GivebucksCooldown)*time.Second, command.GiveCommand), } } diff --git a/migrations/000004_add_multiplier_to_guild_profiles.down.sql b/migrations/000004_add_multiplier_to_guild_profiles.down.sql new file mode 100644 index 0000000..b14479e --- /dev/null +++ b/migrations/000004_add_multiplier_to_guild_profiles.down.sql @@ -0,0 +1 @@ +ALTER TABLE guild_profiles DROP COLUMN multiplier; diff --git a/migrations/000004_add_multiplier_to_guild_profiles.up.sql b/migrations/000004_add_multiplier_to_guild_profiles.up.sql new file mode 100644 index 0000000..6f74156 --- /dev/null +++ b/migrations/000004_add_multiplier_to_guild_profiles.up.sql @@ -0,0 +1 @@ +ALTER TABLE guild_profiles ADD COLUMN multiplier REAL DEFAULT 1.0;