Beta: clean up servers that opted out while the bot was offline

The bot remembers where it registered beta commands (beta_registered) and
removes them from servers that are no longer in the beta at the next start.
Failed cleanups are retried; a server the bot has left is forgotten.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 01:32:20 +01:00
1 parent fcf4f3ccbd
commit 9839bba50e
9 files changed
+393 -30

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ Some commands have a response that takes longer than three seconds (anything tha
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session | | Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period | | Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) | | Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are | | Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are and which servers opted in, and adds or removes beta commands to match |
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests | | Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests |
## The link to the dashboard ## The link to the dashboard
+1 -1
View File
@@ -37,7 +37,7 @@ Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBet
- For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no. - For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no.
- If the owner changes the beta server on the dashboard, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept. - If the owner changes the beta server on the dashboard, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
- `/beta` shows the bot's owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators. - `/beta` shows the bot's owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
- A server that opted out while the bot was **offline** keeps its beta commands in the menu until someone removes them. Using one still fails, because the gate checks every time. - The bot remembers the servers where it registered beta commands (`beta_registered` table). A server that opted out while the bot was **offline** is cleaned up on the next start, and a cleanup that fails is retried every 30 seconds. A server the bot has left is forgotten.
### Add a beta command ### Add a beta command
+1 -1
View File
@@ -33,7 +33,7 @@ Two things only exist in the API's migrations, because the bot never inserts int
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard | | Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard |
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | | | Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests | | Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
| Beta | `beta_settings`, `beta_guilds` | `beta_settings` is one row: the beta server and the Beta Tester role. `beta_guilds` lists servers whose owner opted in to beta features. Written by the dashboard, read by the bot | | Beta | `beta_settings`, `beta_guilds` | `beta_settings` is one row: the beta server and the Beta Tester role. `beta_guilds` lists servers whose owner opted in to beta features. Both are written by the dashboard and read by the bot. `beta_registered` is the bot's own note of where it registered beta commands |
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge. Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
+221
View File
@@ -0,0 +1,221 @@
package bot
import (
"context"
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"velox-bot/internal/beta"
"velox-bot/internal/commands"
"github.com/bwmarrin/discordgo"
)
const (
appID = "100"
betaServer = "1170934850157547600"
optedIn = "222"
leftServer = "333"
)
// fakeDiscord answers the application-command calls the beta sync makes, from
// memory, and records what it was asked to change.
var registerOnce sync.Once
type fakeDiscord struct {
mu sync.Mutex
cmds map[string][]*discordgo.ApplicationCommand // guild -> commands
failures map[string]int // guild -> status for every call
nextID int
created []string // "guild/name"
deleted []string // "guild/name"
}
var cmdPath = regexp.MustCompile(`/applications/` + appID + `/guilds/(\d+)/commands(?:/(\d+))?$`)
func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) {
f.mu.Lock()
defer f.mu.Unlock()
reply := func(code int, v any) (*http.Response, error) {
b, _ := json.Marshal(v)
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
}
m := cmdPath.FindStringSubmatch(r.URL.Path)
if m == nil {
return reply(404, map[string]any{"message": "unknown route"})
}
guild, id := m[1], m[2]
if code := f.failures[guild]; code != 0 {
return reply(code, map[string]any{"message": "failure", "code": 0})
}
switch r.Method {
case http.MethodGet:
return reply(200, f.cmds[guild])
case http.MethodPost:
var c discordgo.ApplicationCommand
_ = json.NewDecoder(r.Body).Decode(&c)
f.nextID++
c.ID = strconv.Itoa(1000 + f.nextID)
f.cmds[guild] = append(f.cmds[guild], &c)
f.created = append(f.created, guild+"/"+c.Name)
return reply(200, c)
case http.MethodDelete:
kept := f.cmds[guild][:0]
for _, c := range f.cmds[guild] {
if c.ID == id {
f.deleted = append(f.deleted, guild+"/"+c.Name)
continue
}
kept = append(kept, c)
}
f.cmds[guild] = kept
return reply(204, nil)
}
return reply(405, nil)
}
func (f *fakeDiscord) names(guild string) []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.cmds[guild] {
out = append(out, c.Name)
}
sort.Strings(out)
return out
}
type fakeStore struct {
registered map[string]bool
servers []string
failWrite bool
}
func (s *fakeStore) GetBetaSettings(context.Context) (string, string, error) {
return betaServer, "555", nil
}
func (s *fakeStore) ListBetaGuilds(context.Context) ([]string, error) { return s.servers, nil }
func (s *fakeStore) ListBetaRegistered(context.Context) ([]string, error) {
var out []string
for id := range s.registered {
out = append(out, id)
}
return out, nil
}
func (s *fakeStore) SetBetaRegistered(_ context.Context, id string, on bool) error {
if s.failWrite {
return io.ErrUnexpectedEOF
}
if on {
s.registered[id] = true
} else {
delete(s.registered, id)
}
return nil
}
func newTestBot(t *testing.T, d *fakeDiscord) *Bot {
t.Helper()
sess, err := discordgo.New("Bot x")
if err != nil {
t.Fatal(err)
}
sess.Client = &http.Client{Transport: d}
sess.MaxRestRetries = 0
return &Bot{
Session: sess,
AppID: appID,
betaRegistered: map[string]bool{},
betaKnown: map[string]bool{},
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
func TestBetaCommandsFollowTheServers(t *testing.T) {
registerOnce.Do(func() {
commands.RegisterBeta(&discordgo.ApplicationCommand{Name: "betatest", Description: "test"}, func(*discordgo.Session, *discordgo.InteractionCreate) {})
})
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
// A server that opted out while the bot was offline still has them,
// next to a command that isn't a beta one.
leftServer: {{ID: "1", Name: "betatest"}, {ID: "2", Name: "ping"}},
// A stale command in the beta server, which the bot cleans up.
betaServer: {{ID: "3", Name: "oldthing"}},
}, failures: map[string]int{}}
store := &fakeStore{registered: map[string]bool{leftServer: true}, servers: []string{optedIn}}
b := newTestBot(t, d)
ctx := context.Background()
beta.Configure(betaServer, "555", store.servers)
b.syncBetaCommands(ctx, store)
if got := d.names(leftServer); len(got) != 1 || got[0] != "ping" {
t.Errorf("the beta command is removed from the server that left, nothing else: %v", got)
}
if got := d.names(optedIn); len(got) != 1 || got[0] != "betatest" {
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
}
got := d.names(betaServer)
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") {
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got)
}
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("the database remembers where the commands are now: %v", store.registered)
}
// The server opts out.
store.servers = nil
beta.Configure(betaServer, "555", store.servers)
b.syncBetaCommands(ctx, store)
if got := d.names(optedIn); len(got) != 0 {
t.Errorf("an opted-out server loses them within one round: %v", got)
}
if store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("only the beta server stays remembered: %v", store.registered)
}
}
func TestBetaCleanupIsRetriedAndGivesUpWhenTheBotIsGone(t *testing.T) {
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
leftServer: {{ID: "1", Name: "betatest"}},
}, failures: map[string]int{leftServer: 500}}
store := &fakeStore{registered: map[string]bool{leftServer: true}}
b := newTestBot(t, d)
ctx := context.Background()
beta.Configure(betaServer, "555", nil)
b.syncBetaCommands(ctx, store)
if !store.registered[leftServer] {
t.Fatal("a failed cleanup is remembered so it is tried again")
}
d.failures[leftServer] = 0
b.syncBetaCommands(ctx, store)
if store.registered[leftServer] || len(d.names(leftServer)) != 0 {
t.Errorf("the retry cleans up: %v, %v", store.registered, d.names(leftServer))
}
// The bot was removed from a server: nothing to clean, don't try forever.
store.registered["444"] = true
b.betaKnown["444"] = true
d.failures["444"] = 403
b.syncBetaCommands(ctx, store)
if store.registered["444"] {
t.Error("a server the bot can't reach any more is forgotten")
}
}
+86 -19
View File
@@ -2,7 +2,9 @@ package bot
import ( import (
"context" "context"
"errors"
"log" "log"
"net/http"
"sync" "sync"
"time" "time"
"velox-bot/internal/beta" "velox-bot/internal/beta"
@@ -29,7 +31,9 @@ type Bot struct {
DBHost string DBHost string
LavalinkHost string LavalinkHost string
LavalinkPass string LavalinkPass string
betaRegistered map[string]bool // servers where the beta commands are registered right now betaRegistered map[string]bool // servers where this run registered the beta commands
betaKnown map[string]bool // servers where they were registered, now or in an earlier run
betaLoaded bool
betaRoles sync.Map // "guild:user" -> cachedRoles betaRoles sync.Map // "guild:user" -> cachedRoles
} }
@@ -54,6 +58,7 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
return &Bot{ return &Bot{
betaRegistered: map[string]bool{}, betaRegistered: map[string]bool{},
betaKnown: map[string]bool{},
Session: session, Session: session,
AppID: appID, AppID: appID,
GuildID: guildID, GuildID: guildID,
@@ -210,21 +215,37 @@ func (b *Bot) Start() error {
// made on the dashboard. // made on the dashboard.
const betaPollEvery = 30 * time.Second const betaPollEvery = 30 * time.Second
// BetaStore is where the bot reads the beta settings the dashboard writes,
// and remembers which servers it registered beta commands in.
type BetaStore interface {
GetBetaSettings(ctx context.Context) (guildID, roleID string, err error)
ListBetaGuilds(ctx context.Context) ([]string, error)
ListBetaRegistered(ctx context.Context) ([]string, error)
SetBetaRegistered(ctx context.Context, guildID string, registered bool) error
}
// WatchBeta keeps the bot's idea of who the beta testers are, and which // 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 // 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 // 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 // are removed from servers that opted out, also if that happened while the bot
// last known ones stay in force (and before the first read, beta stays off). // was offline (it remembers where it registered them). If the settings can't
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, servers []string, err error)) { // be read, the last known ones stay in force (and before the first read, beta
// stays off).
func (b *Bot) WatchBeta(ctx context.Context, store BetaStore) {
beta.SetRolesLookup(b.betaMemberRoles) beta.SetRolesLookup(b.betaMemberRoles)
sync := func() { sync := func() {
guildID, roleID, servers, err := load(ctx) guildID, roleID, err := store.GetBetaSettings(ctx)
if err != nil { if err != nil {
log.Printf("beta: cannot read settings, keeping the last ones: %v", err) log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
return return
} }
servers, err := store.ListBetaGuilds(ctx)
if err != nil {
log.Printf("beta: cannot read the opted-in servers, keeping the last ones: %v", err)
return
}
beta.Configure(guildID, roleID, servers) beta.Configure(guildID, roleID, servers)
b.syncBetaCommands() b.syncBetaCommands(ctx, store)
} }
sync() sync()
go func() { go func() {
@@ -274,8 +295,22 @@ func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) {
// syncBetaCommands makes the beta commands exist in exactly the servers where // 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 // 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. // nowhere else. The servers it registered them in are remembered in the
func (b *Bot) syncBetaCommands() { // database, so a server that opted out while the bot was offline is cleaned up
// at the next start. A step that failed is retried on the next round.
func (b *Bot) syncBetaCommands(ctx context.Context, store BetaStore) {
if !b.betaLoaded {
known, err := store.ListBetaRegistered(ctx)
if err != nil {
log.Printf("beta: cannot read where beta commands were registered: %v", err)
return
}
for _, id := range known {
b.betaKnown[id] = true
}
b.betaLoaded = true
}
want := map[string]bool{} want := map[string]bool{}
if g := beta.GuildID(); g != "" { if g := beta.GuildID(); g != "" {
want[g] = true want[g] = true
@@ -283,16 +318,30 @@ func (b *Bot) syncBetaCommands() {
for _, id := range beta.Servers() { for _, id := range beta.Servers() {
want[id] = true want[id] = true
} }
for id := range b.betaRegistered { for id := range b.betaKnown {
if !want[id] { if want[id] {
b.removeBetaCommands(id) continue
}
if !b.removeBetaCommands(id) {
continue
}
if err := store.SetBetaRegistered(ctx, id, false); err != nil {
log.Printf("beta: cannot forget server %s: %v", id, err)
continue
}
delete(b.betaKnown, id)
delete(b.betaRegistered, id) delete(b.betaRegistered, id)
} }
}
for id := range want { for id := range want {
if !b.betaRegistered[id] && b.addBetaCommands(id) { if b.betaRegistered[id] || !b.addBetaCommands(id) {
b.betaRegistered[id] = true continue
} }
b.betaRegistered[id] = true
if err := store.SetBetaRegistered(ctx, id, true); err != nil {
log.Printf("beta: cannot remember server %s: %v", id, err)
continue
}
b.betaKnown[id] = true
} }
} }
@@ -339,26 +388,44 @@ func (b *Bot) addBetaCommands(guildID string) bool {
return ok return ok
} }
// goneFromServer reports whether an error from Discord means the bot isn't in
// that server any more (or it no longer exists), so there is nothing left to
// clean up there.
func goneFromServer(err error) bool {
var rest *discordgo.RESTError
if errors.As(err, &rest) && rest.Response != nil {
return rest.Response.StatusCode == http.StatusForbidden || rest.Response.StatusCode == http.StatusNotFound
}
return false
}
// removeBetaCommands takes the beta commands out of a server that is no longer // removeBetaCommands takes the beta commands out of a server that is no longer
// part of the beta, and leaves everything else there alone. // part of the beta, and leaves everything else there alone. It reports whether
func (b *Bot) removeBetaCommands(guildID string) { // the server is clean (or out of reach for good).
func (b *Bot) removeBetaCommands(guildID string) bool {
isBeta := map[string]bool{} isBeta := map[string]bool{}
for _, cmd := range commands.BetaCommands { for _, cmd := range commands.BetaCommands {
isBeta[cmd.Name] = true isBeta[cmd.Name] = true
} }
existing, err := b.Session.ApplicationCommands(b.AppID, guildID) existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
if err != nil { if err != nil {
log.Printf("beta: cannot list commands in %s: %v", guildID, err) if goneFromServer(err) {
return return true
} }
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
return false
}
clean := true
for _, cmd := range existing { for _, cmd := range existing {
if !isBeta[cmd.Name] { if !isBeta[cmd.Name] {
continue continue
} }
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil { if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil && !goneFromServer(err) {
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err) log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
clean = false
} }
} }
return clean
} }
func (b *Bot) Close() error { func (b *Bot) Close() error {
@@ -215,3 +215,44 @@ func TestListBetaGuilds(t *testing.T) {
t.Fatal("listed after opting in") t.Fatal("listed after opting in")
} }
} }
func TestBetaRegistered(t *testing.T) {
r, _ := testRepo(t)
ctx := context.Background()
if _, err := r.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_registered (guild_id BIGINT PRIMARY KEY)`); err != nil {
t.Fatal(err)
}
const g = "8800000000000005151"
_ = r.SetBetaRegistered(ctx, g, false)
t.Cleanup(func() { _ = r.SetBetaRegistered(ctx, g, false) })
has := func() bool {
ids, err := r.ListBetaRegistered(ctx)
if err != nil {
t.Fatal(err)
}
for _, id := range ids {
if id == g {
return true
}
}
return false
}
if has() {
t.Fatal("not remembered at first")
}
for i := 0; i < 2; i++ { // twice: harmless
if err := r.SetBetaRegistered(ctx, g, true); err != nil {
t.Fatal(err)
}
}
if !has() {
t.Fatal("remembered after registering")
}
if err := r.SetBetaRegistered(ctx, g, false); err != nil || has() {
t.Fatalf("forgotten after removing: %v", err)
}
if err := r.SetBetaRegistered(ctx, "not-a-number", true); err == nil {
t.Error("a bad ID is refused")
}
}
+34
View File
@@ -441,3 +441,37 @@ func (r *Repo) ListBetaGuilds(ctx context.Context) ([]string, error) {
} }
return out, rows.Err() return out, rows.Err()
} }
// ListBetaRegistered returns the servers where the bot registered beta
// commands, so it can clean up after itself even if a server opted out while
// it was offline.
func (r *Repo) ListBetaRegistered(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_registered`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []string
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, strconv.FormatInt(id, 10))
}
return out, rows.Err()
}
// SetBetaRegistered records (or forgets) that beta commands exist in a server.
func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered bool) error {
id, err := strconv.ParseInt(guildID, 10, 64)
if err != nil {
return err
}
if registered {
_, err = r.db.ExecContext(ctx, `INSERT INTO beta_registered (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, id)
} else {
_, err = r.db.ExecContext(ctx, `DELETE FROM beta_registered WHERE guild_id = $1`, id)
}
return err
}
+1 -8
View File
@@ -94,14 +94,7 @@ func main() {
defer bot.Close() defer bot.Close()
// Who the beta testers are is set on the dashboard; follow it. // Who the beta testers are is set on the dashboard; follow it.
bot.WatchBeta(context.Background(), func(ctx context.Context) (string, string, []string, error) { bot.WatchBeta(context.Background(), settingsRepo)
guildID, roleID, err := settingsRepo.GetBetaSettings(ctx)
if err != nil {
return "", "", nil, err
}
servers, err := settingsRepo.ListBetaGuilds(ctx)
return guildID, roleID, servers, err
})
fmt.Println("Bot is running...") fmt.Println("Bot is running...")
+7
View File
@@ -92,6 +92,13 @@ CREATE TABLE IF NOT EXISTS beta_guilds (
enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW() enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
-- Servers where the bot registered beta commands, so it can remove them even
-- if the server opted out while the bot was offline. Bot-owned (the API's
-- migration 000022 creates it for a combined database).
CREATE TABLE IF NOT EXISTS beta_registered (
guild_id BIGINT PRIMARY KEY
);
-- A server can have several lobbies (at most 10; the trigger enforces it, and -- A server can have several lobbies (at most 10; the trigger enforces it, and
-- is the same as the API's migration 000020). meeting_settings.lobby_channel_id -- is the same as the API's migration 000020). meeting_settings.lobby_channel_id
-- is the old single-lobby column, no longer read. -- is the old single-lobby column, no longer read.