diff --git a/README.md b/README.md index 6cf3d58..898f073 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,6 @@ Required: - `BOT_TOKEN`: Discord bot token - `APP_ID`: Discord application ID - `GUILD_ID`: Target guild/server ID (commands are registered per-guild) -- `BETA_GUILD_ID`: Server where beta features exist (defaults to the Velox server) -- `BETA_ROLE_ID`: ID of the "Beta Tester" role. Beta features work only for members of the beta server who have this role; if unset, they stay locked for everyone - `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable` Optional (music): @@ -148,5 +146,5 @@ just down # stop postgres ## Notes - Slash commands are registered to the configured `GUILD_ID` on startup. -- Beta features: add the command with `commands.RegisterBeta(cmd, handler)` instead of `AllCommands`. It is registered only in `BETA_GUILD_ID` and the handler runs only for members with `BETA_ROLE_ID`. `/beta` (bot owner only) shows how access is set up. To release a feature, move it into `AllCommands` and the handler map. +- Beta features: add the command with `commands.RegisterBeta(cmd, handler)` instead of `AllCommands`. It is registered only in the beta server and the handler runs only for members with the Beta Tester role. The owner picks both on the dashboard (Admin, Beta access); the bot reads them from the database every 30 seconds, so there is nothing to set in `.env`. Until a role is set, beta features are locked for everyone. `/beta` (bot owner only) shows how access is set up. To release a feature, move it into `AllCommands` and the handler map. - Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music). diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 7d65e5c..b5b32ef 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -27,6 +27,7 @@ type Bot struct { DBHost string LavalinkHost string LavalinkPass string + betaGuild string // where beta commands are registered right now } func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) { @@ -114,8 +115,6 @@ func (b *Bot) Start() error { b.registeredCommands = append(b.registeredCommands, created) } - b.registerBetaCommands() - b.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) { @@ -203,12 +202,65 @@ func (b *Bot) Start() error { return nil } +// betaPollEvery is how often the bot looks for a change to the beta settings +// 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)) { + sync := func() { + guildID, roleID, 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 + } + } + sync() + go func() { + t := time.NewTicker(betaPollEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + sync() + } + } + }() +} + // 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. -func (b *Bot) registerBetaCommands() { - guildID := beta.GuildID() +// 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) + } + } + } + } + } if guildID == "" { return } diff --git a/internal/config/config.go b/internal/config/config.go index 1121721..6d2b975 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,16 +7,10 @@ import ( "github.com/joho/godotenv" ) -// DefaultBetaGuildID is the server that beta features are tried in, unless -// BETA_GUILD_ID says otherwise. -const DefaultBetaGuildID = "1170934850157547600" - type Config struct { BotToken string AppID string GuildID string - BetaGuildID string // BETA_GUILD_ID: where beta features exist - BetaRoleID string // BETA_ROLE_ID: the Beta Tester role; empty keeps beta features locked for everyone DBHost string LavalinkHost string LavalinkPass string @@ -39,12 +33,6 @@ func LoadConfig() (*Config, error) { guildID := os.Getenv("GUILD_ID") - betaGuildID := os.Getenv("BETA_GUILD_ID") - if betaGuildID == "" { - betaGuildID = DefaultBetaGuildID - } - betaRoleID := os.Getenv("BETA_ROLE_ID") - dbHost := os.Getenv("DB_HOST") if dbHost == "" { return nil, fmt.Errorf("DB_HOST is not set") @@ -62,8 +50,6 @@ func LoadConfig() (*Config, error) { BotToken: token, AppID: appID, GuildID: guildID, - BetaGuildID: betaGuildID, - BetaRoleID: betaRoleID, DBHost: dbHost, LavalinkHost: lavalinkHost, LavalinkPass: lavalinkPass, diff --git a/internal/db/repos/settingsrepo/meeting_integration_test.go b/internal/db/repos/settingsrepo/meeting_integration_test.go index e73144b..fe376c1 100644 --- a/internal/db/repos/settingsrepo/meeting_integration_test.go +++ b/internal/db/repos/settingsrepo/meeting_integration_test.go @@ -117,3 +117,66 @@ func TestMeetingLobbiesConcurrent(t *testing.T) { t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies) } } + +func TestGetBetaSettings(t *testing.T) { + r, _ := testRepo(t) + db := r.db + ctx := context.Background() + _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_settings ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), guild_id BIGINT, role_id BIGINT, updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`) + if err != nil { + t.Fatal(err) + } + var wasG, wasR sql.NullInt64 + hadRow := db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil + t.Cleanup(func() { + if hadRow { + _, _ = db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR) + } else { + _, _ = db.ExecContext(ctx, `DELETE FROM beta_settings`) + } + }) + + _, err = db.ExecContext(ctx, `DELETE FROM beta_settings`) + if err != nil { + t.Fatal(err) + } + g, role, err := r.GetBetaSettings(ctx) + if err != nil { + t.Fatal(err) + } + if g != "" { + t.Fatalf("g = %q, want empty", g) + } + if role != "" { + t.Fatalf("role = %q, want empty", role) + } + + _, err = db.ExecContext(ctx, `INSERT INTO beta_settings (id, guild_id) VALUES (TRUE, 1170934850157547600)`) + if err != nil { + t.Fatal(err) + } + g, role, err = r.GetBetaSettings(ctx) + if err != nil { + t.Fatal(err) + } + if g != "1170934850157547600" { + t.Fatalf("g = %q, want %q", g, "1170934850157547600") + } + if role != "" { + t.Fatalf("role = %q, want empty", role) + } + + _, err = db.ExecContext(ctx, `UPDATE beta_settings SET role_id = 42`) + if err != nil { + t.Fatal(err) + } + _, role, err = r.GetBetaSettings(ctx) + if err != nil { + t.Fatal(err) + } + if role != "42" { + t.Fatalf("role = %q, want %q", role, "42") + } +} diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index 66a4308..017ad5a 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "strconv" "github.com/jackc/pgx/v5/pgconn" ) @@ -401,3 +402,23 @@ func (r *Repo) SetMusicEnabled(ctx context.Context, guildID int64, enabled bool) _, err := r.db.ExecContext(ctx, q, guildID, enabled) return err } + +// GetBetaSettings returns the beta server and Beta Tester role as set on the +// dashboard. Either is "" when not set (or when there's no row yet). +func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err error) { + var g, role sql.NullInt64 + err = r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id = TRUE`).Scan(&g, &role) + if errors.Is(err, sql.ErrNoRows) { + return "", "", nil + } + if err != nil { + return "", "", err + } + if g.Valid { + guildID = strconv.FormatInt(g.Int64, 10) + } + if role.Valid { + roleID = strconv.FormatInt(role.Int64, 10) + } + return guildID, roleID, nil +} diff --git a/main.go b/main.go index 59913a2..a566eab 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,12 @@ package main import ( + "context" "fmt" "log" "os" "os/signal" "syscall" - "velox-bot/internal/beta" "velox-bot/internal/bot" "velox-bot/internal/commands" cmdtickets "velox-bot/internal/commands/tickets" @@ -46,7 +46,6 @@ func main() { log.Fatalf("Error loading config: %v", err) return } - beta.Configure(config.BetaGuildID, config.BetaRoleID) db, err := db.NewDB(config) if err != nil { @@ -94,6 +93,9 @@ func main() { defer bot.Close() + // Who the beta testers are is set on the dashboard; follow it. + bot.WatchBeta(context.Background(), settingsRepo.GetBetaSettings) + fmt.Println("Bot is running...") sc := make(chan os.Signal, 1) diff --git a/schema.sql b/schema.sql index 2319641..e4d84d5 100644 --- a/schema.sql +++ b/schema.sql @@ -73,6 +73,17 @@ CREATE TABLE IF NOT EXISTS meeting_settings ( lobby_channel_id BIGINT ); +-- Who counts as a beta tester: holders of role_id in guild_id. One row, set by +-- the owner on the dashboard's admin page (the API creates and writes it, see +-- its migration 000021); the bot only reads it. +CREATE TABLE IF NOT EXISTS beta_settings ( + id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id), + guild_id BIGINT, + role_id BIGINT, + updated_by BIGINT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + -- 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 old single-lobby column, no longer read.