// Package beta gates features that are still being tried out. A beta feature // works only for members of the beta server who have the Beta Tester role: // the server comes from BETA_GUILD_ID and the role from BETA_ROLE_ID, both set // by the bot's owner in its environment, so nobody can change them from // Discord. // // Everything fails closed. With no role configured, or an ID that isn't a // Discord ID, nobody gets access. package beta import ( "log" "regexp" "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 } var current atomic.Pointer[settings] var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`) // idOrEmpty keeps a Discord ID and drops anything else, so a typo in the // environment 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; beta features stay locked", name, v) return "" } return v } // Configure sets the beta server and the Beta Tester role. Call it once at // startup. func Configure(guildID, roleID string) { current.Store(&settings{ guildID: idOrEmpty("BETA_GUILD_ID", guildID), roleID: idOrEmpty("BETA_ROLE_ID", roleID), }) } func get() settings { if s := current.Load(); s != nil { return *s } return settings{} } // GuildID is the beta server, or "" when beta features are off. func GuildID() string { return get().guildID } // Status describes where a member stands, for the /beta command. type Status struct { InBetaGuild bool // the interaction came from the beta server RoleConfigured bool // the owner has set a Beta Tester role HasRole bool // the member has it } // StatusOf reports a member's standing in the guild they're acting in. func StatusOf(guildID string, member *discordgo.Member) Status { c := get() st := Status{ InBetaGuild: c.guildID != "" && guildID == c.guildID, RoleConfigured: c.roleID != "", } if st.InBetaGuild && st.RoleConfigured && member != nil { for _, r := range member.Roles { if r == c.roleID { st.HasRole = true break } } } return st } // Allowed reports whether a member may use beta features in guildID: it has // to be the beta server, and the member has to have the Beta Tester role. // Use it for anything that isn't a command, such as an event handler. func Allowed(guildID string, member *discordgo.Member) bool { st := StatusOf(guildID, member) return st.InBetaGuild && st.RoleConfigured && st.HasRole } // AllowedInteraction is Allowed for an interaction. A DM has no member, so it // never qualifies. The member and their roles come 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 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) } }