From a364435bab91bae6ff3a173fa894725e211e5cc5 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Fri, 23 Oct 2026 00:38:43 +0100 Subject: [PATCH] Multiple meeting lobbies and beta-gated features (3.5.2) - /meeting add-lobby, remove-lobby, lobbies: up to 10 lobbies per server, capped in the database - Deleted voice channels are dropped from the lobbies - Beta features: registered only in the beta server and usable only with the Beta Tester role (BETA_GUILD_ID, BETA_ROLE_ID); fails closed - /beta (bot owner only) shows how beta access is set up Co-Authored-By: Claude Sonnet 5.5 --- README.md | 5 +- internal/beta/beta.go | 145 ++++++++++++++++++ internal/beta/beta_test.go | 115 ++++++++++++++ internal/bot/bot.go | 45 +++++- internal/commands/betacmd/betacmd.go | 86 +++++++++++ internal/commands/commands.go | 22 ++- internal/commands/help/help.go | 4 +- internal/commands/meeting/public/meeting.go | 118 ++++++++++++-- internal/config/config.go | 14 ++ .../settingsrepo/meeting_integration_test.go | 119 ++++++++++++++ internal/db/repos/settingsrepo/repo.go | 90 ++++++++--- internal/db/services/meeting/service.go | 22 ++- internal/events/meeting_voice.go | 27 +++- main.go | 2 + schema.sql | 27 ++++ 15 files changed, 789 insertions(+), 52 deletions(-) create mode 100644 internal/beta/beta.go create mode 100644 internal/beta/beta_test.go create mode 100644 internal/commands/betacmd/betacmd.go create mode 100644 internal/db/repos/settingsrepo/meeting_integration_test.go diff --git a/README.md b/README.md index 93e7e0a..6cf3d58 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ 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): @@ -93,7 +95,7 @@ just down # stop postgres - `/slvl ...` admin/config - `/rank`, `/leaderboard`, `/rewards` - **Meetings** (**Manage Server**) - - `/meeting set-lobby`, `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite` + - `/meeting add-lobby`, `/meeting remove-lobby`, `/meeting lobbies` (up to 10 lobby channels per server), `/meeting lock`, `/meeting lock-private`, `/meeting unlock`, `/meeting invite`, `/meeting uninvite` - **Timezone** - `/timezone set`, `/timezone show` - **Scheduling** @@ -146,4 +148,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. - Some features require server permissions (e.g. **Manage Server**) or roles (e.g. **DJ** for music). diff --git a/internal/beta/beta.go b/internal/beta/beta.go new file mode 100644 index 0000000..bd26c77 --- /dev/null +++ b/internal/beta/beta.go @@ -0,0 +1,145 @@ +// Package beta gates features that are still being tried out. A beta feature +// works only for members of the beta server who have the Beta Tester role: +// the server comes from BETA_GUILD_ID and the role from BETA_ROLE_ID, both set +// by the bot's owner in its environment, so nobody can change them from +// Discord. +// +// Everything fails closed. With no role configured, or an ID that isn't a +// Discord ID, nobody gets access. +package beta + +import ( + "log" + "regexp" + "strings" + "sync/atomic" + + "github.com/bwmarrin/discordgo" +) + +// Handler is a slash command handler. +type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate) + +type settings struct{ guildID, roleID string } + +var current atomic.Pointer[settings] + +var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`) + +// idOrEmpty keeps a Discord ID and drops anything else, so a typo in the +// environment turns beta features off instead of matching something odd. +func idOrEmpty(name, v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + if !snowflake.MatchString(v) { + log.Printf("beta: %s %q isn't a Discord ID; beta features stay locked", name, v) + return "" + } + return v +} + +// Configure sets the beta server and the Beta Tester role. Call it once at +// startup. +func Configure(guildID, roleID string) { + current.Store(&settings{ + guildID: idOrEmpty("BETA_GUILD_ID", guildID), + roleID: idOrEmpty("BETA_ROLE_ID", roleID), + }) +} + +func get() settings { + if s := current.Load(); s != nil { + return *s + } + return settings{} +} + +// GuildID is the beta server, or "" when beta features are off. +func GuildID() string { return get().guildID } + +// Status describes where a member stands, for the /beta command. +type Status struct { + InBetaGuild bool // the interaction came from the beta server + RoleConfigured bool // the owner has set a Beta Tester role + HasRole bool // the member has it +} + +// StatusOf reports a member's standing in the guild they're acting in. +func StatusOf(guildID string, member *discordgo.Member) Status { + c := get() + st := Status{ + InBetaGuild: c.guildID != "" && guildID == c.guildID, + RoleConfigured: c.roleID != "", + } + if st.InBetaGuild && st.RoleConfigured && member != nil { + for _, r := range member.Roles { + if r == c.roleID { + st.HasRole = true + break + } + } + } + return st +} + +// Allowed reports whether a member may use beta features in guildID: it has +// to be the beta server, and the member has to have the Beta Tester role. +// Use it for anything that isn't a command, such as an event handler. +func Allowed(guildID string, member *discordgo.Member) bool { + st := StatusOf(guildID, member) + return st.InBetaGuild && st.RoleConfigured && st.HasRole +} + +// AllowedInteraction is Allowed for an interaction. A DM has no member, so it +// never qualifies. The member and their roles come from Discord with the +// interaction, not from anything the user typed. +func AllowedInteraction(i *discordgo.InteractionCreate) bool { + return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member) +} + +// respond is how a refusal is sent; tests replace it. +var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + if i.Type == discordgo.InteractionApplicationCommandAutocomplete { + // Nothing to suggest, and a message isn't a valid reply here. + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{Choices: []*discordgo.ApplicationCommandOptionChoice{}}, + }) + return + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) +} + +const denied = "This feature is in beta and isn't available to you yet." + +// Guard is for the start of a beta feature's handler, and for its component, +// modal and autocomplete handlers too (the command's gate doesn't cover +// those). It answers the user and returns false when they aren't allowed: +// +// if !beta.Guard(s, i) { return } +func Guard(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if AllowedInteraction(i) { + return true + } + respond(s, i, denied) + return false +} + +// Gate wraps a command handler so it only runs for beta testers. +func Gate(h Handler) Handler { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !Guard(s, i) { + return + } + h(s, i) + } +} diff --git a/internal/beta/beta_test.go b/internal/beta/beta_test.go new file mode 100644 index 0000000..6296896 --- /dev/null +++ b/internal/beta/beta_test.go @@ -0,0 +1,115 @@ +package beta + +import ( + "testing" + + "github.com/bwmarrin/discordgo" +) + +const ( + betaGuild = "1170934850157547600" + testerRol = "555000000000000001" +) + +func member(roles ...string) *discordgo.Member { return &discordgo.Member{Roles: roles} } + +func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate { + return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}} +} + +func TestAllowed(t *testing.T) { + cases := []struct { + name string + guild, role string // configuration + inGuild string + member *discordgo.Member + want bool + inBeta, hasRole bool + }{ + {"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true}, + {"member without the role", betaGuild, testerRol, betaGuild, member("1", "2"), false, true, false}, + {"member with no roles at all", betaGuild, testerRol, betaGuild, member(), false, true, false}, + {"the role in another server doesn't count", betaGuild, testerRol, "999000000000000001", member(testerRol), false, false, false}, + {"a DM has no member", betaGuild, testerRol, "", nil, false, false, false}, + {"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, false}, + {"no role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false}, + {"no beta server configured: nobody", "", testerRol, betaGuild, member(testerRol), false, false, false}, + {"a role ID that isn't a number is dropped", betaGuild, "tester-role", betaGuild, member("tester-role"), false, true, false}, + {"a server ID that isn't a number is dropped", "my-server", testerRol, "my-server", member(testerRol), false, false, false}, + {"a role that only contains the ID isn't a match", betaGuild, testerRol, betaGuild, member(testerRol + "9"), false, true, false}, + {"stray spaces in the configuration are ignored", " " + betaGuild + " ", " " + testerRol + "\n", betaGuild, member(testerRol), true, true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + Configure(tc.guild, tc.role) + if got := Allowed(tc.inGuild, tc.member); got != tc.want { + t.Errorf("Allowed = %v, want %v", got, tc.want) + } + if got := AllowedInteraction(interaction(tc.inGuild, tc.member)); got != tc.want { + t.Errorf("AllowedInteraction = %v, want %v", got, tc.want) + } + st := StatusOf(tc.inGuild, tc.member) + if st.InBetaGuild != tc.inBeta || st.HasRole != tc.hasRole { + t.Errorf("StatusOf = %+v, want in beta server %v, has role %v", st, tc.inBeta, tc.hasRole) + } + }) + } +} + +func TestNotConfiguredAtAll(t *testing.T) { + current.Store(nil) + if Allowed(betaGuild, member(testerRol)) || GuildID() != "" { + t.Error("before Configure nothing is allowed and there's no beta server") + } + if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) { + t.Error("a missing interaction is never allowed") + } +} + +func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) { + Configure(betaGuild, testerRol) + var answered []string + orig := respond + respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) { + answered = append(answered, msg) + } + t.Cleanup(func() { respond = orig }) + + ran := 0 + h := Gate(func(*discordgo.Session, *discordgo.InteractionCreate) { ran++ }) + + h(nil, interaction(betaGuild, member(testerRol))) + if ran != 1 || len(answered) != 0 { + t.Fatalf("a tester runs the handler without being refused: ran=%d answered=%v", ran, answered) + } + + for name, i := range map[string]*discordgo.InteractionCreate{ + "no role": interaction(betaGuild, member("1")), + "wrong server": interaction("999000000000000001", member(testerRol)), + "DM": interaction("", nil), + } { + before := ran + h(nil, i) + if ran != before { + t.Errorf("%s: the handler must not run", name) + } + } + if len(answered) != 3 || answered[0] != denied { + t.Errorf("each refusal is answered with the beta message: %v", answered) + } +} + +func TestGuardOnItsOwn(t *testing.T) { + Configure(betaGuild, testerRol) + n := 0 + orig := respond + respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ } + t.Cleanup(func() { respond = orig }) + + if !Guard(nil, interaction(betaGuild, member(testerRol))) || n != 0 { + t.Error("a tester passes without a reply") + } + if Guard(nil, interaction(betaGuild, member())) || n != 1 { + t.Error("someone else is refused with a reply") + } +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 24a951b..7d65e5c 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -4,13 +4,14 @@ import ( "context" "log" "time" + "velox-bot/internal/beta" "velox-bot/internal/commands" "velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/services" "velox-bot/internal/events" "velox-bot/internal/music" - "velox-bot/internal/tickets" "velox-bot/internal/security" + "velox-bot/internal/tickets" "github.com/bwmarrin/discordgo" ) @@ -113,8 +114,14 @@ 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) { + events.HandleChannelDelete(s, c, b.Services) + }) + b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { if eng := security.Default(); eng != nil { eng.OnMessage(context.Background(), m) @@ -196,6 +203,42 @@ func (b *Bot) Start() error { return nil } +// 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() + if guildID == "" { + return + } + keep := map[string]bool{} + for _, cmd := range b.Commands { + if b.GuildID == guildID { + keep[cmd.Name] = true + } + } + for _, cmd := range commands.BetaCommands { + 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) + } + } + existing, err := b.Session.ApplicationCommands(b.AppID, guildID) + if err != nil { + log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err) + return + } + for _, cmd := range existing { + if keep[cmd.Name] { + continue + } + if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil { + log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err) + } + } +} + func (b *Bot) Close() error { for _, cmd := range b.registeredCommands { if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil { diff --git a/internal/commands/betacmd/betacmd.go b/internal/commands/betacmd/betacmd.go new file mode 100644 index 0000000..3a55f55 --- /dev/null +++ b/internal/commands/betacmd/betacmd.go @@ -0,0 +1,86 @@ +// Package betacmd is the /beta command: it shows how beta access is set up. +// It exists only in the beta server (see commands.BetaCommands), is hidden +// from everyone but administrators in Discord's command picker, and only the +// bot's owner can actually run it (the owner is looked up from the Discord +// application, so there's nothing to configure). +package betacmd + +import ( + "sync" + "velox-bot/internal/beta" + + "github.com/bwmarrin/discordgo" +) + +var ( + noDM = false + noPerms = int64(0) // hides the command from everyone but administrators + ownerIDs sync.Map // user ID -> struct{}, filled once from the application + loadOnce sync.Mutex + loaded bool +) + +var Beta = &discordgo.ApplicationCommand{ + Name: "beta", + Description: "Show how beta access is set up (bot owner only)", + DMPermission: &noDM, + DefaultMemberPermissions: &noPerms, +} + +// isOwner reports whether the user owns the bot's Discord application (or is +// on its team). If the application can't be read, nobody is the owner. +func isOwner(s *discordgo.Session, userID string) bool { + if userID == "" { + return false + } + loadOnce.Lock() + if !loaded { + app, err := s.Application("@me") + if err == nil && app != nil { + if app.Owner != nil { + ownerIDs.Store(app.Owner.ID, struct{}{}) + } + if app.Team != nil { + ownerIDs.Store(app.Team.OwnerID, struct{}{}) + for _, m := range app.Team.Members { + if m != nil && m.User != nil { + ownerIDs.Store(m.User.ID, struct{}{}) + } + } + } + loaded = true + } + } + loadOnce.Unlock() + _, ok := ownerIDs.Load(userID) + return ok +} + +func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) { + var userID string + if i.Member != nil && i.Member.User != nil { + userID = i.Member.User.ID + } + var msg string + st := beta.StatusOf(i.GuildID, i.Member) + switch { + case !isOwner(s, userID): + msg = "Only the bot's owner can use this command." + case !st.InBetaGuild: + msg = "Beta features aren't available here." + case !st.RoleConfigured: + msg = "No Beta Tester role is set up, so beta features are locked for everyone." + case st.HasRole: + msg = "You're a beta tester: beta features are unlocked for you in this server." + default: + msg = "You don't have the Beta Tester role, so beta features are locked for you." + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + AllowedMentions: &discordgo.MessageAllowedMentions{}, + }, + }) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index e703c94..4af1251 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -2,6 +2,8 @@ package commands import ( "strings" + "velox-bot/internal/beta" + "velox-bot/internal/commands/betacmd" "velox-bot/internal/commands/config" "velox-bot/internal/commands/fun" "velox-bot/internal/commands/help" @@ -11,8 +13,8 @@ import ( cmdmusic "velox-bot/internal/commands/music" "velox-bot/internal/commands/projects" "velox-bot/internal/commands/schedule" - cmdtickets "velox-bot/internal/commands/tickets" cmdsecurity "velox-bot/internal/commands/security" + cmdtickets "velox-bot/internal/commands/tickets" "velox-bot/internal/commands/timezone" "velox-bot/internal/music" @@ -79,6 +81,24 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "unlock": cmdsecurity.UnlockHandler, } +// BetaCommands are the beta features' slash commands. They're registered in +// the beta server only, so nobody else even sees them, and each handler is +// wrapped so it runs only for members with the Beta Tester role (see the beta +// package). To put a feature in beta, add it with RegisterBeta instead of to +// AllCommands and handlers; to release it, move it there. +var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta} + +func init() { + // /beta itself is open to the whole beta server (see betacmd). + handlers[betacmd.Beta.Name] = betacmd.Handler +} + +// RegisterBeta adds a slash command that only beta testers can use. +func RegisterBeta(cmd *discordgo.ApplicationCommand, h func(s *discordgo.Session, i *discordgo.InteractionCreate)) { + BetaCommands = append(BetaCommands, cmd) + handlers[cmd.Name] = beta.Gate(h) +} + func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.Type { case discordgo.InteractionApplicationCommand: diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 8b66243..1ed53c1 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -106,7 +106,9 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { { Name: "Meetings (**Manage Server**)", Value: "" + - "`/meeting set-lobby channel:#voice` — Set voice lobby used to create meetings\n" + + "`/meeting add-lobby channel:#voice` — Add a voice lobby that creates meetings (up to 10)\n" + + "`/meeting remove-lobby channel:#voice` — Stop using a voice lobby\n" + + "`/meeting lobbies` — List the voice lobbies\n" + "`/meeting lock` — Lock your meeting room\n" + "`/meeting lock-private` — Lock + hide your meeting room\n" + "`/meeting unlock` — Unlock your meeting room\n" + diff --git a/internal/commands/meeting/public/meeting.go b/internal/commands/meeting/public/meeting.go index 8c26065..46ff0d4 100644 --- a/internal/commands/meeting/public/meeting.go +++ b/internal/commands/meeting/public/meeting.go @@ -2,11 +2,14 @@ package public import ( "context" + "errors" "fmt" "strconv" + "strings" "velox-bot/internal/commands/meeting/shared" "velox-bot/internal/db/services" + meetingsvc "velox-bot/internal/db/services/meeting" "github.com/bwmarrin/discordgo" ) @@ -17,18 +20,37 @@ var Meeting = &discordgo.ApplicationCommand{ Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, - Name: "set-lobby", - Description: "Set the voice lobby channel used to create meetings", + Name: "add-lobby", + Description: "Add a voice lobby: joining it creates a meeting room", Options: []*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", - Description: "Voice channel lobby", + Description: "Voice channel to use as a lobby", Required: true, ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, }, }, }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "remove-lobby", + Description: "Stop using a voice channel as a lobby", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "The lobby to remove", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "lobbies", + Description: "List this server's meeting lobbies", + }, { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "lock", @@ -85,8 +107,12 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { } switch data.Options[0].Name { - case "set-lobby": - handleSetLobby(s, i) + case "add-lobby": + handleAddLobby(s, i) + case "remove-lobby": + handleRemoveLobby(s, i) + case "lobbies": + handleListLobbies(s, i) case "lock": handleLock(s, i) case "lock-private": @@ -102,37 +128,97 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { } } -func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { - guildID, ok := shared.ParseGuildID(s, i) - if !ok { - return - } - +// lobbyChannelOption reads the "channel" option of add-lobby and +// remove-lobby, answering the user itself if it's missing or not a voice +// channel. +func lobbyChannelOption(s *discordgo.Session, i *discordgo.InteractionCreate) (*discordgo.Channel, int64, bool) { optMap := shared.SubcommandOptionMap(i) channelOpt, ok := optMap["channel"] if !ok { shared.RespondEphemeral(s, i, "Missing channel option.") - return + return nil, 0, false } ch := channelOpt.ChannelValue(s) if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { shared.RespondEphemeral(s, i, "Invalid voice channel.") - return + return nil, 0, false } channelID, err := strconv.ParseInt(ch.ID, 10, 64) if err != nil { shared.RespondEphemeral(s, i, "Invalid channel ID.") + return nil, 0, false + } + return ch, channelID, true +} + +func handleAddLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + ch, channelID, ok := lobbyChannelOption(s, i) + if !ok { return } - if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { - shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") + added, err := services.Global.Meeting.AddLobby(context.Background(), guildID, channelID) + switch { + case errors.Is(err, meetingsvc.ErrLobbyLimit): + shared.RespondEphemeral(s, i, fmt.Sprintf("This server already has %d lobbies. Remove one with `/meeting remove-lobby` first.", meetingsvc.MaxLobbies)) + case err != nil: + shared.RespondEphemeral(s, i, "Failed to add the meeting lobby.") + case !added: + shared.RespondEphemeral(s, i, ch.Mention()+" is already a meeting lobby.") + default: + shared.RespondEphemeral(s, i, ch.Mention()+" is now a meeting lobby.") + } +} + +func handleRemoveLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + ch, channelID, ok := lobbyChannelOption(s, i) + if !ok { return } - shared.RespondEphemeral(s, i, "Meeting lobby set to "+ch.Mention()+".") + removed, err := services.Global.Meeting.RemoveLobby(context.Background(), guildID, channelID) + switch { + case err != nil: + shared.RespondEphemeral(s, i, "Failed to remove the meeting lobby.") + case !removed: + shared.RespondEphemeral(s, i, ch.Mention()+" isn't a meeting lobby.") + default: + shared.RespondEphemeral(s, i, ch.Mention()+" is no longer a meeting lobby.") + } +} + +func handleListLobbies(s *discordgo.Session, i *discordgo.InteractionCreate) { + guildID, ok := shared.ParseGuildID(s, i) + if !ok { + return + } + + ids, err := services.Global.Meeting.ListLobbies(context.Background(), guildID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to list the meeting lobbies.") + return + } + if len(ids) == 0 { + shared.RespondEphemeral(s, i, "No meeting lobbies yet. Add one with `/meeting add-lobby`.") + return + } + + var b strings.Builder + fmt.Fprintf(&b, "Meeting lobbies (%d of %d):\n", len(ids), meetingsvc.MaxLobbies) + for _, id := range ids { + fmt.Fprintf(&b, "<#%d>\n", id) + } + shared.RespondEphemeral(s, i, b.String()) } func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) { diff --git a/internal/config/config.go b/internal/config/config.go index 6d2b975..1121721 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,10 +7,16 @@ 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 @@ -33,6 +39,12 @@ 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") @@ -50,6 +62,8 @@ 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 new file mode 100644 index 0000000..e73144b --- /dev/null +++ b/internal/db/repos/settingsrepo/meeting_integration_test.go @@ -0,0 +1,119 @@ +//go:build integration + +// Runs against a real Postgres with the dashboard API's migrations applied +// (the lobby limit is a trigger from migration 000020): +// +// TEST_DB=postgres://... go test -tags integration ./internal/db/repos/settingsrepo +package settingsrepo + +import ( + "context" + "database/sql" + "errors" + "os" + "sync" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func testRepo(t *testing.T) (*Repo, int64) { + t.Helper() + dsn := os.Getenv("TEST_DB") + if dsn == "" { + t.Skip("TEST_DB not set") + } + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + const guild = 4242 + if _, err := db.Exec(`DELETE FROM meeting_lobbies WHERE guild_id IN ($1, $2)`, guild, guild+1); err != nil { + t.Fatal(err) + } + return NewRepo(db), guild +} + +func TestMeetingLobbies(t *testing.T) { + r, g := testRepo(t) + ctx := context.Background() + + if ids, err := r.ListMeetingLobbies(ctx, g); err != nil || len(ids) != 0 { + t.Fatalf("a fresh server has no lobbies: %v %v", ids, err) + } + + for ch := int64(1); ch <= MaxMeetingLobbies; ch++ { + added, err := r.AddMeetingLobby(ctx, g, ch) + if err != nil || !added { + t.Fatalf("adding lobby %d: added=%v err=%v", ch, added, err) + } + } + if _, err := r.AddMeetingLobby(ctx, g, 99); !errors.Is(err, ErrMeetingLobbyLimit) { + t.Errorf("an 11th lobby must hit the limit, got %v", err) + } + if added, err := r.AddMeetingLobby(ctx, g, 3); err != nil || added { + t.Errorf("adding an existing lobby is a no-op even at the limit: added=%v err=%v", added, err) + } + if added, err := r.AddMeetingLobby(ctx, g+1, 99); err != nil || !added { + t.Errorf("the limit is per server: added=%v err=%v", added, err) + } + + if ok, _ := r.IsMeetingLobby(ctx, g, 7); !ok { + t.Error("7 is a lobby") + } + if ok, _ := r.IsMeetingLobby(ctx, g, 99); ok { + t.Error("99 isn't a lobby of this server (it is one of the other server's)") + } + + if removed, err := r.RemoveMeetingLobby(ctx, g, 7); err != nil || !removed { + t.Errorf("removing 7: removed=%v err=%v", removed, err) + } + if removed, _ := r.RemoveMeetingLobby(ctx, g, 7); removed { + t.Error("7 was already removed") + } + if ok, _ := r.IsMeetingLobby(ctx, g, 7); ok { + t.Error("7 is no longer a lobby") + } + if added, err := r.AddMeetingLobby(ctx, g, 99); err != nil || !added { + t.Errorf("removing one makes room: added=%v err=%v", added, err) + } + + ids, err := r.ListMeetingLobbies(ctx, g) + if err != nil || len(ids) != MaxMeetingLobbies || ids[len(ids)-1] != 99 { + t.Errorf("oldest first, newest last: %v %v", ids, err) + } +} + +// Two people adding a lobby at the same moment must not both fit under the limit. +func TestMeetingLobbiesConcurrent(t *testing.T) { + r, g := testRepo(t) + ctx := context.Background() + for ch := int64(1); ch < MaxMeetingLobbies; ch++ { + if _, err := r.AddMeetingLobby(ctx, g, ch); err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + var mu sync.Mutex + succeeded := 0 + for i := 0; i < 8; i++ { + wg.Add(1) + go func(ch int64) { + defer wg.Done() + if added, err := r.AddMeetingLobby(ctx, g, ch); err == nil && added { + mu.Lock() + succeeded++ + mu.Unlock() + } + }(int64(100 + i)) + } + wg.Wait() + if succeeded != 1 { + t.Errorf("exactly one of the concurrent adds fits, %d did", succeeded) + } + if ids, _ := r.ListMeetingLobbies(ctx, g); len(ids) != MaxMeetingLobbies { + t.Errorf("have %d lobbies, want %d", len(ids), MaxMeetingLobbies) + } +} diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index 36cd8f1..66a4308 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -3,6 +3,9 @@ package settingsrepo import ( "context" "database/sql" + "errors" + + "github.com/jackc/pgx/v5/pgconn" ) type Repo struct { @@ -25,36 +28,75 @@ func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } -func (r *Repo) SetMeetingLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { +// MaxMeetingLobbies is how many lobby channels one server can have. The +// database enforces it too (a trigger from the API's migration 000020), so +// the command and the dashboard can't disagree. +const MaxMeetingLobbies = 10 + +// ErrMeetingLobbyLimit means the server already has MaxMeetingLobbies. +var ErrMeetingLobbyLimit = errors.New("meeting lobby limit reached") + +// pgLimitExceeded is the error code the database raises for one lobby too many. +const pgLimitExceeded = "54000" + +// AddMeetingLobby makes channelID a lobby. added is false when it already +// was one; going over the limit is ErrMeetingLobbyLimit. +func (r *Repo) AddMeetingLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) { const q = ` - INSERT INTO meeting_settings (guild_id, lobby_channel_id) + INSERT INTO meeting_lobbies (guild_id, channel_id) VALUES ($1, $2) - ON CONFLICT (guild_id) - DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id + ON CONFLICT DO NOTHING ` - _, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) - return err + res, err := r.db.ExecContext(ctx, q, guildID, channelID) + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == pgLimitExceeded { + return false, ErrMeetingLobbyLimit + } + if err != nil { + return false, err + } + n, err := res.RowsAffected() + return n > 0, err } -func (r *Repo) GetMeetingLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { - const q = ` - SELECT lobby_channel_id - FROM meeting_settings - WHERE guild_id = $1 - ` - row := r.db.QueryRowContext(ctx, q, guildID) - var ch sql.NullInt64 - switch err := row.Scan(&ch); err { - case nil: - if !ch.Valid { - return 0, false, nil - } - return ch.Int64, true, nil - case sql.ErrNoRows: - return 0, false, nil - default: - return 0, false, err +// RemoveMeetingLobby takes a channel off the lobby list; removed is false +// when it wasn't one. +func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) { + const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2` + res, err := r.db.ExecContext(ctx, q, guildID, channelID) + if err != nil { + return false, err } + n, err := res.RowsAffected() + return n > 0, err +} + +// ListMeetingLobbies returns a server's lobbies, oldest first. +func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) { + const q = `SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at, channel_id` + rows, err := r.db.QueryContext(ctx, q, guildID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +// IsMeetingLobby reports whether channelID is one of the server's lobbies. +// It runs whenever someone joins a voice channel, so it's one indexed lookup. +func (r *Repo) IsMeetingLobby(ctx context.Context, guildID, channelID int64) (bool, error) { + const q = `SELECT EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2)` + var ok bool + err := r.db.QueryRowContext(ctx, q, guildID, channelID).Scan(&ok) + return ok, err } func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { diff --git a/internal/db/services/meeting/service.go b/internal/db/services/meeting/service.go index 7c2b585..06334ae 100644 --- a/internal/db/services/meeting/service.go +++ b/internal/db/services/meeting/service.go @@ -14,12 +14,26 @@ func New(settings *settingsrepo.Repo) *Service { return &Service{settings: settings} } -func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { - return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) +// MaxLobbies is how many lobbies a server can have. +const MaxLobbies = settingsrepo.MaxMeetingLobbies + +// ErrLobbyLimit means the server already has MaxLobbies. +var ErrLobbyLimit = settingsrepo.ErrMeetingLobbyLimit + +func (s *Service) AddLobby(ctx context.Context, guildID, channelID int64) (added bool, err error) { + return s.settings.AddMeetingLobby(ctx, guildID, channelID) } -func (s *Service) GetLobbyChannel(ctx context.Context, guildID int64) (channelID int64, ok bool, err error) { - return s.settings.GetMeetingLobbyChannel(ctx, guildID) +func (s *Service) RemoveLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) { + return s.settings.RemoveMeetingLobby(ctx, guildID, channelID) +} + +func (s *Service) ListLobbies(ctx context.Context, guildID int64) ([]int64, error) { + return s.settings.ListMeetingLobbies(ctx, guildID) +} + +func (s *Service) IsLobby(ctx context.Context, guildID, channelID int64) (bool, error) { + return s.settings.IsMeetingLobby(ctx, guildID, channelID) } func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error { diff --git a/internal/events/meeting_voice.go b/internal/events/meeting_voice.go index d63314a..d08f631 100644 --- a/internal/events/meeting_voice.go +++ b/internal/events/meeting_voice.go @@ -13,7 +13,7 @@ import ( ) // HandleVoiceStateUpdate implements the "meeting lobby" behavior: -// - When a user joins the configured lobby voice channel, create a temporary voice channel +// - When a user joins one of the server's lobby voice channels, create a temporary voice channel // with the same UserLimit and move the user into it. // - When a temporary channel becomes empty, delete it. func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { @@ -40,11 +40,12 @@ func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate return } - lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) - if err != nil || !ok || lobbyID == 0 { + joinedID, err := strconv.ParseInt(vs.ChannelID, 10, 64) + if err != nil { return } - if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { + isLobby, err := svc.Meeting.IsLobby(ctx, guildID64, joinedID) + if err != nil || !isLobby { return } @@ -114,3 +115,21 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi _ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) } +// HandleChannelDelete drops a deleted voice channel from the meeting lobbies, +// so it doesn't keep using up one of the server's slots. +func HandleChannelDelete(_ *discordgo.Session, c *discordgo.ChannelDelete, svc *services.Services) { + if svc == nil || svc.Meeting == nil || c == nil || c.Channel == nil || c.GuildID == "" { + return + } + if c.Type != discordgo.ChannelTypeGuildVoice { + return + } + guildID, err1 := strconv.ParseInt(c.GuildID, 10, 64) + channelID, err2 := strconv.ParseInt(c.ID, 10, 64) + if err1 != nil || err2 != nil { + return + } + if _, err := svc.Meeting.RemoveLobby(context.Background(), guildID, channelID); err != nil { + log.Printf("meeting: removing deleted lobby guild=%s channel=%s err=%v", c.GuildID, c.ID, err) + } +} diff --git a/main.go b/main.go index 2690a70..59913a2 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "os/signal" "syscall" + "velox-bot/internal/beta" "velox-bot/internal/bot" "velox-bot/internal/commands" cmdtickets "velox-bot/internal/commands/tickets" @@ -45,6 +46,7 @@ func main() { log.Fatalf("Error loading config: %v", err) return } + beta.Configure(config.BetaGuildID, config.BetaRoleID) db, err := db.NewDB(config) if err != nil { diff --git a/schema.sql b/schema.sql index 83e1d09..2319641 100644 --- a/schema.sql +++ b/schema.sql @@ -73,6 +73,33 @@ CREATE TABLE IF NOT EXISTS meeting_settings ( lobby_channel_id BIGINT ); +-- 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. +CREATE TABLE IF NOT EXISTS meeting_lobbies ( + guild_id BIGINT NOT NULL, + channel_id BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (guild_id, channel_id) +); + +CREATE OR REPLACE FUNCTION meeting_lobbies_limit() RETURNS trigger AS $$ +BEGIN + PERFORM pg_advisory_xact_lock(NEW.guild_id); + IF EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = NEW.guild_id AND channel_id = NEW.channel_id) THEN + RETURN NEW; + END IF; + IF (SELECT COUNT(*) FROM meeting_lobbies WHERE guild_id = NEW.guild_id) >= 10 THEN + RAISE EXCEPTION 'a server can have at most 10 lobby channels' USING ERRCODE = '54000'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS meeting_lobbies_limit ON meeting_lobbies; +CREATE TRIGGER meeting_lobbies_limit BEFORE INSERT ON meeting_lobbies + FOR EACH ROW EXECUTE FUNCTION meeting_lobbies_limit(); + CREATE TABLE IF NOT EXISTS meeting_temp_channels ( guild_id BIGINT NOT NULL, channel_id BIGINT NOT NULL,