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

+116 -46
View File
@@ -3,9 +3,11 @@ package bot
import (
"context"
"log"
"sync"
"time"
"velox-bot/internal/beta"
"velox-bot/internal/commands"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services"
"velox-bot/internal/events"
@@ -27,7 +29,8 @@ type Bot struct {
DBHost string
LavalinkHost string
LavalinkPass string
betaGuild string // where beta commands are registered right now
betaRegistered map[string]bool // servers where the beta commands are registered right now
betaRoles sync.Map // "guild:user" -> cachedRoles
}
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
@@ -50,15 +53,16 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
discordgo.IntentsDirectMessages
return &Bot{
Session: session,
AppID: appID,
GuildID: guildID,
Commands: cmds,
Services: services,
MusicRepo: musicRepo,
DBHost: dbHost,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
betaRegistered: map[string]bool{},
Session: session,
AppID: appID,
GuildID: guildID,
Commands: cmds,
Services: services,
MusicRepo: musicRepo,
DBHost: dbHost,
LavalinkHost: lavalinkHost,
LavalinkPass: lavalinkPass,
}, nil
}
@@ -206,22 +210,21 @@ func (b *Bot) Start() error {
// made on the dashboard.
const betaPollEvery = 30 * time.Second
// WatchBeta keeps the bot's idea of who the beta testers are in step with the
// settings the owner saves on the dashboard. When the beta server changes, the
// beta commands move with it. If the settings can't be read, the last known
// ones stay in force (and before the first read, beta stays locked).
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, err error)) {
// WatchBeta keeps the bot's idea of who the beta testers are, and which
// servers opted in, in step with the dashboard. When the list of servers
// changes, the beta commands follow: they appear in servers that opted in and
// are removed from servers that opted out. If the settings can't be read, the
// last known ones stay in force (and before the first read, beta stays off).
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, servers []string, err error)) {
beta.SetRolesLookup(b.betaMemberRoles)
sync := func() {
guildID, roleID, err := load(ctx)
guildID, roleID, servers, err := load(ctx)
if err != nil {
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
return
}
beta.Configure(guildID, roleID)
if now := beta.GuildID(); now != b.betaGuild {
b.registerBetaCommands(b.betaGuild, now)
b.betaGuild = now
}
beta.Configure(guildID, roleID, servers)
b.syncBetaCommands()
}
sync()
go func() {
@@ -238,48 +241,92 @@ func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID
}()
}
// registerBetaCommands puts the beta commands in the beta server, and only
// there, so nobody else sees them. Commands this bot left there that are no
// longer beta (or no longer exist) are removed. When the beta server is also
// the bot's main server, its regular commands are kept. If the beta server
// changed, the beta commands are taken out of the old one.
func (b *Bot) registerBetaCommands(previous, guildID string) {
if previous != "" && previous != guildID {
isBeta := map[string]bool{}
for _, cmd := range commands.BetaCommands {
isBeta[cmd.Name] = true
}
if old, err := b.Session.ApplicationCommands(b.AppID, previous); err != nil {
log.Printf("beta: cannot list commands in the previous server: %v", err)
} else {
for _, cmd := range old {
if isBeta[cmd.Name] {
if err := b.Session.ApplicationCommandDelete(b.AppID, previous, cmd.ID); err != nil {
log.Printf("beta: cannot remove '%v' from the previous server: %v", cmd.Name, err)
}
}
}
type cachedRoles struct {
roles []string
at time.Time
}
// betaRolesTTL is how long a member's roles in the beta server are remembered
// when they had to be fetched from Discord.
const betaRolesTTL = 30 * time.Second
// betaMemberRoles answers beta.SetRolesLookup: someone's roles in the beta
// server. The gateway cache is used when it has them (it is kept current by
// member updates), otherwise Discord is asked, at most once per
// betaRolesTTL per person.
func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) {
if m, err := b.Session.State.Member(guildID, userID); err == nil && m != nil {
return m.Roles, nil
}
key := guildID + ":" + userID
if v, ok := b.betaRoles.Load(key); ok {
if c := v.(cachedRoles); time.Since(c.at) < betaRolesTTL {
return c.roles, nil
}
}
if guildID == "" {
return
m, err := b.Session.GuildMember(guildID, userID)
if err != nil {
return nil, err
}
b.betaRoles.Store(key, cachedRoles{roles: m.Roles, at: time.Now()})
return m.Roles, nil
}
// syncBetaCommands makes the beta commands exist in exactly the servers where
// beta features are on (the beta server and the ones that opted in), and
// nowhere else. A server whose registration failed is retried next time.
func (b *Bot) syncBetaCommands() {
want := map[string]bool{}
if g := beta.GuildID(); g != "" {
want[g] = true
}
for _, id := range beta.Servers() {
want[id] = true
}
for id := range b.betaRegistered {
if !want[id] {
b.removeBetaCommands(id)
delete(b.betaRegistered, id)
}
}
for id := range want {
if !b.betaRegistered[id] && b.addBetaCommands(id) {
b.betaRegistered[id] = true
}
}
}
// addBetaCommands registers the beta commands in a server. In the beta server
// it also removes commands this bot left there that no longer exist (when the
// beta server is also the bot's main server, its regular commands are kept).
// /beta is for the bot's owner and is only registered in the beta server. It
// reports whether everything was registered.
func (b *Bot) addBetaCommands(guildID string) bool {
isBetaServer := guildID == beta.GuildID()
keep := map[string]bool{}
for _, cmd := range b.Commands {
if b.GuildID == guildID {
keep[cmd.Name] = true
}
}
ok := true
for _, cmd := range commands.BetaCommands {
if cmd.Name == betacmd.Beta.Name && !isBetaServer {
continue
}
keep[cmd.Name] = true
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
log.Printf("beta: cannot create '%v' command: %v", cmd.Name, err)
log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
ok = false
}
}
if !isBetaServer {
return ok
}
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
if err != nil {
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
return
return ok
}
for _, cmd := range existing {
if keep[cmd.Name] {
@@ -289,6 +336,29 @@ func (b *Bot) registerBetaCommands(previous, guildID string) {
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
}
}
return ok
}
// removeBetaCommands takes the beta commands out of a server that is no longer
// part of the beta, and leaves everything else there alone.
func (b *Bot) removeBetaCommands(guildID string) {
isBeta := map[string]bool{}
for _, cmd := range commands.BetaCommands {
isBeta[cmd.Name] = true
}
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
if err != nil {
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
return
}
for _, cmd := range existing {
if !isBeta[cmd.Name] {
continue
}
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil {
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
}
}
}
func (b *Bot) Close() error {