feat(admin): add guildstate commands, helpers & home_guild sync#2280
feat(admin): add guildstate commands, helpers & home_guild sync#2280
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a consolidated admin entrypoint for managing persisted guildstate across guilds (with a guild ID override), plus supporting helpers and command-sync behavior driven by a home_guild setting.
Changes:
- Add
admin-guildstateslash command withaction+guild-idoverride and autocomplete sourcing from persisted guildstate. - Refactor guildstate admin handlers into
*ForGuild(...)variants to support explicit guild overrides. - Add
home_guild-driven command sync splitting and a helper to list persisted guild IDs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 8 comments.
| File | Description |
|---|---|
src/guildstate/state.go |
Adds GetAllGuildIDs() helper used for admin autocomplete. |
src/guildstate/slashcmd.go |
Refactors handlers to support explicit guildID overrides and adds display-name helper. |
src/boost/boost_admin.go |
Introduces admin-guildstate command, routing, and autocomplete. |
main.go |
Splits command syncing to optionally sync some commands to home_guild. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if guildID == "" { | ||
| guildID = "DISABLED" | ||
| } |
There was a problem hiding this comment.
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.
| if guildID == "" { | |
| guildID = "DISABLED" | |
| } |
| if err != nil { | ||
| log.Println(err) | ||
| } | ||
| return perms&discordgo.PermissionAdministrator != 0 || userID == config.AdminUserID |
There was a problem hiding this comment.
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.
| return perms&discordgo.PermissionAdministrator != 0 || userID == config.AdminUserID | |
| return userID == config.AdminUserID |
| if guild, guildErr := s.Guild(id); guildErr == nil && guild != nil { | ||
| guildName := strings.TrimSpace(guild.Name) | ||
| if guildName != "" { | ||
| choiceName = fmt.Sprintf("%s (%s)", guildName, id) |
There was a problem hiding this comment.
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).
| 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) | |
| } |
| if guildID == "" { | ||
| _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ | ||
| Type: discordgo.InteractionResponseChannelMessageWithSource, | ||
| Data: &discordgo.InteractionResponseData{ | ||
| Content: "guild-id is required.", |
There was a problem hiding this comment.
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.
| respondEphemeral(s, i, "Guild ID is required.") | ||
| return | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| // 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 | |
| } |
| if homeGuild != "DISABLED" { | ||
| syncCommands(s, homeGuild, homeGuildCommandSet) | ||
| } | ||
| syncCommands(s, config.DiscordGuildID, commandSet) |
There was a problem hiding this comment.
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.
| 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) | |
| } |
| 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) | ||
| } |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
GetAllGuildIDs currently calls GetAllGuildState(), which selects * (including the value JSON) and unmarshals each row just to extract IDs. Since guild_record.id is a PRIMARY KEY, you can fetch and sort IDs without loading/parsing value (e.g., add a SELECT id FROM guild_record sqlc query) and drop the seen de-dupe map (duplicates can’t occur). This will make autocomplete lighter as the table grows.
No description provided.