Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -210,6 +211,7 @@ var (
},
boost.SlashAdminGetContractData(slashAdminGetContractData),
boost.SlashAdminListRoles(slashAdminListRoles),
boost.SlashAdminGuildStateCommand(slashAdminGuildstate),
guildstate.SlashSetGuildSettingCommand(slashAdminSetGuildSetting),
guildstate.SlashGetGuildSettingsCommand(slashAdminGetGuildSettings),
}
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -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)
}
Comment on lines +1087 to +1099
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting homeGuild to the sentinel "DISABLED" makes homeGuild != "" always true, so any command with cmd.GuildID == "DISABLED" gets filtered out of commandSet (and then never synced because homeGuild != "DISABLED" gates the home sync). If the intent is to disable home-guild syncing when unset, it’s clearer/safer to keep homeGuild empty and branch explicitly (or avoid assigning an invalid guild ID to commands).

Copilot uses AI. Check for mistakes.
}
commandSet = filteredCommandSet

if homeGuild != "DISABLED" {
syncCommands(s, homeGuild, homeGuildCommandSet)
}
syncCommands(s, config.DiscordGuildID, commandSet)
Comment on lines +1103 to 1106
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If homeGuild equals config.DiscordGuildID, this code will call syncCommands twice for the same guild with two different desired command sets. The second call will delete commands from the first call (because syncCommands bulk-overwrites), so the "home guild" commands won’t actually stick. Consider merging the two desired lists (or skipping the split) when the guild IDs match.

Suggested change
if homeGuild != "DISABLED" {
syncCommands(s, homeGuild, homeGuildCommandSet)
}
syncCommands(s, config.DiscordGuildID, commandSet)
// Avoid calling syncCommands twice for the same guild with different command sets.
// If homeGuild is enabled and equals the main DiscordGuildID, merge the two sets and sync once.
if homeGuild != "DISABLED" && homeGuild == config.DiscordGuildID {
mergedCommandSet := make([]*discordgo.ApplicationCommand, 0, len(commandSet)+len(homeGuildCommandSet))
mergedCommandSet = append(mergedCommandSet, commandSet...)
mergedCommandSet = append(mergedCommandSet, homeGuildCommandSet...)
syncCommands(s, config.DiscordGuildID, mergedCommandSet)
} else {
if homeGuild != "DISABLED" {
syncCommands(s, homeGuild, homeGuildCommandSet)
}
syncCommands(s, config.DiscordGuildID, commandSet)
}

Copilot uses AI. Check for mistakes.

defer func() {
Expand Down
186 changes: 186 additions & 0 deletions src/boost/boost_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Comment on lines +84 to +86
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SlashAdminGuildStateCommand sets GuildID to the literal string "DISABLED" when home_guild is unset. Because main.go then filters commands by cmd.GuildID == homeGuild, this command is effectively never synced/registered unless home_guild is configured. Consider either (a) not adding this command to adminCommands unless home_guild is a valid snowflake, or (b) leaving GuildID empty and skipping the home-guild special casing when unset, rather than using a sentinel that isn’t a valid Discord guild ID.

Suggested change
if guildID == "" {
guildID = "DISABLED"
}

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The permission check here allows any server administrator in the command’s invocation channel to run cross-guild state changes (because the command accepts a guild-id override). If the bot is in multiple guilds, this effectively lets admins of the "home" guild modify settings for other guilds. Consider restricting cross-guild actions to config.AdminUserID (or an explicit allowlist) instead of PermissionAdministrator.

Suggested change
return perms&discordgo.PermissionAdministrator != 0 || userID == config.AdminUserID
return userID == config.AdminUserID

Copilot uses AI. Check for mistakes.
}

// 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)
Comment on lines +160 to +163
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandleAdminGuildStateAutoComplete calls s.Guild(id) inside the loop. If this is a REST fetch (discordgo’s Session.Guild), autocomplete can trigger up to 25 network calls per keystroke, which is likely to hit rate limits and slow down the UI. Prefer using the state cache (e.g., s.State.Guild) or returning IDs without resolving names (or caching resolved names).

Suggested change
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 s.State != nil {
if guild, guildErr := s.State.Guild(id); guildErr == nil && guild != nil {
guildName := strings.TrimSpace(guild.Name)
if guildName != "" {
choiceName = fmt.Sprintf("%s (%s)", guildName, id)
}

Copilot uses AI. Check for mistakes.
}
}

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.",
Comment on lines +218 to +222
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only checking guildID != "" means callers can type any snowflake and create/update persisted state for a guild the bot isn’t in (or that never existed). Since this command is explicitly about operating on persisted guildstate, consider validating guild-id against guildstate.GetAllGuildIDs() (or ensuring s.Guild(guildID) succeeds) and rejecting unknown guild IDs.

Copilot uses AI. Check for mistakes.
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
Expand Down
82 changes: 51 additions & 31 deletions src/guildstate/slashcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down Expand Up @@ -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
}

Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetGuildSettingForGuild will persist settings for any non-empty guildID, even if the bot is not in that guild / it’s not a known persisted guild. Since this is now used with an explicit guild override, consider validating the provided guildID (e.g., ensure it exists in persisted guildstate or that s.Guild(guildID) succeeds) before writing.

Suggested change
// Validate that the guild ID corresponds to a guild the bot can access
if dgGuild, guildErr := s.Guild(guildID); guildErr != nil || dgGuild == nil {
respondEphemeral(s, i, fmt.Sprintf("Guild ID '%s' is not valid or the bot is not in that guild.", guildID))
return
}

Copilot uses AI. Check for mistakes.
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.")
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Loading
Loading