diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 4f02f5c..bd272fc 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -9,21 +9,21 @@ import ( ) var AllCommands = []*discordgo.ApplicationCommand{ - fun.PingCommand, - help.HelpCommand, - cmdlevel.LevelConfigCommand, - cmdlevel.RankCommand, - cmdlevel.LeaderboardCommand, - cmdlevel.RewardsCommand, + fun.Ping, + help.Help, + cmdlevel.Slvl, + cmdlevel.Rank, + cmdlevel.Leaderboard, + cmdlevel.Rewards, } var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "ping": fun.HandlePing, - "help": help.HandleHelp, - "slvl": cmdlevel.HandleLevelConfig, - "rank": cmdlevel.HandleRank, - "leaderboard": cmdlevel.HandleLeaderboard, - "rewards": cmdlevel.HandleRewards, + "ping": fun.PingHandler, + "help": help.HelpHandler, + "slvl": cmdlevel.SlvlHandler, + "rank": cmdlevel.RankHandler, + "leaderboard": cmdlevel.LeaderboardHandler, + "rewards": cmdlevel.RewardsHandler, } func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/commands/fun/ping.go b/internal/commands/fun/ping.go index 1567bfd..2810ca5 100644 --- a/internal/commands/fun/ping.go +++ b/internal/commands/fun/ping.go @@ -2,12 +2,12 @@ package fun import "github.com/bwmarrin/discordgo" -var PingCommand = &discordgo.ApplicationCommand{ +var Ping = &discordgo.ApplicationCommand{ Name: "ping", Description: "Ping the bot", } -func HandlePing(s *discordgo.Session, i *discordgo.InteractionCreate) { +func PingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index d3688b8..346c845 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -6,12 +6,12 @@ import ( "github.com/bwmarrin/discordgo" ) -var HelpCommand = &discordgo.ApplicationCommand{ +var Help = &discordgo.ApplicationCommand{ Name: "help", Description: "Get help with the bot", } -func HandleHelp(s *discordgo.Session, i *discordgo.InteractionCreate) { +func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ diff --git a/internal/commands/level/config/command.go b/internal/commands/level/config/command.go new file mode 100644 index 0000000..0a6afed --- /dev/null +++ b/internal/commands/level/config/command.go @@ -0,0 +1,41 @@ +package config + +import ( + "velox-bot/internal/commands/level/shared" + + "github.com/bwmarrin/discordgo" +) + +var Slvl = &discordgo.ApplicationCommand{ + Name: "slvl", + Description: "Leveling System", + Options: []*discordgo.ApplicationCommandOption{ + slvlToggleOption(), + slvlSetLevelOption(), + slvlSetChannelOption(), + slvlSetMessageOption(), + slvlSetRewardOption(), + }, +} + +func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + return + } + switch data.Options[0].Name { + case "toggle": + slvlToggleHandler(s, i) + case "set": + slvlSetLevelHandler(s, i) + case "set-channel": + slvlSetChannelHandler(s, i) + case "set-message": + slvlSetMessageHandler(s, i) + case "set-reward": + slvlSetRewardHandler(s, i) + default: + shared.RespondEphemeral(s, i, "Invalid subcommand.") + } +} + diff --git a/internal/commands/level/config/set_channel.go b/internal/commands/level/config/set_channel.go new file mode 100644 index 0000000..e2994e4 --- /dev/null +++ b/internal/commands/level/config/set_channel.go @@ -0,0 +1,68 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetChannelOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-channel", + Description: "Set the level up channel", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Channel for level-up messages", + Required: true, + }, + }, + } +} + +func slvlSetChannelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + channelOpt, ok := optMap["channel"] + if !ok { + shared.RespondEphemeral(s, i, "Missing channel option.") + return + } + + ch := channelOpt.ChannelValue(s) + if ch == nil { + shared.RespondEphemeral(s, i, "Invalid channel.") + return + } + + channelID, err := strconv.ParseInt(ch.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid channel ID.") + return + } + + if err := services.Global.LevelSettings.SetLevelUpChannel(context.Background(), guildID, channelID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level up channel.") + return + } + + shared.RespondEphemeral(s, i, "Level up channel set to "+ch.Mention()+".") +} + diff --git a/internal/commands/level/config/set_level.go b/internal/commands/level/config/set_level.go new file mode 100644 index 0000000..b3e5c43 --- /dev/null +++ b/internal/commands/level/config/set_level.go @@ -0,0 +1,84 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetLevelOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set", + Description: "Set the level of a user", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "The user to set the level of", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "level", + Description: "The level to set the user to", + Required: true, + }, + }, + } +} + +func slvlSetLevelHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if services.Global == nil || services.Global.Level == nil { + shared.RespondEphemeral(s, i, "Leveling service is not available.") + return + } + + optMap := shared.SubcommandOptionMap(i) + userOpt, ok := optMap["user"] + if !ok { + shared.RespondEphemeral(s, i, "Missing user option.") + return + } + levelOpt, ok := optMap["level"] + if !ok { + shared.RespondEphemeral(s, i, "Missing level option.") + return + } + + targetUser := userOpt.UserValue(s) + if targetUser == nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + if targetUser.Bot { + shared.RespondEphemeral(s, i, "You can't set a bot's level.") + return + } + + newLevel := int(levelOpt.IntValue()) + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + userID, err := strconv.ParseInt(targetUser.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + if err := services.Global.Level.SetLevel(context.Background(), guildID, userID, newLevel); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level.") + return + } + + shared.RespondEphemeral(s, i, "Level set to "+strconv.Itoa(newLevel)+" for "+targetUser.Mention()+".") +} + diff --git a/internal/commands/level/config/set_message.go b/internal/commands/level/config/set_message.go new file mode 100644 index 0000000..839532e --- /dev/null +++ b/internal/commands/level/config/set_message.go @@ -0,0 +1,60 @@ +package config + +import ( + "context" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetMessageOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-message", + Description: "Set the level up message", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "message", + Description: "Level-up message. Variables: {user}, {level}", + Required: true, + }, + }, + } +} + +func slvlSetMessageHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + messageOpt, ok := optMap["message"] + if !ok { + shared.RespondEphemeral(s, i, "Missing message option.") + return + } + + message := messageOpt.StringValue() + if message == "" { + message = "GG {user}, you reached level {level}!" + } + + if err := services.Global.LevelSettings.SetLevelUpMessage(context.Background(), guildID, message); err != nil { + shared.RespondEphemeral(s, i, "Failed to set level up message.") + return + } + + shared.RespondEphemeral(s, i, "Level up message set.") +} + diff --git a/internal/commands/level/config/set_reward.go b/internal/commands/level/config/set_reward.go new file mode 100644 index 0000000..1940121 --- /dev/null +++ b/internal/commands/level/config/set_reward.go @@ -0,0 +1,79 @@ +package config + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlSetRewardOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set-reward", + Description: "Set role reward for a level", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "level", + Description: "Level to reward", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionRole, + Name: "role", + Description: "Role to grant", + Required: true, + }, + }, + } +} + +func slvlSetRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + optMap := shared.SubcommandOptionMap(i) + levelOpt, ok := optMap["level"] + if !ok { + shared.RespondEphemeral(s, i, "Missing level option.") + return + } + roleOpt, ok := optMap["role"] + if !ok { + shared.RespondEphemeral(s, i, "Missing role option.") + return + } + + level := int(levelOpt.IntValue()) + role := roleOpt.RoleValue(s, i.GuildID) + if role == nil { + shared.RespondEphemeral(s, i, "Invalid role.") + return + } + roleID, err := strconv.ParseInt(role.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid role ID.") + return + } + + if err := services.Global.LevelSettings.SetRoleRewardForLevel(context.Background(), guildID, level, roleID); err != nil { + shared.RespondEphemeral(s, i, "Failed to set reward.") + return + } + + shared.RespondEphemeral(s, i, "Reward set: level "+strconv.Itoa(level)+" -> "+role.Mention()) +} + diff --git a/internal/commands/level/config/toggle.go b/internal/commands/level/config/toggle.go new file mode 100644 index 0000000..3ffb9ad --- /dev/null +++ b/internal/commands/level/config/toggle.go @@ -0,0 +1,43 @@ +package config + +import ( + "context" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +func slvlToggleOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "toggle", + Description: "Toggle the leveling system", + } +} + +func slvlToggleHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireManageGuild(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + enabled, err := services.Global.LevelSettings.ToggleLevelSystem(context.Background(), guildID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to toggle leveling system.") + return + } + status := "disabled" + if enabled { + status = "enabled" + } + shared.RespondEphemeral(s, i, "Leveling system "+status+" for this server.") +} + diff --git a/internal/commands/level/level.go b/internal/commands/level/level.go deleted file mode 100644 index 4cad7dc..0000000 --- a/internal/commands/level/level.go +++ /dev/null @@ -1,218 +0,0 @@ -package level - -import ( - "context" - "bytes" - "strconv" - "fmt" - - "velox-bot/internal/db/services" - "velox-bot/internal/rankcard" - - "github.com/bwmarrin/discordgo" -) - -var RankCommand = &discordgo.ApplicationCommand{ - Name: "rank", - Description: "Get your level card", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionUser, - Name: "user", - Description: "The user to get the rank card of", - Required: false, - }, - }, -} - -var LeaderboardCommand = &discordgo.ApplicationCommand{ - Name: "leaderboard", - Description: "Top 10 levels in this server", -} - -var RewardsCommand = &discordgo.ApplicationCommand{ - Name: "rewards", - Description: "View level rewards in this server", -} - -func HandleRank(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireLevelServices(s, i) { - return - } - - if i.GuildID != "" && !requireLevelSystemEnabled(s, i) { - respondEphemeral(s, i, "Leveling system is disabled on this server.") - return - } - - // Determine target user: option or caller. - target := i.User - if i.Member != nil && i.Member.User != nil { - target = i.Member.User - } - - if len(i.ApplicationCommandData().Options) > 0 { - if u := i.ApplicationCommandData().Options[0].UserValue(s); u != nil { - target = u - } - } - - if target == nil { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - userID, err := strconv.ParseInt(target.ID, 10, 64) - if err != nil { - respondEphemeral(s, i, "Invalid user ID.") - return - } - - ctx := context.Background() - lvl, err := services.Global.Level.GetLevel(ctx, guildID, userID) - if err != nil { - respondEphemeral(s, i, "Failed to load level.") - return - } - - percent := float64(lvl.XP) / 100.0 - avatarURL := target.AvatarURL("256") - - pngBytes, err := rankcard.RenderPNG(ctx, rankcard.Data{ - Name: target.Username, - Level: lvl.Level, - XP: lvl.XP, - Percent: percent, - Avatar: avatarURL, - }) - if err != nil { - respondEphemeral(s, i, "Failed to render rank card.") - return - } - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Files: []*discordgo.File{ - { - Name: "levelcard.png", - Reader: bytes.NewReader(pngBytes), - }, - }, - }, - }) -} - -func HandleLeaderboard(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireGuild(s, i) { - return - } - - if !requireLevelServices(s, i) { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - ctx := context.Background() - if !requireLevelSystemEnabled(s, i) { - return - } - top, err := services.Global.Level.TopLevels(ctx, guildID, 10) - if err != nil { - respondEphemeral(s, i, "Failed to load leaderboard.") - return - } - - if len(top) == 0 { - respondEphemeral(s, i, "No leaderboard data yet.") - return - } - - fields := make([]*discordgo.MessageEmbedField, 0, len(top)) - for idx, entry := range top { - display := fmt.Sprintf("<@%d>", entry.UserID) - if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { - display = u.Username - } - - fields = append(fields, &discordgo.MessageEmbedField{ - Name: fmt.Sprintf("%d. %s", idx+1, display), - Value: fmt.Sprintf("Level: **%d** | XP: **%d**", entry.Level, entry.XP), - Inline: false, - }) - } - - embed := &discordgo.MessageEmbed{ - Title: "Leaderboard", - Description: fmt.Sprintf("These are the top %d users in the server", len(top)), - Fields: fields, - Color: 0xF59E0B, - } - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{embed}, - }, - }) -} - -func HandleRewards(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireGuild(s, i) { - return - } - - if !requireLevelSettingsService(s, i) { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - ctx := context.Background() - if !requireLevelSystemEnabled(s, i) { - return - } - rewards, err := services.Global.LevelSettings.ListRoleRewards(ctx, guildID, 25) - if err != nil { - respondEphemeral(s, i, "Failed to load rewards.") - return - } - - if len(rewards) == 0 { - respondEphemeral(s, i, "No rewards configured yet.") - return - } - - fields := make([]*discordgo.MessageEmbedField, 0, len(rewards)) - for _, r := range rewards { - fields = append(fields, &discordgo.MessageEmbedField{ - Name: fmt.Sprintf("Level %d", r.Level), - Value: fmt.Sprintf("<@&%d>", r.RoleID), - Inline: true, - }) - } - - embed := &discordgo.MessageEmbed{ - Title: "Level Rewards", - Description: "Roles granted when you reach a level.", - Fields: fields, - Color: 0xF59E0B, - } - - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{embed}, - }, - }) -} diff --git a/internal/commands/level/level_config.go b/internal/commands/level/level_config.go deleted file mode 100644 index b8a3ac6..0000000 --- a/internal/commands/level/level_config.go +++ /dev/null @@ -1,326 +0,0 @@ -package level - -import ( - "context" - "strconv" - - "velox-bot/internal/db/services" - - "github.com/bwmarrin/discordgo" -) - -var LevelConfigCommand = &discordgo.ApplicationCommand{ - Name: "slvl", - Description: "Leveling System", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "toggle", - Description: "Toggle the leveling system", - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set", - Description: "Set the level of a user", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionUser, - Name: "user", - Description: "The user to set the level of", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "level", - Description: "The level to set the user to", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set-channel", - Description: "Set the level up channel", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionChannel, - Name: "channel", - Description: "The channel to set the level up channel to. If not provided, the current channel will be used.", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set-message", - Description: "Set the level up message", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "message", - Description: "Level-up message. Variables: {user}, {level}", - Required: true, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set-reward", - Description: "Set role reward for a level", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionInteger, - Name: "level", - Description: "Level to reward", - Required: true, - }, - { - Type: discordgo.ApplicationCommandOptionRole, - Name: "role", - Description: "Role to grant", - Required: true, - }, - }, - }, - }, -} - -func HandleLevelConfig(s *discordgo.Session, i *discordgo.InteractionCreate) { - - data := i.ApplicationCommandData() - subCommand := data.Options[0].Name - - switch subCommand { - case "toggle": - HandleLevelToggle(s, i) - case "set": - HandleLevelSet(s, i) - case "set-channel": - HandleLevelSetChannel(s, i) - case "set-message": - HandleLevelSetMessage(s, i) - case "set-reward": - HandleLevelSetReward(s, i) - default: - respondEphemeral(s, i, "Invalid subcommand.") - } - -} - -func HandleLevelToggle(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireManageGuild(s, i) { - return - } - - if !requireLevelSettingsService(s, i) { - return - } - - ctx := context.Background() - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - enabled, err := services.Global.LevelSettings.ToggleLevelSystem(ctx, guildID) - if err != nil { - respondEphemeral(s, i, "Failed to toggle leveling system.") - return - } - - status := "disabled" - if enabled { - status = "enabled" - } - - respondEphemeral(s, i, "Leveling system "+status+" for this server.") -} - -func HandleLevelSet(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireManageGuild(s, i) || !requireLevelSystemEnabled(s, i) { - return - } - - if services.Global == nil || services.Global.Level == nil { - respondEphemeral(s, i, "Leveling service is not available.") - return - } - - optMap := subcommandOptionMap(i) - - userOpt, ok := optMap["user"] - if !ok { - respondEphemeral(s, i, "Missing user option.") - return - } - - levelOpt, ok := optMap["level"] - if !ok { - respondEphemeral(s, i, "Missing level option.") - return - } - - targetUser := userOpt.UserValue(s) - if targetUser == nil { - respondEphemeral(s, i, "Invalid user.") - return - } - - // prevent setting bots (including ourselves) - if targetUser.Bot { - respondEphemeral(s, i, "You can't set a bot's level.") - return - } - - newLevel := int(levelOpt.IntValue()) - - ctx := context.Background() - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - userID, err := strconv.ParseInt(targetUser.ID, 10, 64) - if err != nil { - respondEphemeral(s, i, "Invalid user ID.") - return - } - - err = services.Global.Level.SetLevel(ctx, guildID, userID, newLevel) - if err != nil { - respondEphemeral(s, i, "Failed to set level.") - return - } - - respondEphemeral(s, i, "Level set to "+strconv.Itoa(newLevel)+" for "+targetUser.Mention()+".") -} - -func HandleLevelSetChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireManageGuild(s, i) || !requireLevelSystemEnabled(s, i) { - return - } - - if !requireLevelSettingsService(s, i) { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - optMap := subcommandOptionMap(i) - - channelOpt, ok := optMap["channel"] - if !ok { - respondEphemeral(s, i, "Missing channel option.") - return - } - - channelID := channelOpt.ChannelValue(s) - if channelID == nil { - respondEphemeral(s, i, "Invalid channel.") - return - } - - ctx := context.Background() - channelIDInt, err := strconv.ParseInt(channelID.ID, 10, 64) - if err != nil { - respondEphemeral(s, i, "Invalid channel ID.") - return - } - - err = services.Global.LevelSettings.SetLevelUpChannel(ctx, guildID, channelIDInt) - if err != nil { - respondEphemeral(s, i, "Failed to set level up channel.") - return - } - - respondEphemeral(s, i, "Level up channel set to "+channelID.Mention()+".") -} - -func HandleLevelSetMessage(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireManageGuild(s, i) || !requireLevelSystemEnabled(s, i) { - return - } - - if !requireLevelSettingsService(s, i) { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - optMap := subcommandOptionMap(i) - - messageOpt, ok := optMap["message"] - if !ok { - respondEphemeral(s, i, "Missing message option.") - return - } - - message := messageOpt.StringValue() - if message == "" { - message = "GG {user}, you reached level {level}!" - } - - ctx := context.Background() - err := services.Global.LevelSettings.SetLevelUpMessage(ctx, guildID, message) - if err != nil { - respondEphemeral(s, i, "Failed to set level up message.") - return - } - - respondEphemeral(s, i, "Level up message set.") -} - -func HandleLevelSetReward(s *discordgo.Session, i *discordgo.InteractionCreate) { - if !requireManageGuild(s, i) || !requireLevelSystemEnabled(s, i) { - return - } - - if !requireLevelSettingsService(s, i) { - return - } - - guildID, ok := parseGuildID(s, i) - if !ok { - return - } - - optMap := subcommandOptionMap(i) - - levelOpt, ok := optMap["level"] - if !ok { - respondEphemeral(s, i, "Missing level option.") - return - } - roleOpt, ok := optMap["role"] - if !ok { - respondEphemeral(s, i, "Missing role option.") - return - } - - level := int(levelOpt.IntValue()) - role := roleOpt.RoleValue(s, i.GuildID) - if role == nil { - respondEphemeral(s, i, "Invalid role.") - return - } - - roleID, err := strconv.ParseInt(role.ID, 10, 64) - if err != nil { - respondEphemeral(s, i, "Invalid role ID.") - return - } - - ctx := context.Background() - if err := services.Global.LevelSettings.SetRoleRewardForLevel(ctx, guildID, level, roleID); err != nil { - respondEphemeral(s, i, "Failed to set reward.") - return - } - - respondEphemeral(s, i, "Reward set: level "+strconv.Itoa(level)+" -> "+role.Mention()) -} diff --git a/internal/commands/level/public/leaderboard.go b/internal/commands/level/public/leaderboard.go new file mode 100644 index 0000000..3c4ca1e --- /dev/null +++ b/internal/commands/level/public/leaderboard.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Leaderboard = &discordgo.ApplicationCommand{ + Name: "leaderboard", + Description: "Top 10 levels in this server", +} + +func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) { + return + } + if !shared.RequireLevelServices(s, i) { + return + } + if !shared.RequireLevelSystemEnabled(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + ctx := context.Background() + top, err := services.Global.Level.TopLevels(ctx, guildID, 10) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load leaderboard.") + return + } + if len(top) == 0 { + shared.RespondEphemeral(s, i, "No leaderboard data yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(top)) + for idx, entry := range top { + display := fmt.Sprintf("<@%d>", entry.UserID) + if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { + display = u.Username + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("%d. %s", idx+1, display), + Value: fmt.Sprintf("Level: **%d** | XP: **%d**", entry.Level, entry.XP), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "Leaderboard", + Description: fmt.Sprintf("These are the top %d users in the server", len(top)), + Fields: fields, + Color: 0xF59E0B, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/level/public/rank.go b/internal/commands/level/public/rank.go new file mode 100644 index 0000000..4866681 --- /dev/null +++ b/internal/commands/level/public/rank.go @@ -0,0 +1,94 @@ +package public + +import ( + "bytes" + "context" + "strconv" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + "velox-bot/internal/rankcard" + + "github.com/bwmarrin/discordgo" +) + +var Rank = &discordgo.ApplicationCommand{ + Name: "rank", + Description: "Get your level card", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "The user to get the rank card of", + Required: false, + }, + }, +} + +func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireLevelServices(s, i) { + return + } + if i.GuildID != "" && !shared.RequireLevelSystemEnabled(s, i) { + return + } + + // Determine target user: option or caller. + target := i.User + if i.Member != nil && i.Member.User != nil { + target = i.Member.User + } + if len(i.ApplicationCommandData().Options) > 0 { + if u := i.ApplicationCommandData().Options[0].UserValue(s); u != nil { + target = u + } + } + if target == nil { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + userID, err := strconv.ParseInt(target.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user ID.") + return + } + + ctx := context.Background() + lvl, err := services.Global.Level.GetLevel(ctx, guildID, userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load level.") + return + } + + percent := float64(lvl.XP) / 100.0 + avatarURL := target.AvatarURL("256") + + pngBytes, err := rankcard.RenderPNG(ctx, rankcard.Data{ + Name: target.Username, + Level: lvl.Level, + XP: lvl.XP, + Percent: percent, + Avatar: avatarURL, + }) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to render rank card.") + return + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Files: []*discordgo.File{ + { + Name: "levelcard.png", + Reader: bytes.NewReader(pngBytes), + }, + }, + }, + }) +} + diff --git a/internal/commands/level/public/rewards.go b/internal/commands/level/public/rewards.go new file mode 100644 index 0000000..bc1ae1a --- /dev/null +++ b/internal/commands/level/public/rewards.go @@ -0,0 +1,68 @@ +package public + +import ( + "context" + "fmt" + + "velox-bot/internal/commands/level/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var Rewards = &discordgo.ApplicationCommand{ + Name: "rewards", + Description: "View level rewards in this server", +} + +func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !shared.RequireGuild(s, i) { + return + } + if !shared.RequireLevelSettingsService(s, i) { + return + } + if !shared.RequireLevelSystemEnabled(s, i) { + return + } + + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + ctx := context.Background() + rewards, err := services.Global.LevelSettings.ListRoleRewards(ctx, guildID, 25) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load rewards.") + return + } + if len(rewards) == 0 { + shared.RespondEphemeral(s, i, "No rewards configured yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(rewards)) + for _, r := range rewards { + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("Level %d", r.Level), + Value: fmt.Sprintf("<@&%d>", r.RoleID), + Inline: true, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "Level Rewards", + Description: "Roles granted when you reach a level.", + Fields: fields, + Color: 0xF59E0B, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/level/registry.go b/internal/commands/level/registry.go new file mode 100644 index 0000000..7a2af42 --- /dev/null +++ b/internal/commands/level/registry.go @@ -0,0 +1,23 @@ +package level + +import ( + "velox-bot/internal/commands/level/config" + "velox-bot/internal/commands/level/public" + + "github.com/bwmarrin/discordgo" +) + +// Commands +var ( + Rank *discordgo.ApplicationCommand = public.Rank + Leaderboard *discordgo.ApplicationCommand = public.Leaderboard + Rewards *discordgo.ApplicationCommand = public.Rewards + Slvl *discordgo.ApplicationCommand = config.Slvl +) + +// Handlers +func RankHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RankHandler(s, i) } +func LeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.LeaderboardHandler(s, i) } +func RewardsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RewardsHandler(s, i) } +func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { config.SlvlHandler(s, i) } + diff --git a/internal/commands/level/helpers.go b/internal/commands/level/shared/shared.go similarity index 62% rename from internal/commands/level/helpers.go rename to internal/commands/level/shared/shared.go index 9e471dc..d158d10 100644 --- a/internal/commands/level/helpers.go +++ b/internal/commands/level/shared/shared.go @@ -1,4 +1,4 @@ -package level +package shared import ( "context" @@ -9,7 +9,7 @@ import ( "github.com/bwmarrin/discordgo" ) -func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { +func RespondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ @@ -19,68 +19,68 @@ func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg }) } -func requireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { +func RequireGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if i.GuildID == "" { - respondEphemeral(s, i, "This command can only be used in a server.") + RespondEphemeral(s, i, "This command can only be used in a server.") return false } return true } -func requireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { +func RequireManageGuild(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if i.Member == nil || (i.Member.Permissions&discordgo.PermissionManageGuild) == 0 { - respondEphemeral(s, i, "You don't have permission to manage leveling settings.") + RespondEphemeral(s, i, "You don't have permission to manage leveling settings.") return false } return true } -func requireLevelServices(s *discordgo.Session, i *discordgo.InteractionCreate) bool { +func RequireLevelServices(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if services.Global == nil || services.Global.Level == nil || services.Global.LevelSettings == nil { - respondEphemeral(s, i, "Leveling service is not available.") + RespondEphemeral(s, i, "Leveling service is not available.") return false } return true } -func requireLevelSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { +func RequireLevelSettingsService(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if services.Global == nil || services.Global.LevelSettings == nil { - respondEphemeral(s, i, "Leveling service is not available.") + RespondEphemeral(s, i, "Leveling service is not available.") return false } return true } -func parseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { +func ParseGuildID(s *discordgo.Session, i *discordgo.InteractionCreate) (int64, bool) { guildID, err := strconv.ParseInt(i.GuildID, 10, 64) if err != nil { - respondEphemeral(s, i, "Invalid guild ID.") + RespondEphemeral(s, i, "Invalid guild ID.") return 0, false } return guildID, true } -func requireLevelSystemEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool { - if !requireLevelSettingsService(s, i) { +func RequireLevelSystemEnabled(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if !RequireLevelSettingsService(s, i) { return false } - guildID, ok := parseGuildID(s, i) + guildID, ok := ParseGuildID(s, i) if !ok { return false } enabled, err := services.Global.LevelSettings.IsLevelSystemEnabled(context.Background(), guildID) if err != nil { - respondEphemeral(s, i, "Failed to load leveling settings.") + RespondEphemeral(s, i, "Failed to load leveling settings.") return false } if !enabled { - respondEphemeral(s, i, "Leveling system is disabled on this server. Use /slvl toggle to enable it.") + RespondEphemeral(s, i, "Leveling system is disabled on this server. Use /slvl toggle to enable it.") return false } return true } -func subcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { +func SubcommandOptionMap(i *discordgo.InteractionCreate) map[string]*discordgo.ApplicationCommandInteractionDataOption { data := i.ApplicationCommandData() if len(data.Options) == 0 { return map[string]*discordgo.ApplicationCommandInteractionDataOption{} diff --git a/tmp/bot b/tmp/bot index c17d945..7c7182b 100755 Binary files a/tmp/bot and b/tmp/bot differ diff --git a/tmp/build-errors.log b/tmp/build-errors.log index 38667d6..0aa6797 100644 --- a/tmp/build-errors.log +++ b/tmp/build-errors.log @@ -1 +1 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file