- 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]>
216 lines
6.9 KiB
Go
216 lines
6.9 KiB
Go
// 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)
|
|
}
|
|
}
|