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 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 00:38:48 +01:00
1 parent 136377d81a
commit a364435bab
15 files changed
+788 -51

No files matched your search

+4 -1
View File
@@ -35,6 +35,8 @@ 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):
@@ -93,7 +95,7 @@ just down # stop postgres
- `/slvl ...` admin/config - `/slvl ...` admin/config
- `/rank`, `/leaderboard`, `/rewards` - `/rank`, `/leaderboard`, `/rewards`
- **Meetings** (**Manage Server**) - **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**
- `/timezone set`, `/timezone show` - `/timezone set`, `/timezone show`
- **Scheduling** - **Scheduling**
@@ -146,4 +148,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.
- 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).
+145
View File
@@ -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)
}
}
+115
View File
@@ -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")
}
}
+44 -1
View File
@@ -4,13 +4,14 @@ import (
"context" "context"
"log" "log"
"time" "time"
"velox-bot/internal/beta"
"velox-bot/internal/commands" "velox-bot/internal/commands"
"velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/repos/musicrepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/events" "velox-bot/internal/events"
"velox-bot/internal/music" "velox-bot/internal/music"
"velox-bot/internal/tickets"
"velox-bot/internal/security" "velox-bot/internal/security"
"velox-bot/internal/tickets"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -113,8 +114,14 @@ 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) {
events.HandleChannelDelete(s, c, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
if eng := security.Default(); eng != nil { if eng := security.Default(); eng != nil {
eng.OnMessage(context.Background(), m) eng.OnMessage(context.Background(), m)
@@ -196,6 +203,42 @@ func (b *Bot) Start() error {
return nil 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 { func (b *Bot) Close() error {
for _, cmd := range b.registeredCommands { for _, cmd := range b.registeredCommands {
if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil { if err := b.Session.ApplicationCommandDelete(b.AppID, b.GuildID, cmd.ID); err != nil {
+86
View File
@@ -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{},
},
})
}
+21 -1
View File
@@ -2,6 +2,8 @@ package commands
import ( import (
"strings" "strings"
"velox-bot/internal/beta"
"velox-bot/internal/commands/betacmd"
"velox-bot/internal/commands/config" "velox-bot/internal/commands/config"
"velox-bot/internal/commands/fun" "velox-bot/internal/commands/fun"
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
@@ -11,8 +13,8 @@ import (
cmdmusic "velox-bot/internal/commands/music" cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/projects" "velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule" "velox-bot/internal/commands/schedule"
cmdtickets "velox-bot/internal/commands/tickets"
cmdsecurity "velox-bot/internal/commands/security" cmdsecurity "velox-bot/internal/commands/security"
cmdtickets "velox-bot/internal/commands/tickets"
"velox-bot/internal/commands/timezone" "velox-bot/internal/commands/timezone"
"velox-bot/internal/music" "velox-bot/internal/music"
@@ -79,6 +81,24 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"unlock": cmdsecurity.UnlockHandler, "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) { func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type { switch i.Type {
case discordgo.InteractionApplicationCommand: case discordgo.InteractionApplicationCommand:
+3 -1
View File
@@ -106,7 +106,9 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
{ {
Name: "Meetings (**Manage Server**)", Name: "Meetings (**Manage Server**)",
Value: "" + 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` — Lock your meeting room\n" +
"`/meeting lock-private` — Lock + hide your meeting room\n" + "`/meeting lock-private` — Lock + hide your meeting room\n" +
"`/meeting unlock` — Unlock your meeting room\n" + "`/meeting unlock` — Unlock your meeting room\n" +
+102 -16
View File
@@ -2,11 +2,14 @@ package public
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"velox-bot/internal/commands/meeting/shared" "velox-bot/internal/commands/meeting/shared"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
meetingsvc "velox-bot/internal/db/services/meeting"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@@ -17,18 +20,37 @@ var Meeting = &discordgo.ApplicationCommand{
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "set-lobby", Name: "add-lobby",
Description: "Set the voice lobby channel used to create meetings", Description: "Add a voice lobby: joining it creates a meeting room",
Options: []*discordgo.ApplicationCommandOption{ Options: []*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionChannel, Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel", Name: "channel",
Description: "Voice channel lobby", Description: "Voice channel to use as a lobby",
Required: true, Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildVoice}, 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, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "lock", Name: "lock",
@@ -85,8 +107,12 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
switch data.Options[0].Name { switch data.Options[0].Name {
case "set-lobby": case "add-lobby":
handleSetLobby(s, i) handleAddLobby(s, i)
case "remove-lobby":
handleRemoveLobby(s, i)
case "lobbies":
handleListLobbies(s, i)
case "lock": case "lock":
handleLock(s, i) handleLock(s, i)
case "lock-private": case "lock-private":
@@ -102,37 +128,97 @@ func MeetingHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
} }
} }
func handleSetLobby(s *discordgo.Session, i *discordgo.InteractionCreate) { // lobbyChannelOption reads the "channel" option of add-lobby and
guildID, ok := shared.ParseGuildID(s, i) // remove-lobby, answering the user itself if it's missing or not a voice
if !ok { // channel.
return func lobbyChannelOption(s *discordgo.Session, i *discordgo.InteractionCreate) (*discordgo.Channel, int64, bool) {
}
optMap := shared.SubcommandOptionMap(i) optMap := shared.SubcommandOptionMap(i)
channelOpt, ok := optMap["channel"] channelOpt, ok := optMap["channel"]
if !ok { if !ok {
shared.RespondEphemeral(s, i, "Missing channel option.") shared.RespondEphemeral(s, i, "Missing channel option.")
return return nil, 0, false
} }
ch := channelOpt.ChannelValue(s) ch := channelOpt.ChannelValue(s)
if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice { if ch == nil || ch.Type != discordgo.ChannelTypeGuildVoice {
shared.RespondEphemeral(s, i, "Invalid voice channel.") shared.RespondEphemeral(s, i, "Invalid voice channel.")
return return nil, 0, false
} }
channelID, err := strconv.ParseInt(ch.ID, 10, 64) channelID, err := strconv.ParseInt(ch.ID, 10, 64)
if err != nil { if err != nil {
shared.RespondEphemeral(s, i, "Invalid channel ID.") 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 return
} }
if err := services.Global.Meeting.SetLobbyChannel(context.Background(), guildID, channelID); err != nil { added, err := services.Global.Meeting.AddLobby(context.Background(), guildID, channelID)
shared.RespondEphemeral(s, i, "Failed to set meeting lobby.") 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 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) { func handleLock(s *discordgo.Session, i *discordgo.InteractionCreate) {
+14
View File
@@ -7,10 +7,16 @@ 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
@@ -33,6 +39,12 @@ 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")
@@ -50,6 +62,8 @@ 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,
@@ -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)
}
}
+65 -23
View File
@@ -3,6 +3,9 @@ package settingsrepo
import ( import (
"context" "context"
"database/sql" "database/sql"
"errors"
"github.com/jackc/pgx/v5/pgconn"
) )
type Repo struct { type Repo struct {
@@ -25,36 +28,75 @@ func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db} 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 = ` const q = `
INSERT INTO meeting_settings (guild_id, lobby_channel_id) INSERT INTO meeting_lobbies (guild_id, channel_id)
VALUES ($1, $2) VALUES ($1, $2)
ON CONFLICT (guild_id) ON CONFLICT DO NOTHING
DO UPDATE SET lobby_channel_id = EXCLUDED.lobby_channel_id
` `
_, err := r.db.ExecContext(ctx, q, guildID, lobbyChannelID) res, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err 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) { // RemoveMeetingLobby takes a channel off the lobby list; removed is false
const q = ` // when it wasn't one.
SELECT lobby_channel_id func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
FROM meeting_settings const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2`
WHERE guild_id = $1 res, err := r.db.ExecContext(ctx, q, guildID, channelID)
` if err != nil {
row := r.db.QueryRowContext(ctx, q, guildID) return false, err
var ch sql.NullInt64
switch err := row.Scan(&ch); err {
case nil:
if !ch.Valid {
return 0, false, nil
} }
return ch.Int64, true, nil n, err := res.RowsAffected()
case sql.ErrNoRows: return n > 0, err
return 0, false, nil }
default:
return 0, false, 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 { func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
+18 -4
View File
@@ -14,12 +14,26 @@ func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings} return &Service{settings: settings}
} }
func (s *Service) SetLobbyChannel(ctx context.Context, guildID int64, lobbyChannelID int64) error { // MaxLobbies is how many lobbies a server can have.
return s.settings.SetMeetingLobbyChannel(ctx, guildID, lobbyChannelID) 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) { func (s *Service) RemoveLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
return s.settings.GetMeetingLobbyChannel(ctx, guildID) 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 { func (s *Service) AddTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
+23 -4
View File
@@ -13,7 +13,7 @@ import (
) )
// HandleVoiceStateUpdate implements the "meeting lobby" behavior: // 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. // with the same UserLimit and move the user into it.
// - When a temporary channel becomes empty, delete it. // - When a temporary channel becomes empty, delete it.
func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) { func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate, svc *services.Services) {
@@ -40,11 +40,12 @@ func HandleVoiceStateUpdate(s *discordgo.Session, vs *discordgo.VoiceStateUpdate
return return
} }
lobbyID, ok, err := svc.Meeting.GetLobbyChannel(ctx, guildID64) joinedID, err := strconv.ParseInt(vs.ChannelID, 10, 64)
if err != nil || !ok || lobbyID == 0 { if err != nil {
return return
} }
if vs.ChannelID != strconv.FormatInt(lobbyID, 10) { isLobby, err := svc.Meeting.IsLobby(ctx, guildID64, joinedID)
if err != nil || !isLobby {
return return
} }
@@ -114,3 +115,21 @@ func tryCleanupTempChannel(s *discordgo.Session, ctx context.Context, svc *servi
_ = svc.Meeting.RemoveTempChannel(ctx, guildID64, chID64) _ = 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)
}
}
+2
View File
@@ -6,6 +6,7 @@ import (
"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"
@@ -45,6 +46,7 @@ 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 {
+27
View File
@@ -73,6 +73,33 @@ CREATE TABLE IF NOT EXISTS meeting_settings (
lobby_channel_id BIGINT 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 ( CREATE TABLE IF NOT EXISTS meeting_temp_channels (
guild_id BIGINT NOT NULL, guild_id BIGINT NOT NULL,
channel_id BIGINT NOT NULL, channel_id BIGINT NOT NULL,