Multiple meeting lobbies and beta-gated features (3.5.2)
- /meeting add-lobby, remove-lobby, lobbies: up to 10 lobbies per server, capped in the database - Deleted voice channels are dropped from the lobbies - Beta features: registered only in the beta server and usable only with the Beta Tester role (BETA_GUILD_ID, BETA_ROLE_ID); fails closed - /beta (bot owner only) shows how beta access is set up Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
136377d81a
commit
a364435bab
15 files changed
+789
-52
No files matched your search
@@ -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
|
||||
}
|
||||
return ch.Int64, true, nil
|
||||
case sql.ErrNoRows:
|
||||
return 0, false, nil
|
||||
default:
|
||||
return 0, false, err
|
||||
// RemoveMeetingLobby takes a channel off the lobby list; removed is false
|
||||
// when it wasn't one.
|
||||
func (r *Repo) RemoveMeetingLobby(ctx context.Context, guildID, channelID int64) (removed bool, err error) {
|
||||
const q = `DELETE FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2`
|
||||
res, err := r.db.ExecContext(ctx, q, guildID, channelID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
// ListMeetingLobbies returns a server's lobbies, oldest first.
|
||||
func (r *Repo) ListMeetingLobbies(ctx context.Context, guildID int64) ([]int64, error) {
|
||||
const q = `SELECT channel_id FROM meeting_lobbies WHERE guild_id = $1 ORDER BY created_at, channel_id`
|
||||
rows, err := r.db.QueryContext(ctx, q, guildID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int64
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// IsMeetingLobby reports whether channelID is one of the server's lobbies.
|
||||
// It runs whenever someone joins a voice channel, so it's one indexed lookup.
|
||||
func (r *Repo) IsMeetingLobby(ctx context.Context, guildID, channelID int64) (bool, error) {
|
||||
const q = `SELECT EXISTS (SELECT 1 FROM meeting_lobbies WHERE guild_id = $1 AND channel_id = $2)`
|
||||
var ok bool
|
||||
err := r.db.QueryRowContext(ctx, q, guildID, channelID).Scan(&ok)
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func (r *Repo) AddMeetingTempChannel(ctx context.Context, guildID, channelID, createdBy int64) error {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user