Beta servers: testers can use beta features in servers whose owner opted in

- beta.Configure takes the opted-in servers; testers are looked up in the beta server
- beta commands registered per opted-in server and removed when it opts out
- beta.ServerEnabled for behaviour that no person triggers
- docs updated

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 01:25:50 +01:00
1 parent e2fe30b524
commit fcf4f3ccbd
12 files changed
+491 -122

No files matched your search

+105 -35
View File
@@ -1,16 +1,30 @@
// 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.
// Package beta gates features that are still being tried out.
//
// Everything fails closed. With no role configured, or an ID that isn't a
// Discord ID, nobody gets access.
// 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"
@@ -20,32 +34,43 @@ import (
// Handler is a slash command handler.
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
type settings struct{ guildID, roleID string }
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 typo in the
// environment turns beta features off instead of matching something odd.
// 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; beta features stay locked", name, v)
log.Printf("beta: %s %q isn't a Discord ID; ignoring it", 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) {
// 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_GUILD_ID", guildID),
roleID: idOrEmpty("BETA_ROLE_ID", roleID),
guildID: idOrEmpty("beta server", guildID),
roleID: idOrEmpty("beta role", roleID),
servers: set,
})
}
@@ -56,45 +81,90 @@ func get() settings {
return settings{}
}
// GuildID is the beta server, or "" when beta features are off.
// 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 {
InBetaGuild bool // the interaction came from the beta server
ServerEnabled bool // beta features are on in this server
RoleConfigured bool // the owner has set a Beta Tester role
HasRole bool // the member has it
HasRole bool // the member is a tester
}
// StatusOf reports a member's standing in the guild they're acting in.
// 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{
InBetaGuild: c.guildID != "" && guildID == c.guildID,
ServerEnabled: ServerEnabled(guildID),
RoleConfigured: c.roleID != "",
}
if st.InBetaGuild && st.RoleConfigured && member != nil {
for _, r := range member.Roles {
if r == c.roleID {
st.HasRole = true
break
}
}
if st.ServerEnabled && st.RoleConfigured && member != nil {
st.HasRole = isTester(c, guildID, member)
}
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.
// 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.InBetaGuild && st.RoleConfigured && st.HasRole
return st.ServerEnabled && 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.
// 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)
}
@@ -119,7 +189,7 @@ var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg str
})
}
const denied = "This feature is in beta and isn't available to you yet."
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