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
+789 -52

No files matched your search

+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 (
"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:
+3 -1
View File
@@ -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" +
+102 -16
View File
@@ -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) {