diff --git a/main.go b/main.go index 1fc76975..cab604bf 100644 --- a/main.go +++ b/main.go @@ -46,6 +46,7 @@ const slashAdminGetContractData string = "admin-get-contract-data" const slashAdminListRoles string = "list-roles" const slashAdminSetGuildSetting string = "admin-set-guild-setting" const slashAdminGetGuildSettings string = "admin-get-guild-settings" +const slashAdminGuildstate string = "admin-guildstate" // Slash Command Constants const slashContract string = "contract" @@ -210,6 +211,7 @@ var ( }, boost.SlashAdminGetContractData(slashAdminGetContractData), boost.SlashAdminListRoles(slashAdminListRoles), + boost.SlashAdminGuildStateCommand(slashAdminGuildstate), guildstate.SlashSetGuildSettingCommand(slashAdminSetGuildSetting), guildstate.SlashGetGuildSettingsCommand(slashAdminGetGuildSettings), } @@ -385,6 +387,9 @@ var ( slashAdminListRoles: func(s *discordgo.Session, i *discordgo.InteractionCreate) { boost.HandleContractAutoComplete(s, i) }, + slashAdminGuildstate: func(s *discordgo.Session, i *discordgo.InteractionCreate) { + boost.HandleAdminGuildStateAutoComplete(s, i) + }, slashToken: func(s *discordgo.Session, i *discordgo.InteractionCreate) { boost.HandleContractAutoComplete(s, i) }, @@ -522,6 +527,9 @@ var ( slashAdminListRoles: func(s *discordgo.Session, i *discordgo.InteractionCreate) { boost.HandleAdminListRoles(s, i) }, + slashAdminGuildstate: func(s *discordgo.Session, i *discordgo.InteractionCreate) { + boost.HandleAdminGuildStateCommand(s, i) + }, slashAdminSetGuildSetting: func(s *discordgo.Session, i *discordgo.InteractionCreate) { guildstate.SetGuildSetting(s, i) }, @@ -1075,6 +1083,26 @@ func main() { commandSet = append(commandSet, adminCommands...) + // Restrict some commands to a specific guild if the home_guild setting is set, to allow for faster iteration during development without affecting the global command set that everyone uses + homeGuild := guildstate.GetGuildSettingString("DEFAULT", "home_guild") + if homeGuild == "" { + homeGuild = "DISABLED" + } + + var homeGuildCommandSet []*discordgo.ApplicationCommand + var filteredCommandSet []*discordgo.ApplicationCommand + for _, cmd := range commandSet { + if homeGuild != "" && cmd.GuildID == homeGuild { + homeGuildCommandSet = append(homeGuildCommandSet, cmd) + } else { + filteredCommandSet = append(filteredCommandSet, cmd) + } + } + commandSet = filteredCommandSet + + if homeGuild != "DISABLED" { + syncCommands(s, homeGuild, homeGuildCommandSet) + } syncCommands(s, config.DiscordGuildID, commandSet) defer func() { diff --git a/src/boost/boost_admin.go b/src/boost/boost_admin.go index ffe173ea..016c1c22 100644 --- a/src/boost/boost_admin.go +++ b/src/boost/boost_admin.go @@ -14,7 +14,14 @@ import ( "github.com/bwmarrin/discordgo" "github.com/mkmccarty/TokenTimeBoostBot/src/bottools" + "github.com/mkmccarty/TokenTimeBoostBot/src/config" "github.com/mkmccarty/TokenTimeBoostBot/src/ei" + "github.com/mkmccarty/TokenTimeBoostBot/src/guildstate" +) + +const ( + adminGuildStateActionSet = "set-guild-setting" + adminGuildStateActionGet = "get-guild-settings" ) // SlashAdminGetContractData is the slash to get contract JSON data @@ -69,6 +76,185 @@ func SlashAdminListRoles(cmd string) *discordgo.ApplicationCommand { } } +// SlashAdminGuildStateCommand provides a generic entrypoint for guildstate admin actions. +func SlashAdminGuildStateCommand(cmd string) *discordgo.ApplicationCommand { + var adminPermission = int64(0) + + guildID := guildstate.GetGuildSettingString("DEFAULT", "home_guild") + if guildID == "" { + guildID = "DISABLED" + } + + return &discordgo.ApplicationCommand{ + Name: cmd, + Description: "Run guildstate admin command with guild override", + GuildID: guildID, + DefaultMemberPermissions: &adminPermission, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, + IntegrationTypes: &[]discordgo.ApplicationIntegrationType{ + discordgo.ApplicationIntegrationGuildInstall, + }, + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "action", + Description: "Guildstate command to run", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "set-guild-setting", Value: adminGuildStateActionSet}, + {Name: "get-guild-settings", Value: adminGuildStateActionGet}, + }, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "guild-id", + Description: "Guild ID override (from persisted guildstate)", + Required: true, + Autocomplete: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "setting", + Description: "Setting key (used by set-guild-setting)", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "value", + Description: "Optional value (used by set-guild-setting; blank clears)", + Required: false, + }, + }, + } +} + +func isAdminCommandCaller(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + userID := getInteractionUserID(i) + perms, err := s.UserChannelPermissions(userID, i.ChannelID) + if err != nil { + log.Println(err) + } + return perms&discordgo.PermissionAdministrator != 0 || userID == config.AdminUserID +} + +// HandleAdminGuildStateAutoComplete serves guild-id suggestions from persisted guildstate keys. +func HandleAdminGuildStateAutoComplete(s *discordgo.Session, i *discordgo.InteractionCreate) { + optionMap := bottools.GetCommandOptionsMap(i) + search := "" + if opt, ok := optionMap["guild-id"]; ok { + search = strings.TrimSpace(opt.StringValue()) + } + + ids, err := guildstate.GetAllGuildIDs() + if err != nil { + log.Println(err) + ids = []string{} + } + + searchLower := strings.ToLower(search) + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, 25) + for _, id := range ids { + choiceName := id + if guild, guildErr := s.Guild(id); guildErr == nil && guild != nil { + guildName := strings.TrimSpace(guild.Name) + if guildName != "" { + choiceName = fmt.Sprintf("%s (%s)", guildName, id) + } + } + + if searchLower != "" && !strings.Contains(strings.ToLower(choiceName), searchLower) && !strings.Contains(strings.ToLower(id), searchLower) { + continue + } + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: choiceName, Value: id}) + if len(choices) >= 25 { + break + } + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Content: "Guild IDs", + Choices: choices, + }, + }) +} + +// HandleAdminGuildStateCommand routes to guildstate handlers with explicit guild override. +func HandleAdminGuildStateCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !isAdminCommandCaller(s, i) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You are not authorized to use this command.", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{}, + }, + }) + return + } + + optionMap := bottools.GetCommandOptionsMap(i) + action := "" + guildID := "" + setting := "" + value := "" + + if opt, ok := optionMap["action"]; ok { + action = strings.TrimSpace(opt.StringValue()) + } + if opt, ok := optionMap["guild-id"]; ok { + guildID = strings.TrimSpace(opt.StringValue()) + } + if opt, ok := optionMap["setting"]; ok { + setting = strings.TrimSpace(opt.StringValue()) + } + if opt, ok := optionMap["value"]; ok { + value = strings.TrimSpace(opt.StringValue()) + } + + if guildID == "" { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "guild-id is required.", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{}, + }, + }) + return + } + + switch action { + case adminGuildStateActionSet: + if setting == "" { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "setting is required when action is set-guild-setting.", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{}, + }, + }) + return + } + guildstate.SetGuildSettingForGuild(s, i, guildID, setting, value) + case adminGuildStateActionGet: + guildstate.GetGuildSettingsForGuild(s, i, guildID) + default: + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "action must be one of: set-guild-setting, get-guild-settings", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{}, + }, + }) + } +} + // HandleAdminListRoles is the handler for the list roles command func HandleAdminListRoles(s *discordgo.Session, i *discordgo.InteractionCreate) { var contractID string diff --git a/src/guildstate/slashcmd.go b/src/guildstate/slashcmd.go index a878a8a4..39d12835 100644 --- a/src/guildstate/slashcmd.go +++ b/src/guildstate/slashcmd.go @@ -166,6 +166,14 @@ func classifySnowflake(s *discordgo.Session, guildID, id string) string { return fmt.Sprintf("user (%s)", name) } + if g, err := s.Guild(id); err == nil && g != nil { + name := strings.TrimSpace(g.Name) + if name == "" { + return "guild" + } + return fmt.Sprintf("guild (%s)", name) + } + return "unknown snowflake" } @@ -199,36 +207,33 @@ func splitCSV(value string) []string { return items } -// SetGuildSetting handles the admin slash command for setting or clearing guild settings. -func SetGuildSetting(s *discordgo.Session, i *discordgo.InteractionCreate) { +func getGuildDisplayName(s *discordgo.Session, guildID string) string { + guildName := guildID + if dgGuild, guildErr := s.Guild(guildID); guildErr == nil && dgGuild != nil { + if strings.TrimSpace(dgGuild.Name) != "" { + guildName = dgGuild.Name + } + } + return guildName +} + +// SetGuildSettingForGuild sets or clears a guild setting for a specific guild ID. +func SetGuildSettingForGuild(s *discordgo.Session, i *discordgo.InteractionCreate, guildID, setting, value string) { if !isAdminCaller(s, i) { respondEphemeral(s, i, "You are not authorized to use this command.") return } - optionMap := bottools.GetCommandOptionsMap(i) - guildID := i.GuildID - setting := "" - value := "" - - if opt, ok := optionMap["setting"]; ok { - setting = strings.TrimSpace(opt.StringValue()) - } - if opt, ok := optionMap["value"]; ok { - value = strings.TrimSpace(opt.StringValue()) - } + guildID = strings.TrimSpace(guildID) + setting = strings.TrimSpace(setting) + value = strings.TrimSpace(value) if guildID == "" { - respondEphemeral(s, i, "This command can only be used in a server.") + respondEphemeral(s, i, "Guild ID is required.") return } - guildName := guildID - if dgGuild, guildErr := s.Guild(guildID); guildErr == nil && dgGuild != nil { - if strings.TrimSpace(dgGuild.Name) != "" { - guildName = dgGuild.Name - } - } + guildName := getGuildDisplayName(s, guildID) if setting == "" { respondEphemeral(s, i, "setting is required.") @@ -263,26 +268,21 @@ func SetGuildSetting(s *discordgo.Session, i *discordgo.InteractionCreate) { respondEphemeral(s, i, builder.String()) } -// GetGuildSettings handles the admin slash command for retrieving all guild settings. -func GetGuildSettings(s *discordgo.Session, i *discordgo.InteractionCreate) { +// GetGuildSettingsForGuild retrieves all persisted guild settings for a specific guild ID. +func GetGuildSettingsForGuild(s *discordgo.Session, i *discordgo.InteractionCreate, guildID string) { if !isAdminCaller(s, i) { respondEphemeral(s, i, "You are not authorized to use this command.") return } - guildID := i.GuildID - guildName := guildID - if dgGuild, guildErr := s.Guild(guildID); guildErr == nil && dgGuild != nil { - if strings.TrimSpace(dgGuild.Name) != "" { - guildName = dgGuild.Name - } - } - + guildID = strings.TrimSpace(guildID) if guildID == "" { - respondEphemeral(s, i, "This command can only be used in a server.") + respondEphemeral(s, i, "Guild ID is required.") return } + guildName := getGuildDisplayName(s, guildID) + guild, err := GetGuildState(guildID) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -349,3 +349,23 @@ func GetGuildSettings(s *discordgo.Session, i *discordgo.InteractionCreate) { respondEphemeral(s, i, builder.String()) } + +// SetGuildSetting handles the admin slash command for setting or clearing guild settings. +func SetGuildSetting(s *discordgo.Session, i *discordgo.InteractionCreate) { + optionMap := bottools.GetCommandOptionsMap(i) + setting := "" + value := "" + + if opt, ok := optionMap["setting"]; ok { + setting = strings.TrimSpace(opt.StringValue()) + } + if opt, ok := optionMap["value"]; ok { + value = strings.TrimSpace(opt.StringValue()) + } + SetGuildSettingForGuild(s, i, i.GuildID, setting, value) +} + +// GetGuildSettings handles the admin slash command for retrieving all guild settings. +func GetGuildSettings(s *discordgo.Session, i *discordgo.InteractionCreate) { + GetGuildSettingsForGuild(s, i, i.GuildID) +} diff --git a/src/guildstate/state.go b/src/guildstate/state.go index f33fc80f..bb810dfc 100644 --- a/src/guildstate/state.go +++ b/src/guildstate/state.go @@ -6,6 +6,7 @@ import ( _ "embed" // Required for go:embed. "encoding/json" "log" + "sort" "time" _ "modernc.org/sqlite" // SQLite driver registration. @@ -131,6 +132,30 @@ func GetAllGuildState() ([]GuildState, error) { return items, nil } +// GetAllGuildIDs returns all persisted guild IDs sorted ascending. +func GetAllGuildIDs() ([]string, error) { + items, err := GetAllGuildState() + if err != nil { + return nil, err + } + + ids := make([]string, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + if item.GuildID == "" { + continue + } + if _, ok := seen[item.GuildID]; ok { + continue + } + seen[item.GuildID] = struct{}{} + ids = append(ids, item.GuildID) + } + + sort.Strings(ids) + return ids, nil +} + // SetGuildSettingFlag sets a boolean guild setting and persists it. func SetGuildSettingFlag(guildID string, key string, value bool) { if guildstate[guildID] == nil {