Multiple meeting lobbies and beta-gated features (3.5.2)

- /meeting add-lobby, remove-lobby, lobbies: up to 10 lobbies per server, capped in the database
- Deleted voice channels are dropped from the lobbies
- Beta features: registered only in the beta server and usable only with the Beta Tester role (BETA_GUILD_ID, BETA_ROLE_ID); fails closed
- /beta (bot owner only) shows how beta access is set up

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 00:38:48 +01:00
1 parent 136377d81a
commit a364435bab
15 files changed
+789 -52

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
// 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)
}
}