// Package beta gates features that are still being tried out. // // A beta feature works for a beta tester, in a server where beta features are // on: // // - A beta tester is a member of the beta server who has the Beta Tester // role. Both are chosen by the bot's owner on the dashboard. // - Beta features are on in the beta server itself, and in every other // server whose owner opted in on the dashboard. A feature that can act on // a whole server never reaches a server that didn't ask for it. // // Being a tester is about the person, so it is looked up in the beta server // even when they use a feature somewhere else (see SetRolesLookup). Beta // never replaces a feature's own permission checks: a beta command that needs // Manage Server still needs it. // // Features that act without anyone running a command (an event handler, a // background job) have no person to check, so they use ServerEnabled only. // // Everything fails closed. With no role configured, an ID that isn't a // Discord ID, or a lookup that fails, nobody gets access. package beta import ( "log" "regexp" "slices" "strings" "sync/atomic" "github.com/bwmarrin/discordgo" ) // Handler is a slash command handler. type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate) type settings struct { guildID, roleID string servers map[string]bool // opted-in servers, besides the beta server } var current atomic.Pointer[settings] var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`) // idOrEmpty keeps a Discord ID and drops anything else, so a bad value turns // beta features off instead of matching something odd. func idOrEmpty(name, v string) string { v = strings.TrimSpace(v) if v == "" { return "" } if !snowflake.MatchString(v) { log.Printf("beta: %s %q isn't a Discord ID; ignoring it", name, v) return "" } return v } // Configure sets the beta server, the Beta Tester role and the servers that // opted in. It replaces everything set before, so call it with the full // picture each time. func Configure(guildID, roleID string, servers []string) { set := make(map[string]bool, len(servers)) for _, id := range servers { if id = idOrEmpty("beta server", id); id != "" { set[id] = true } } current.Store(&settings{ guildID: idOrEmpty("beta server", guildID), roleID: idOrEmpty("beta role", roleID), servers: set, }) } func get() settings { if s := current.Load(); s != nil { return *s } return settings{} } // RolesFunc returns a member's role IDs in a server, as Discord has them now, // or an error (including "not in that server"). type RolesFunc func(guildID, userID string) ([]string, error) var lookup atomic.Pointer[RolesFunc] // SetRolesLookup tells the package how to find someone's roles in the beta // server when they use a feature in another server. Without it, testers only // qualify in the beta server itself. func SetRolesLookup(f RolesFunc) { lookup.Store(&f) } // GuildID is the beta server, or "" when it isn't set. func GuildID() string { return get().guildID } // Servers lists the servers that opted in, besides the beta server, in a // stable order. func Servers() []string { c := get() out := make([]string, 0, len(c.servers)) for id := range c.servers { if id != c.guildID { out = append(out, id) } } slices.Sort(out) return out } // ServerEnabled reports whether beta features are on in a server: the beta // server, or one whose owner opted in. Use it alone for anything no person // triggers. func ServerEnabled(guildID string) bool { c := get() return guildID != "" && (guildID == c.guildID || c.servers[guildID]) } // Status describes where a member stands, for the /beta command. type Status struct { ServerEnabled bool // beta features are on in this server RoleConfigured bool // the owner has set a Beta Tester role HasRole bool // the member is a tester } // StatusOf reports a member's standing in the server they're acting in. func StatusOf(guildID string, member *discordgo.Member) Status { c := get() st := Status{ ServerEnabled: ServerEnabled(guildID), RoleConfigured: c.roleID != "", } if st.ServerEnabled && st.RoleConfigured && member != nil { st.HasRole = isTester(c, guildID, member) } return st } // isTester checks the Beta Tester role in the beta server. In the beta server // the roles come with the interaction. Anywhere else they are looked up. func isTester(c settings, guildID string, member *discordgo.Member) bool { if guildID == c.guildID { return slices.Contains(member.Roles, c.roleID) } f := lookup.Load() if f == nil || *f == nil || member.User == nil || member.User.ID == "" { return false } roles, err := (*f)(c.guildID, member.User.ID) if err != nil { return false } return slices.Contains(roles, c.roleID) } // Allowed reports whether a member may use beta features in guildID: beta // features have to be on there, and the member has to be a tester. Use it for // anything a person triggers that isn't a command, such as a button. func Allowed(guildID string, member *discordgo.Member) bool { st := StatusOf(guildID, member) return st.ServerEnabled && st.RoleConfigured && st.HasRole } // AllowedInteraction is Allowed for an interaction. A DM has no member, so it // never qualifies. The member comes from Discord with the interaction, not // from anything the user typed. func AllowedInteraction(i *discordgo.InteractionCreate) bool { return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member) } // respond is how a refusal is sent; tests replace it. var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { if i.Type == discordgo.InteractionApplicationCommandAutocomplete { // Nothing to suggest, and a message isn't a valid reply here. _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionApplicationCommandAutocompleteResult, Data: &discordgo.InteractionResponseData{Choices: []*discordgo.ApplicationCommandOptionChoice{}}, }) return } _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}, }, }) } const denied = "This feature is in beta and isn't available to you or this server yet." // Guard is for the start of a beta feature's handler, and for its component, // modal and autocomplete handlers too (the command's gate doesn't cover // those). It answers the user and returns false when they aren't allowed: // // if !beta.Guard(s, i) { return } func Guard(s *discordgo.Session, i *discordgo.InteractionCreate) bool { if AllowedInteraction(i) { return true } respond(s, i, denied) return false } // Gate wraps a command handler so it only runs for beta testers. func Gate(h Handler) Handler { return func(s *discordgo.Session, i *discordgo.InteractionCreate) { if !Guard(s, i) { return } h(s, i) } }