v3.5.2: multiple meeting lobbies and beta features #25
No files matched your search
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// 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
|
||||
}
|
||||
return ch.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, 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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+27
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user