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:
1 parent
e2fe30b524
commit
fcf4f3ccbd
12 files changed
+491
-122
No files matched your search
+116
-46
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user