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:
1 parent
136377d81a
commit
a364435bab
15 files changed
+789
-52
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package beta
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
const (
|
||||
betaGuild = "1170934850157547600"
|
||||
testerRol = "555000000000000001"
|
||||
)
|
||||
|
||||
func member(roles ...string) *discordgo.Member { return &discordgo.Member{Roles: roles} }
|
||||
|
||||
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
|
||||
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
|
||||
}
|
||||
|
||||
func TestAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
guild, role string // configuration
|
||||
inGuild string
|
||||
member *discordgo.Member
|
||||
want bool
|
||||
inBeta, hasRole bool
|
||||
}{
|
||||
{"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true},
|
||||
{"member without the role", betaGuild, testerRol, betaGuild, member("1", "2"), false, true, false},
|
||||
{"member with no roles at all", betaGuild, testerRol, betaGuild, member(), false, true, false},
|
||||
{"the role in another server doesn't count", betaGuild, testerRol, "999000000000000001", member(testerRol), false, false, false},
|
||||
{"a DM has no member", betaGuild, testerRol, "", nil, false, false, false},
|
||||
{"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, false},
|
||||
{"no role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false},
|
||||
{"no beta server configured: nobody", "", testerRol, betaGuild, member(testerRol), false, false, false},
|
||||
{"a role ID that isn't a number is dropped", betaGuild, "tester-role", betaGuild, member("tester-role"), false, true, false},
|
||||
{"a server ID that isn't a number is dropped", "my-server", testerRol, "my-server", member(testerRol), false, false, false},
|
||||
{"a role that only contains the ID isn't a match", betaGuild, testerRol, betaGuild, member(testerRol + "9"), false, true, false},
|
||||
{"stray spaces in the configuration are ignored", " " + betaGuild + " ", " " + testerRol + "\n", betaGuild, member(testerRol), true, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
Configure(tc.guild, tc.role)
|
||||
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
|
||||
t.Errorf("Allowed = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got := AllowedInteraction(interaction(tc.inGuild, tc.member)); got != tc.want {
|
||||
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
|
||||
}
|
||||
st := StatusOf(tc.inGuild, tc.member)
|
||||
if st.InBetaGuild != tc.inBeta || st.HasRole != tc.hasRole {
|
||||
t.Errorf("StatusOf = %+v, want in beta server %v, has role %v", st, tc.inBeta, tc.hasRole)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfiguredAtAll(t *testing.T) {
|
||||
current.Store(nil)
|
||||
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" {
|
||||
t.Error("before Configure nothing is allowed and there's no beta server")
|
||||
}
|
||||
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
|
||||
t.Error("a missing interaction is never allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
||||
Configure(betaGuild, testerRol)
|
||||
var answered []string
|
||||
orig := respond
|
||||
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
|
||||
answered = append(answered, msg)
|
||||
}
|
||||
t.Cleanup(func() { respond = orig })
|
||||
|
||||
ran := 0
|
||||
h := Gate(func(*discordgo.Session, *discordgo.InteractionCreate) { ran++ })
|
||||
|
||||
h(nil, interaction(betaGuild, member(testerRol)))
|
||||
if ran != 1 || len(answered) != 0 {
|
||||
t.Fatalf("a tester runs the handler without being refused: ran=%d answered=%v", ran, answered)
|
||||
}
|
||||
|
||||
for name, i := range map[string]*discordgo.InteractionCreate{
|
||||
"no role": interaction(betaGuild, member("1")),
|
||||
"wrong server": interaction("999000000000000001", member(testerRol)),
|
||||
"DM": interaction("", nil),
|
||||
} {
|
||||
before := ran
|
||||
h(nil, i)
|
||||
if ran != before {
|
||||
t.Errorf("%s: the handler must not run", name)
|
||||
}
|
||||
}
|
||||
if len(answered) != 3 || answered[0] != denied {
|
||||
t.Errorf("each refusal is answered with the beta message: %v", answered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardOnItsOwn(t *testing.T) {
|
||||
Configure(betaGuild, testerRol)
|
||||
n := 0
|
||||
orig := respond
|
||||
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
|
||||
t.Cleanup(func() { respond = orig })
|
||||
|
||||
if !Guard(nil, interaction(betaGuild, member(testerRol))) || n != 0 {
|
||||
t.Error("a tester passes without a reply")
|
||||
}
|
||||
if Guard(nil, interaction(betaGuild, member())) || n != 1 {
|
||||
t.Error("someone else is refused with a reply")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user