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
|
- `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).
|
||||||
@@ -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"
|
"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 {
|
||||||
|
|||||||
@@ -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 (
|
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:
|
||||||
|
|||||||
@@ -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" +
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user