Beta server and role come from the dashboard
The owner sets them on the admin page; the bot polls the shared beta_settings row (30s) and moves the beta commands if the server changes. BETA_* env vars are gone. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
a364435bab
commit
1ee6280d91
7 files changed
+157
-24
No files matched your search
@@ -35,8 +35,6 @@ Required:
|
|||||||
- `BOT_TOKEN`: Discord bot token
|
- `BOT_TOKEN`: Discord bot token
|
||||||
- `APP_ID`: Discord application ID
|
- `APP_ID`: Discord application ID
|
||||||
- `GUILD_ID`: Target guild/server ID (commands are registered per-guild)
|
- `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`
|
- `DB_HOST`: Postgres connection string (pgx), e.g. `postgres://velox:velox_pwd@localhost:5432/velox?sslmode=disable`
|
||||||
|
|
||||||
Optional (music):
|
Optional (music):
|
||||||
@@ -148,5 +146,5 @@ just down # stop postgres
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Slash commands are registered to the configured `GUILD_ID` on startup.
|
- 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).
|
- Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music).
|
||||||
+57
-5
@@ -27,6 +27,7 @@ type Bot struct {
|
|||||||
DBHost string
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass 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) {
|
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.registeredCommands = append(b.registeredCommands, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
b.registerBetaCommands()
|
|
||||||
|
|
||||||
b.Session.AddHandler(commands.HandleInteraction)
|
b.Session.AddHandler(commands.HandleInteraction)
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
|
||||||
@@ -203,12 +202,65 @@ func (b *Bot) Start() error {
|
|||||||
return nil
|
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
|
// 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
|
// 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
|
// longer beta (or no longer exist) are removed. When the beta server is also
|
||||||
// the bot's main server, its regular commands are kept.
|
// the bot's main server, its regular commands are kept. If the beta server
|
||||||
func (b *Bot) registerBetaCommands() {
|
// changed, the beta commands are taken out of the old one.
|
||||||
guildID := beta.GuildID()
|
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 == "" {
|
if guildID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,10 @@ import (
|
|||||||
"github.com/joho/godotenv"
|
"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 {
|
type Config struct {
|
||||||
BotToken string
|
BotToken string
|
||||||
AppID string
|
AppID string
|
||||||
GuildID 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
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass string
|
LavalinkPass string
|
||||||
@@ -39,12 +33,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
|
|
||||||
guildID := os.Getenv("GUILD_ID")
|
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")
|
dbHost := os.Getenv("DB_HOST")
|
||||||
if dbHost == "" {
|
if dbHost == "" {
|
||||||
return nil, fmt.Errorf("DB_HOST is not set")
|
return nil, fmt.Errorf("DB_HOST is not set")
|
||||||
@@ -62,8 +50,6 @@ func LoadConfig() (*Config, error) {
|
|||||||
BotToken: token,
|
BotToken: token,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
BetaGuildID: betaGuildID,
|
|
||||||
BetaRoleID: betaRoleID,
|
|
||||||
DBHost: dbHost,
|
DBHost: dbHost,
|
||||||
LavalinkHost: lavalinkHost,
|
LavalinkHost: lavalinkHost,
|
||||||
LavalinkPass: lavalinkPass,
|
LavalinkPass: lavalinkPass,
|
||||||
|
|||||||
@@ -117,3 +117,66 @@ func TestMeetingLobbiesConcurrent(t *testing.T) {
|
|||||||
t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"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)
|
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
|
||||||
return err
|
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
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"velox-bot/internal/beta"
|
|
||||||
"velox-bot/internal/bot"
|
"velox-bot/internal/bot"
|
||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
cmdtickets "velox-bot/internal/commands/tickets"
|
cmdtickets "velox-bot/internal/commands/tickets"
|
||||||
@@ -46,7 +46,6 @@ func main() {
|
|||||||
log.Fatalf("Error loading config: %v", err)
|
log.Fatalf("Error loading config: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
beta.Configure(config.BetaGuildID, config.BetaRoleID)
|
|
||||||
|
|
||||||
db, err := db.NewDB(config)
|
db, err := db.NewDB(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,6 +93,9 @@ func main() {
|
|||||||
|
|
||||||
defer bot.Close()
|
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...")
|
fmt.Println("Bot is running...")
|
||||||
|
|
||||||
sc := make(chan os.Signal, 1)
|
sc := make(chan os.Signal, 1)
|
||||||
|
|||||||
+11
@@ -73,6 +73,17 @@ CREATE TABLE IF NOT EXISTS meeting_settings (
|
|||||||
lobby_channel_id BIGINT
|
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
|
-- 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.
|
||||||
|
|||||||
Reference in new issue
Block a user