Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d086d46fe5 | ||
|
|
279607183a | ||
|
|
9839bba50e | ||
|
|
fcf4f3ccbd |
No files matched your search
@@ -84,7 +84,7 @@ Some commands have a response that takes longer than three seconds (anything tha
|
|||||||
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
|
| Schedule reminders | `events/schedule_reminders.go` | 1 min | DMs participants 10 minutes before a session |
|
||||||
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
|
| Raid watch | `security` | 30 s | Ends raid mode after a quiet period |
|
||||||
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
|
| Snapshots | `security` | 6 h | Saves roles and channels while anti-nuke is on (keeps three) |
|
||||||
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are |
|
| Beta settings | `bot/bot.go` (`WatchBeta`) | 30 s | Re-reads who the beta testers are and which servers opted in, and adds or removes beta commands to match |
|
||||||
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests |
|
| Command listeners | music, tickets, security | on `NOTIFY` | Carry out dashboard requests |
|
||||||
|
|
||||||
## The link to the dashboard
|
## The link to the dashboard
|
||||||
|
|||||||
+46
-14
@@ -1,24 +1,43 @@
|
|||||||
# Beta features
|
# Beta features
|
||||||
|
|
||||||
A beta feature is one that only a few chosen people can use while it is still being tried out. The same rule applies on the bot and on the dashboard: you can use beta features if you are **a member of the beta server and hold the Beta Tester role**.
|
A beta feature is one that is still being tried out, so only chosen people can use it, and only where the server agreed. Two things must both be true:
|
||||||
|
|
||||||
|
1. **The person is a beta tester**: a member of the beta server who holds the Beta Tester role.
|
||||||
|
2. **Beta features are on in the server they use it in**: the beta server itself, or any other server whose owner opted in.
|
||||||
|
|
||||||
|
The same rule applies on the bot and on the dashboard.
|
||||||
|
|
||||||
## Who the testers are
|
## Who the testers are
|
||||||
|
|
||||||
- The **beta server** and the **Beta Tester role** are chosen by the bot's owner on the dashboard, under **Admin, Beta access**. Only the owner can see or change this page.
|
- The **beta server** and the **Beta Tester role** are chosen by the bot's owner on the dashboard, under **Admin, Beta access**. Only the owner can see or change that page.
|
||||||
- Giving someone the role in Discord makes them a tester. Taking it away removes their access: within about 30 seconds on the bot, and within about 30 seconds on the dashboard API.
|
- Giving someone the role in Discord makes them a tester. Taking it away removes their access: within about 30 seconds on the bot (a few seconds if the bot has them cached), and within about 30 seconds on the dashboard API.
|
||||||
- Until a role is chosen, **nobody** has beta access.
|
- Until a role is chosen, **nobody** has beta access.
|
||||||
- The settings are stored in the `beta_settings` table (one row). The API writes it, and the bot polls it every 30 seconds. There is nothing to put in `.env`.
|
- Being a tester is about the person, so it is always checked in the **beta server**, also when they use a feature somewhere else. Having a role with the same ID in another server means nothing.
|
||||||
|
|
||||||
Access **fails closed**. If the settings cannot be read, if the role or the server is not set, if a person is not in the server, if a command is used in a DM, or if Discord cannot be reached, access is denied.
|
## Which servers have beta features
|
||||||
|
|
||||||
|
- The beta server always has them.
|
||||||
|
- Any other server gets them only when **its owner** switches on **Beta features** on that server's page of the dashboard. Beta features can act on a whole server (anti-raid behaviour, for example), so the people who answer for the server decide. A server's admins who are not its owner cannot switch it on.
|
||||||
|
- The bot's owner can take a server out again from **Admin, Beta access**, but cannot put one in.
|
||||||
|
- The list of opted-in servers is the `beta_guilds` table. The bot polls it every 30 seconds, together with the beta settings.
|
||||||
|
|
||||||
|
Beta never replaces a feature's own permission checks. A beta command that needs Manage Server still needs it, a music command still needs the DJ role, and anything for the owner or trusted users still needs that.
|
||||||
|
|
||||||
|
## What a tester sees
|
||||||
|
|
||||||
|
- **Bot:** beta commands appear only in the beta server and in opted-in servers, and run only for testers. When a server opts in, the commands show up within about 30 seconds. When it opts out, they are removed.
|
||||||
|
- **Dashboard:** beta pages and links show a **Beta** badge, and only to testers, and only for servers where beta features are on.
|
||||||
|
|
||||||
## On the bot
|
## On the bot
|
||||||
|
|
||||||
Code: `internal/beta` (the gate), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `registerBetaCommands`), `internal/commands/betacmd` (`/beta`).
|
Code: `internal/beta` (the rules), `internal/commands/commands.go` (`RegisterBeta`), `internal/bot/bot.go` (`WatchBeta`, `syncBetaCommands`), `internal/commands/betacmd` (`/beta`).
|
||||||
|
|
||||||
- Beta commands are registered **only in the beta server**, so nobody else sees them in the command menu.
|
- Beta commands are registered in each server where beta features are on, and only there. `/beta` is only in the beta server.
|
||||||
- Each beta handler is wrapped in `beta.Gate`, which checks that the interaction came from the beta server and that the member has the role. If not, the user gets "This feature is in beta and isn't available to you yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
|
- Each beta handler is wrapped in `beta.Gate`. It checks that beta is on in the server of the interaction and that the person is a tester, and otherwise answers "This feature is in beta and isn't available to you or this server yet." (Autocomplete requests get an empty list.) Hiding the command is never the only protection.
|
||||||
- If the owner changes the beta server on the dashboard, the bot moves the beta commands to the new server within 30 seconds and removes them from the old one. Commands in the beta server that are neither regular nor beta commands are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
|
- For a person using a command in another server, the bot looks up their roles in the beta server: first in its gateway cache, otherwise it asks Discord (remembered for 30 seconds). If anything fails, the answer is no.
|
||||||
- `/beta` shows the owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
|
- If the owner changes the beta server on the dashboard, the beta commands move within 30 seconds. In the beta server, commands this bot left there that no longer exist are removed too. If the beta server is also `GUILD_ID`, the regular commands are kept.
|
||||||
|
- `/beta` shows the bot's owner how access is set up. Only the owner of the Discord application can run it, and it is hidden from everyone but administrators.
|
||||||
|
- The bot remembers the servers where it registered beta commands (`beta_registered` table). A server that opted out while the bot was **offline** is cleaned up on the next start, and a cleanup that fails is retried every 30 seconds. A server the bot has left is forgotten.
|
||||||
|
|
||||||
### Add a beta command
|
### Add a beta command
|
||||||
|
|
||||||
@@ -30,22 +49,35 @@ func init() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: check `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
|
`RegisterBeta` adds the definition to `BetaCommands` and the handler, wrapped in `beta.Gate`, to the dispatcher. Buttons and forms that belong to a beta command need the same care: call `beta.Guard(s, i)` at the top of their handlers, because a custom ID can be reached from an old message.
|
||||||
|
|
||||||
|
### Add beta behaviour that no one runs
|
||||||
|
|
||||||
|
Event handlers and background jobs have no person to check. Gate them on the server only:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if !beta.ServerEnabled(guildID) {
|
||||||
|
return // beta features are off here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not use this for anything that should only be for testers as people. It means "this server agreed to try beta features", and every member of the server is affected.
|
||||||
|
|
||||||
### Release it
|
### Release it
|
||||||
|
|
||||||
Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and it is removed from the beta server's own list because it is now a regular command.
|
Move the definition into `AllCommands` and the handler into the `handlers` map, and delete the `RegisterBeta` call. On the next start the command appears everywhere, and the beta servers drop it from their own lists because it is now a regular command.
|
||||||
|
|
||||||
## On the API and dashboard
|
## On the API and dashboard
|
||||||
|
|
||||||
These live in the other two repositories.
|
These live in the other two repositories.
|
||||||
|
|
||||||
- **API**: put the feature's routes under `/api/v1/beta`. That route group sits behind a `betaOnly` middleware, which asks Discord whether the signed-in user holds the role in the beta server (with a 30-second memory) and answers `403` otherwise. `GET /users/me` also returns `is_beta`, which the dashboard uses to decide what to show.
|
- **API**: put the feature's routes under `/api/v1/beta` (not tied to a server), or add the `betaGuildOnly()` middleware to a route under `/api/v1/guilds/:guild_id/...` (tied to a server). The first asks Discord whether the signed-in user holds the role in the beta server (30-second memory); the second also checks that the server is the beta server or opted in. Both answer `403` otherwise. `GET /users/me` returns `is_beta`, and `GET /guilds/:guild_id/beta` tells the dashboard whether beta features are on for that server and whether the caller owns it.
|
||||||
- **Dashboard**: give a route `meta: { requiresBeta: true }` to keep its page away from non-testers, and use `is_beta` to hide its links. This is only about display. The API checks access on every request, so a page that was shown by mistake still cannot read or change anything.
|
- **Dashboard**: give a route `meta: { requiresBeta: true }` to keep its page away from non-testers, and mark navigation entries with `beta: true` so testers see them with a **Beta** badge (and nobody else sees them). This is only about display. The API checks access on every request, so a page that was shown by mistake still cannot read or change anything.
|
||||||
|
|
||||||
## Why it works this way
|
## Why it works this way
|
||||||
|
|
||||||
- The role in Discord is the single list of testers, so there is no second list to keep in step with it.
|
- The role in Discord is the single list of testers, so there is no second list to keep in step with it.
|
||||||
|
- Servers choose to take part, because a beta feature can affect everyone in them.
|
||||||
- Access is decided by the server (bot and API), never by the browser.
|
- Access is decided by the server (bot and API), never by the browser.
|
||||||
- Beta commands do not exist outside the beta server, and the gate checks again when they run.
|
- Beta commands do not exist outside the beta server, and the gate checks again when they run.
|
||||||
- The settings are in the database, not in the environment, so the owner can change them without a deploy.
|
- The settings are in the database, not in the environment, so the owner can change them without a deploy.
|
||||||
@@ -175,4 +175,6 @@ In this table "Manage Server" also lets Administrators and trusted members throu
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
|
| `/beta` | The bot's owner | Shows how beta access is set up. Registered in the beta server only |
|
||||||
|
|
||||||
|
Other beta commands appear only in the beta server and in servers whose owner opted in, and only testers can run them.
|
||||||
|
|
||||||
Beta features come and go. See [Beta features](beta-features.md).
|
Beta features come and go. See [Beta features](beta-features.md).
|
||||||
+1
-1
@@ -33,7 +33,7 @@ Two things only exist in the API's migrations, because the bot never inserts int
|
|||||||
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard |
|
| Music | `music_settings`, `music_now_playing`, `music_queue`, `music_commands` | State for the dashboard's player, plus requests from the dashboard |
|
||||||
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
|
| Tickets | `ticket_settings`, `ticket_categories`, `tickets`, `ticket_commands` | |
|
||||||
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
|
| Security | `security_settings`, `security_trusted`, `security_quarantines`, `security_lockdowns`, `security_raids`, `security_raid_caught`, `security_snapshots`, `security_events`, `security_commands` | Settings, who is trusted, quarantines (with the roles to give back), locked channels (with the permission to restore), raids in progress, snapshots, an event history, and dashboard requests |
|
||||||
| Beta | `beta_settings` | One row: the beta server and the Beta Tester role. Written by the dashboard, read by the bot |
|
| Beta | `beta_settings`, `beta_guilds` | `beta_settings` is one row: the beta server and the Beta Tester role. `beta_guilds` lists servers whose owner opted in to beta features. Both are written by the dashboard and read by the bot. `beta_registered` is the bot's own note of where it registered beta commands |
|
||||||
|
|
||||||
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
|
Discord IDs are `BIGINT` (snowflakes fit in 64 bits). In Go they are `int64`; Discord's own API gives them to you as strings, so convert at the edge.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ The dashboard talks to the bot through the database (see [Database](database.md#
|
|||||||
|
|
||||||
## Beta features are not available to a tester
|
## Beta features are not available to a tester
|
||||||
|
|
||||||
Go through [Beta features](beta-features.md): is the Beta Tester role chosen on the dashboard? Is the person in the beta server, and do they have the role? Has 30 seconds passed? `/beta` (owner only) shows how access is set up.
|
Go through [Beta features](beta-features.md): is the Beta Tester role chosen on the dashboard? Is the person in the **beta server**, and do they have the role there? Is the server where they use it the beta server, or has **its owner** switched on Beta features on the dashboard? Has 30 seconds passed? `/beta` (owner only) shows how access is set up.
|
||||||
|
|
||||||
## Getting more detail
|
## Getting more detail
|
||||||
|
|
||||||
|
|||||||
+105
-35
@@ -1,16 +1,30 @@
|
|||||||
// Package beta gates features that are still being tried out. A beta feature
|
// Package beta gates features that are still being tried out.
|
||||||
// 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
|
// A beta feature works for a beta tester, in a server where beta features are
|
||||||
// Discord ID, nobody gets access.
|
// on:
|
||||||
|
//
|
||||||
|
// - A beta tester is a member of the beta server who has the Beta Tester
|
||||||
|
// role. Both are chosen by the bot's owner on the dashboard.
|
||||||
|
// - Beta features are on in the beta server itself, and in every other
|
||||||
|
// server whose owner opted in on the dashboard. A feature that can act on
|
||||||
|
// a whole server never reaches a server that didn't ask for it.
|
||||||
|
//
|
||||||
|
// Being a tester is about the person, so it is looked up in the beta server
|
||||||
|
// even when they use a feature somewhere else (see SetRolesLookup). Beta
|
||||||
|
// never replaces a feature's own permission checks: a beta command that needs
|
||||||
|
// Manage Server still needs it.
|
||||||
|
//
|
||||||
|
// Features that act without anyone running a command (an event handler, a
|
||||||
|
// background job) have no person to check, so they use ServerEnabled only.
|
||||||
|
//
|
||||||
|
// Everything fails closed. With no role configured, an ID that isn't a
|
||||||
|
// Discord ID, or a lookup that fails, nobody gets access.
|
||||||
package beta
|
package beta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
@@ -20,32 +34,43 @@ import (
|
|||||||
// Handler is a slash command handler.
|
// Handler is a slash command handler.
|
||||||
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
|
type Handler = func(s *discordgo.Session, i *discordgo.InteractionCreate)
|
||||||
|
|
||||||
type settings struct{ guildID, roleID string }
|
type settings struct {
|
||||||
|
guildID, roleID string
|
||||||
|
servers map[string]bool // opted-in servers, besides the beta server
|
||||||
|
}
|
||||||
|
|
||||||
var current atomic.Pointer[settings]
|
var current atomic.Pointer[settings]
|
||||||
|
|
||||||
var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
|
var snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
|
||||||
|
|
||||||
// idOrEmpty keeps a Discord ID and drops anything else, so a typo in the
|
// idOrEmpty keeps a Discord ID and drops anything else, so a bad value turns
|
||||||
// environment turns beta features off instead of matching something odd.
|
// beta features off instead of matching something odd.
|
||||||
func idOrEmpty(name, v string) string {
|
func idOrEmpty(name, v string) string {
|
||||||
v = strings.TrimSpace(v)
|
v = strings.TrimSpace(v)
|
||||||
if v == "" {
|
if v == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !snowflake.MatchString(v) {
|
if !snowflake.MatchString(v) {
|
||||||
log.Printf("beta: %s %q isn't a Discord ID; beta features stay locked", name, v)
|
log.Printf("beta: %s %q isn't a Discord ID; ignoring it", name, v)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure sets the beta server and the Beta Tester role. Call it once at
|
// Configure sets the beta server, the Beta Tester role and the servers that
|
||||||
// startup.
|
// opted in. It replaces everything set before, so call it with the full
|
||||||
func Configure(guildID, roleID string) {
|
// picture each time.
|
||||||
|
func Configure(guildID, roleID string, servers []string) {
|
||||||
|
set := make(map[string]bool, len(servers))
|
||||||
|
for _, id := range servers {
|
||||||
|
if id = idOrEmpty("beta server", id); id != "" {
|
||||||
|
set[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
current.Store(&settings{
|
current.Store(&settings{
|
||||||
guildID: idOrEmpty("BETA_GUILD_ID", guildID),
|
guildID: idOrEmpty("beta server", guildID),
|
||||||
roleID: idOrEmpty("BETA_ROLE_ID", roleID),
|
roleID: idOrEmpty("beta role", roleID),
|
||||||
|
servers: set,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,45 +81,90 @@ func get() settings {
|
|||||||
return settings{}
|
return settings{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GuildID is the beta server, or "" when beta features are off.
|
// RolesFunc returns a member's role IDs in a server, as Discord has them now,
|
||||||
|
// or an error (including "not in that server").
|
||||||
|
type RolesFunc func(guildID, userID string) ([]string, error)
|
||||||
|
|
||||||
|
var lookup atomic.Pointer[RolesFunc]
|
||||||
|
|
||||||
|
// SetRolesLookup tells the package how to find someone's roles in the beta
|
||||||
|
// server when they use a feature in another server. Without it, testers only
|
||||||
|
// qualify in the beta server itself.
|
||||||
|
func SetRolesLookup(f RolesFunc) { lookup.Store(&f) }
|
||||||
|
|
||||||
|
// GuildID is the beta server, or "" when it isn't set.
|
||||||
func GuildID() string { return get().guildID }
|
func GuildID() string { return get().guildID }
|
||||||
|
|
||||||
|
// Servers lists the servers that opted in, besides the beta server, in a
|
||||||
|
// stable order.
|
||||||
|
func Servers() []string {
|
||||||
|
c := get()
|
||||||
|
out := make([]string, 0, len(c.servers))
|
||||||
|
for id := range c.servers {
|
||||||
|
if id != c.guildID {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerEnabled reports whether beta features are on in a server: the beta
|
||||||
|
// server, or one whose owner opted in. Use it alone for anything no person
|
||||||
|
// triggers.
|
||||||
|
func ServerEnabled(guildID string) bool {
|
||||||
|
c := get()
|
||||||
|
return guildID != "" && (guildID == c.guildID || c.servers[guildID])
|
||||||
|
}
|
||||||
|
|
||||||
// Status describes where a member stands, for the /beta command.
|
// Status describes where a member stands, for the /beta command.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
InBetaGuild bool // the interaction came from the beta server
|
ServerEnabled bool // beta features are on in this server
|
||||||
RoleConfigured bool // the owner has set a Beta Tester role
|
RoleConfigured bool // the owner has set a Beta Tester role
|
||||||
HasRole bool // the member has it
|
HasRole bool // the member is a tester
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusOf reports a member's standing in the guild they're acting in.
|
// StatusOf reports a member's standing in the server they're acting in.
|
||||||
func StatusOf(guildID string, member *discordgo.Member) Status {
|
func StatusOf(guildID string, member *discordgo.Member) Status {
|
||||||
c := get()
|
c := get()
|
||||||
st := Status{
|
st := Status{
|
||||||
InBetaGuild: c.guildID != "" && guildID == c.guildID,
|
ServerEnabled: ServerEnabled(guildID),
|
||||||
RoleConfigured: c.roleID != "",
|
RoleConfigured: c.roleID != "",
|
||||||
}
|
}
|
||||||
if st.InBetaGuild && st.RoleConfigured && member != nil {
|
if st.ServerEnabled && st.RoleConfigured && member != nil {
|
||||||
for _, r := range member.Roles {
|
st.HasRole = isTester(c, guildID, member)
|
||||||
if r == c.roleID {
|
|
||||||
st.HasRole = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed reports whether a member may use beta features in guildID: it has
|
// isTester checks the Beta Tester role in the beta server. In the beta server
|
||||||
// to be the beta server, and the member has to have the Beta Tester role.
|
// the roles come with the interaction. Anywhere else they are looked up.
|
||||||
// Use it for anything that isn't a command, such as an event handler.
|
func isTester(c settings, guildID string, member *discordgo.Member) bool {
|
||||||
|
if guildID == c.guildID {
|
||||||
|
return slices.Contains(member.Roles, c.roleID)
|
||||||
|
}
|
||||||
|
f := lookup.Load()
|
||||||
|
if f == nil || *f == nil || member.User == nil || member.User.ID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
roles, err := (*f)(c.guildID, member.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return slices.Contains(roles, c.roleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed reports whether a member may use beta features in guildID: beta
|
||||||
|
// features have to be on there, and the member has to be a tester. Use it for
|
||||||
|
// anything a person triggers that isn't a command, such as a button.
|
||||||
func Allowed(guildID string, member *discordgo.Member) bool {
|
func Allowed(guildID string, member *discordgo.Member) bool {
|
||||||
st := StatusOf(guildID, member)
|
st := StatusOf(guildID, member)
|
||||||
return st.InBetaGuild && st.RoleConfigured && st.HasRole
|
return st.ServerEnabled && st.RoleConfigured && st.HasRole
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedInteraction is Allowed for an interaction. A DM has no member, so it
|
// 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
|
// never qualifies. The member comes from Discord with the interaction, not
|
||||||
// interaction, not from anything the user typed.
|
// from anything the user typed.
|
||||||
func AllowedInteraction(i *discordgo.InteractionCreate) bool {
|
func AllowedInteraction(i *discordgo.InteractionCreate) bool {
|
||||||
return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member)
|
return i != nil && i.Interaction != nil && Allowed(i.GuildID, i.Member)
|
||||||
}
|
}
|
||||||
@@ -119,7 +189,7 @@ var respond = func(s *discordgo.Session, i *discordgo.InteractionCreate, msg str
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const denied = "This feature is in beta and isn't available to you yet."
|
const denied = "This feature is in beta and isn't available to you or this server yet."
|
||||||
|
|
||||||
// Guard is for the start of a beta feature's handler, and for its component,
|
// 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
|
// modal and autocomplete handlers too (the command's gate doesn't cover
|
||||||
|
|||||||
+137
-12
@@ -1,6 +1,7 @@
|
|||||||
package beta
|
package beta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
@@ -8,28 +9,46 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
betaGuild = "1170934850157547600"
|
betaGuild = "1170934850157547600"
|
||||||
|
otherGuild = "999000000000000001"
|
||||||
testerRol = "555000000000000001"
|
testerRol = "555000000000000001"
|
||||||
|
testerID = "777000000000000001"
|
||||||
)
|
)
|
||||||
|
|
||||||
func member(roles ...string) *discordgo.Member { return &discordgo.Member{Roles: roles} }
|
func member(roles ...string) *discordgo.Member {
|
||||||
|
return &discordgo.Member{Roles: roles, User: &discordgo.User{ID: testerID}}
|
||||||
|
}
|
||||||
|
|
||||||
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
|
func interaction(guildID string, m *discordgo.Member) *discordgo.InteractionCreate {
|
||||||
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
|
return &discordgo.InteractionCreate{Interaction: &discordgo.Interaction{GuildID: guildID, Member: m}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAllowed(t *testing.T) {
|
// useLookup makes the beta server say who holds which roles, as Discord would.
|
||||||
|
func useLookup(t *testing.T, rolesByUser map[string][]string, err error) *int {
|
||||||
|
t.Helper()
|
||||||
|
calls := 0
|
||||||
|
SetRolesLookup(func(guildID, userID string) ([]string, error) {
|
||||||
|
calls++
|
||||||
|
if guildID != betaGuild {
|
||||||
|
t.Errorf("the lookup must ask the beta server, asked %q", guildID)
|
||||||
|
}
|
||||||
|
return rolesByUser[userID], err
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { lookup.Store(nil) })
|
||||||
|
return &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllowedInTheBetaServer(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
guild, role string // configuration
|
guild, role string // configuration
|
||||||
inGuild string
|
inGuild string
|
||||||
member *discordgo.Member
|
member *discordgo.Member
|
||||||
want bool
|
want bool
|
||||||
inBeta, hasRole bool
|
on, hasRole bool
|
||||||
}{
|
}{
|
||||||
{"tester in the beta server", betaGuild, testerRol, betaGuild, member("1", testerRol), true, true, true},
|
{"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 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},
|
{"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 DM has no member", betaGuild, testerRol, "", nil, false, false, false},
|
||||||
{"a nil member in the beta server", betaGuild, testerRol, betaGuild, nil, false, true, 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 role configured: nobody", betaGuild, "", betaGuild, member(testerRol, ""), false, true, false},
|
||||||
@@ -41,7 +60,7 @@ func TestAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
Configure(tc.guild, tc.role)
|
Configure(tc.guild, tc.role, nil)
|
||||||
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
|
if got := Allowed(tc.inGuild, tc.member); got != tc.want {
|
||||||
t.Errorf("Allowed = %v, want %v", got, tc.want)
|
t.Errorf("Allowed = %v, want %v", got, tc.want)
|
||||||
}
|
}
|
||||||
@@ -49,16 +68,113 @@ func TestAllowed(t *testing.T) {
|
|||||||
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
|
t.Errorf("AllowedInteraction = %v, want %v", got, tc.want)
|
||||||
}
|
}
|
||||||
st := StatusOf(tc.inGuild, tc.member)
|
st := StatusOf(tc.inGuild, tc.member)
|
||||||
if st.InBetaGuild != tc.inBeta || st.HasRole != tc.hasRole {
|
if st.ServerEnabled != tc.on || st.HasRole != tc.hasRole {
|
||||||
t.Errorf("StatusOf = %+v, want in beta server %v, has role %v", st, tc.inBeta, tc.hasRole)
|
t.Errorf("StatusOf = %+v, want server on %v, tester %v", st, tc.on, tc.hasRole)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOptedInServers(t *testing.T) {
|
||||||
|
roles := map[string][]string{testerID: {"1", testerRol}}
|
||||||
|
|
||||||
|
t.Run("a tester in an opted-in server is allowed", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
calls := useLookup(t, roles, nil)
|
||||||
|
if !Allowed(otherGuild, member()) { // roles in this server don't matter
|
||||||
|
t.Error("tester in an opted-in server should be allowed")
|
||||||
|
}
|
||||||
|
if *calls != 1 {
|
||||||
|
t.Errorf("the beta server should be asked once, was asked %d times", *calls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the role in the other server doesn't count", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{}, nil)
|
||||||
|
if Allowed(otherGuild, member(testerRol)) {
|
||||||
|
t.Error("holding a role with that ID in another server must not make anyone a tester")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a server that didn't opt in is refused, and nothing is looked up", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
calls := useLookup(t, roles, nil)
|
||||||
|
if Allowed("123000000000000001", member()) {
|
||||||
|
t.Error("a server that didn't opt in must be refused")
|
||||||
|
}
|
||||||
|
if *calls != 0 {
|
||||||
|
t.Error("no lookup for a server where beta is off")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a failing lookup fails closed", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, roles, errors.New("Unknown Member"))
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("an error from Discord must mean no")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no lookup set: testers only qualify in the beta server", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
lookup.Store(nil)
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("without a lookup there is no way to know")
|
||||||
|
}
|
||||||
|
if !Allowed(betaGuild, member(testerRol)) {
|
||||||
|
t.Error("the beta server doesn't need the lookup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a user without an ID can't be looked up", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, roles, nil)
|
||||||
|
if Allowed(otherGuild, &discordgo.Member{}) || Allowed(otherGuild, &discordgo.Member{User: &discordgo.User{}}) {
|
||||||
|
t.Error("no user, no access")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no role configured: nobody, even when the server opted in", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, "", []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{testerID: {""}}, nil)
|
||||||
|
if Allowed(otherGuild, member()) {
|
||||||
|
t.Error("without a role there are no testers")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad IDs in the list are dropped", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild, "not-an-id", "", " 5 "})
|
||||||
|
got := Servers()
|
||||||
|
if len(got) != 2 || got[0] != "5" || got[1] != otherGuild {
|
||||||
|
t.Errorf("Servers = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the beta server isn't listed twice", func(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{betaGuild, otherGuild})
|
||||||
|
if got := Servers(); len(got) != 1 || got[0] != otherGuild {
|
||||||
|
t.Errorf("Servers = %v", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerEnabledNeedsNoPerson(t *testing.T) {
|
||||||
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
for guild, want := range map[string]bool{betaGuild: true, otherGuild: true, "123": false, "": false} {
|
||||||
|
if got := ServerEnabled(guild); got != want {
|
||||||
|
t.Errorf("ServerEnabled(%q) = %v, want %v", guild, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current.Store(nil)
|
||||||
|
if ServerEnabled(betaGuild) {
|
||||||
|
t.Error("before Configure nothing is on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotConfiguredAtAll(t *testing.T) {
|
func TestNotConfiguredAtAll(t *testing.T) {
|
||||||
current.Store(nil)
|
current.Store(nil)
|
||||||
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" {
|
if Allowed(betaGuild, member(testerRol)) || GuildID() != "" || len(Servers()) != 0 {
|
||||||
t.Error("before Configure nothing is allowed and there's no beta server")
|
t.Error("before Configure nothing is allowed and there's no beta server")
|
||||||
}
|
}
|
||||||
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
|
if AllowedInteraction(nil) || AllowedInteraction(&discordgo.InteractionCreate{}) {
|
||||||
@@ -67,7 +183,8 @@ func TestNotConfiguredAtAll(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
||||||
Configure(betaGuild, testerRol)
|
Configure(betaGuild, testerRol, []string{otherGuild})
|
||||||
|
useLookup(t, map[string][]string{}, nil) // nobody holds the role in the beta server
|
||||||
var answered []string
|
var answered []string
|
||||||
orig := respond
|
orig := respond
|
||||||
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
|
respond = func(_ *discordgo.Session, _ *discordgo.InteractionCreate, msg string) {
|
||||||
@@ -85,7 +202,8 @@ func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
|||||||
|
|
||||||
for name, i := range map[string]*discordgo.InteractionCreate{
|
for name, i := range map[string]*discordgo.InteractionCreate{
|
||||||
"no role": interaction(betaGuild, member("1")),
|
"no role": interaction(betaGuild, member("1")),
|
||||||
"wrong server": interaction("999000000000000001", member(testerRol)),
|
"opted-in, not a tester": interaction(otherGuild, member(testerRol)),
|
||||||
|
"server not opted in": interaction("123000000000000001", member(testerRol)),
|
||||||
"DM": interaction("", nil),
|
"DM": interaction("", nil),
|
||||||
} {
|
} {
|
||||||
before := ran
|
before := ran
|
||||||
@@ -94,13 +212,20 @@ func TestGateRunsTheHandlerOnlyForTesters(t *testing.T) {
|
|||||||
t.Errorf("%s: the handler must not run", name)
|
t.Errorf("%s: the handler must not run", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(answered) != 3 || answered[0] != denied {
|
if len(answered) != 4 || answered[0] != denied {
|
||||||
t.Errorf("each refusal is answered with the beta message: %v", answered)
|
t.Errorf("each refusal is answered with the beta message: %v", answered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useLookup(t, map[string][]string{testerID: {testerRol}}, nil)
|
||||||
|
before := ran
|
||||||
|
h(nil, interaction(otherGuild, member()))
|
||||||
|
if ran != before+1 {
|
||||||
|
t.Error("a tester in an opted-in server runs the handler")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGuardOnItsOwn(t *testing.T) {
|
func TestGuardOnItsOwn(t *testing.T) {
|
||||||
Configure(betaGuild, testerRol)
|
Configure(betaGuild, testerRol, nil)
|
||||||
n := 0
|
n := 0
|
||||||
orig := respond
|
orig := respond
|
||||||
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
|
respond = func(*discordgo.Session, *discordgo.InteractionCreate, string) { n++ }
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"velox-bot/internal/beta"
|
||||||
|
"velox-bot/internal/commands"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/discordgo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
appID = "100"
|
||||||
|
betaServer = "1170934850157547600"
|
||||||
|
optedIn = "222"
|
||||||
|
leftServer = "333"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeDiscord answers the application-command calls the beta sync makes, from
|
||||||
|
// memory, and records what it was asked to change.
|
||||||
|
var registerOnce sync.Once
|
||||||
|
|
||||||
|
type fakeDiscord struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cmds map[string][]*discordgo.ApplicationCommand // guild -> commands
|
||||||
|
failures map[string]int // guild -> status for every call
|
||||||
|
nextID int
|
||||||
|
created []string // "guild/name"
|
||||||
|
deleted []string // "guild/name"
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmdPath = regexp.MustCompile(`/applications/` + appID + `/guilds/(\d+)/commands(?:/(\d+))?$`)
|
||||||
|
|
||||||
|
func (f *fakeDiscord) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
reply := func(code int, v any) (*http.Response, error) {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(string(b))), Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil
|
||||||
|
}
|
||||||
|
m := cmdPath.FindStringSubmatch(r.URL.Path)
|
||||||
|
if m == nil {
|
||||||
|
return reply(404, map[string]any{"message": "unknown route"})
|
||||||
|
}
|
||||||
|
guild, id := m[1], m[2]
|
||||||
|
if code := f.failures[guild]; code != 0 {
|
||||||
|
return reply(code, map[string]any{"message": "failure", "code": 0})
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
return reply(200, f.cmds[guild])
|
||||||
|
case http.MethodPost:
|
||||||
|
var c discordgo.ApplicationCommand
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&c)
|
||||||
|
f.nextID++
|
||||||
|
c.ID = strconv.Itoa(1000 + f.nextID)
|
||||||
|
f.cmds[guild] = append(f.cmds[guild], &c)
|
||||||
|
f.created = append(f.created, guild+"/"+c.Name)
|
||||||
|
return reply(200, c)
|
||||||
|
case http.MethodDelete:
|
||||||
|
kept := f.cmds[guild][:0]
|
||||||
|
for _, c := range f.cmds[guild] {
|
||||||
|
if c.ID == id {
|
||||||
|
f.deleted = append(f.deleted, guild+"/"+c.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, c)
|
||||||
|
}
|
||||||
|
f.cmds[guild] = kept
|
||||||
|
return reply(204, nil)
|
||||||
|
}
|
||||||
|
return reply(405, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDiscord) names(guild string) []string {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
var out []string
|
||||||
|
for _, c := range f.cmds[guild] {
|
||||||
|
out = append(out, c.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeStore struct {
|
||||||
|
registered map[string]bool
|
||||||
|
servers []string
|
||||||
|
failWrite bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeStore) GetBetaSettings(context.Context) (string, string, error) {
|
||||||
|
return betaServer, "555", nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) ListBetaGuilds(context.Context) ([]string, error) { return s.servers, nil }
|
||||||
|
func (s *fakeStore) ListBetaRegistered(context.Context) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for id := range s.registered {
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) SetBetaRegistered(_ context.Context, id string, on bool) error {
|
||||||
|
if s.failWrite {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
s.registered[id] = true
|
||||||
|
} else {
|
||||||
|
delete(s.registered, id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestBot(t *testing.T, d *fakeDiscord) *Bot {
|
||||||
|
t.Helper()
|
||||||
|
sess, err := discordgo.New("Bot x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sess.Client = &http.Client{Transport: d}
|
||||||
|
sess.MaxRestRetries = 0
|
||||||
|
return &Bot{
|
||||||
|
Session: sess,
|
||||||
|
AppID: appID,
|
||||||
|
betaRegistered: map[string]bool{},
|
||||||
|
betaKnown: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, v string) bool {
|
||||||
|
for _, x := range list {
|
||||||
|
if x == v {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaCommandsFollowTheServers(t *testing.T) {
|
||||||
|
registerOnce.Do(func() {
|
||||||
|
commands.RegisterBeta(&discordgo.ApplicationCommand{Name: "betatest", Description: "test"}, func(*discordgo.Session, *discordgo.InteractionCreate) {})
|
||||||
|
})
|
||||||
|
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
|
||||||
|
// A server that opted out while the bot was offline still has them,
|
||||||
|
// next to a command that isn't a beta one.
|
||||||
|
leftServer: {{ID: "1", Name: "betatest"}, {ID: "2", Name: "ping"}},
|
||||||
|
// A stale command in the beta server, which the bot cleans up.
|
||||||
|
betaServer: {{ID: "3", Name: "oldthing"}},
|
||||||
|
}, failures: map[string]int{}}
|
||||||
|
store := &fakeStore{registered: map[string]bool{leftServer: true}, servers: []string{optedIn}}
|
||||||
|
b := newTestBot(t, d)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
beta.Configure(betaServer, "555", store.servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
|
||||||
|
if got := d.names(leftServer); len(got) != 1 || got[0] != "ping" {
|
||||||
|
t.Errorf("the beta command is removed from the server that left, nothing else: %v", got)
|
||||||
|
}
|
||||||
|
if got := d.names(optedIn); len(got) != 1 || got[0] != "betatest" {
|
||||||
|
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
|
||||||
|
}
|
||||||
|
got := d.names(betaServer)
|
||||||
|
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") {
|
||||||
|
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got)
|
||||||
|
}
|
||||||
|
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
|
||||||
|
t.Errorf("the database remembers where the commands are now: %v", store.registered)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server opts out.
|
||||||
|
store.servers = nil
|
||||||
|
beta.Configure(betaServer, "555", store.servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if got := d.names(optedIn); len(got) != 0 {
|
||||||
|
t.Errorf("an opted-out server loses them within one round: %v", got)
|
||||||
|
}
|
||||||
|
if store.registered[optedIn] || !store.registered[betaServer] {
|
||||||
|
t.Errorf("only the beta server stays remembered: %v", store.registered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaCleanupIsRetriedAndGivesUpWhenTheBotIsGone(t *testing.T) {
|
||||||
|
d := &fakeDiscord{cmds: map[string][]*discordgo.ApplicationCommand{
|
||||||
|
leftServer: {{ID: "1", Name: "betatest"}},
|
||||||
|
}, failures: map[string]int{leftServer: 500}}
|
||||||
|
store := &fakeStore{registered: map[string]bool{leftServer: true}}
|
||||||
|
b := newTestBot(t, d)
|
||||||
|
ctx := context.Background()
|
||||||
|
beta.Configure(betaServer, "555", nil)
|
||||||
|
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if !store.registered[leftServer] {
|
||||||
|
t.Fatal("a failed cleanup is remembered so it is tried again")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.failures[leftServer] = 0
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if store.registered[leftServer] || len(d.names(leftServer)) != 0 {
|
||||||
|
t.Errorf("the retry cleans up: %v, %v", store.registered, d.names(leftServer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bot was removed from a server: nothing to clean, don't try forever.
|
||||||
|
store.registered["444"] = true
|
||||||
|
b.betaKnown["444"] = true
|
||||||
|
d.failures["444"] = 403
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
|
if store.registered["444"] {
|
||||||
|
t.Error("a server the bot can't reach any more is forgotten")
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
-32
@@ -2,10 +2,14 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"velox-bot/internal/beta"
|
"velox-bot/internal/beta"
|
||||||
"velox-bot/internal/commands"
|
"velox-bot/internal/commands"
|
||||||
|
"velox-bot/internal/commands/betacmd"
|
||||||
"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"
|
||||||
@@ -27,7 +31,10 @@ type Bot struct {
|
|||||||
DBHost string
|
DBHost string
|
||||||
LavalinkHost string
|
LavalinkHost string
|
||||||
LavalinkPass string
|
LavalinkPass string
|
||||||
betaGuild string // where beta commands are registered right now
|
betaRegistered map[string]bool // servers where this run registered the beta commands
|
||||||
|
betaKnown map[string]bool // servers where they were registered, now or in an earlier run
|
||||||
|
betaLoaded bool
|
||||||
|
betaRoles sync.Map // "guild:user" -> cachedRoles
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*discordgo.ApplicationCommand, services *services.Services, musicRepo *musicrepo.Repo, dbHost string) (*Bot, error) {
|
||||||
@@ -50,6 +57,8 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
discordgo.IntentsDirectMessages
|
discordgo.IntentsDirectMessages
|
||||||
|
|
||||||
return &Bot{
|
return &Bot{
|
||||||
|
betaRegistered: map[string]bool{},
|
||||||
|
betaKnown: map[string]bool{},
|
||||||
Session: session,
|
Session: session,
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
GuildID: guildID,
|
GuildID: guildID,
|
||||||
@@ -206,22 +215,37 @@ func (b *Bot) Start() error {
|
|||||||
// made on the dashboard.
|
// made on the dashboard.
|
||||||
const betaPollEvery = 30 * time.Second
|
const betaPollEvery = 30 * time.Second
|
||||||
|
|
||||||
// WatchBeta keeps the bot's idea of who the beta testers are in step with the
|
// BetaStore is where the bot reads the beta settings the dashboard writes,
|
||||||
// settings the owner saves on the dashboard. When the beta server changes, the
|
// and remembers which servers it registered beta commands in.
|
||||||
// beta commands move with it. If the settings can't be read, the last known
|
type BetaStore interface {
|
||||||
// ones stay in force (and before the first read, beta stays locked).
|
GetBetaSettings(ctx context.Context) (guildID, roleID string, err error)
|
||||||
func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID, roleID string, err error)) {
|
ListBetaGuilds(ctx context.Context) ([]string, error)
|
||||||
|
ListBetaRegistered(ctx context.Context) ([]string, error)
|
||||||
|
SetBetaRegistered(ctx context.Context, guildID string, registered bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WatchBeta keeps the bot's idea of who the beta testers are, and which
|
||||||
|
// servers opted in, in step with the dashboard. When the list of servers
|
||||||
|
// changes, the beta commands follow: they appear in servers that opted in and
|
||||||
|
// are removed from servers that opted out, also if that happened while the bot
|
||||||
|
// was offline (it remembers where it registered them). If the settings can't
|
||||||
|
// be read, the last known ones stay in force (and before the first read, beta
|
||||||
|
// stays off).
|
||||||
|
func (b *Bot) WatchBeta(ctx context.Context, store BetaStore) {
|
||||||
|
beta.SetRolesLookup(b.betaMemberRoles)
|
||||||
sync := func() {
|
sync := func() {
|
||||||
guildID, roleID, err := load(ctx)
|
guildID, roleID, err := store.GetBetaSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
|
log.Printf("beta: cannot read settings, keeping the last ones: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
beta.Configure(guildID, roleID)
|
servers, err := store.ListBetaGuilds(ctx)
|
||||||
if now := beta.GuildID(); now != b.betaGuild {
|
if err != nil {
|
||||||
b.registerBetaCommands(b.betaGuild, now)
|
log.Printf("beta: cannot read the opted-in servers, keeping the last ones: %v", err)
|
||||||
b.betaGuild = now
|
return
|
||||||
}
|
}
|
||||||
|
beta.Configure(guildID, roleID, servers)
|
||||||
|
b.syncBetaCommands(ctx, store)
|
||||||
}
|
}
|
||||||
sync()
|
sync()
|
||||||
go func() {
|
go func() {
|
||||||
@@ -238,48 +262,120 @@ func (b *Bot) WatchBeta(ctx context.Context, load func(context.Context) (guildID
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerBetaCommands puts the beta commands in the beta server, and only
|
type cachedRoles struct {
|
||||||
// there, so nobody else sees them. Commands this bot left there that are no
|
roles []string
|
||||||
// longer beta (or no longer exist) are removed. When the beta server is also
|
at time.Time
|
||||||
// the bot's main server, its regular commands are kept. If the beta server
|
|
||||||
// changed, the beta commands are taken out of the old one.
|
|
||||||
func (b *Bot) registerBetaCommands(previous, guildID string) {
|
|
||||||
if previous != "" && previous != guildID {
|
|
||||||
isBeta := map[string]bool{}
|
|
||||||
for _, cmd := range commands.BetaCommands {
|
|
||||||
isBeta[cmd.Name] = true
|
|
||||||
}
|
}
|
||||||
if old, err := b.Session.ApplicationCommands(b.AppID, previous); err != nil {
|
|
||||||
log.Printf("beta: cannot list commands in the previous server: %v", err)
|
// betaRolesTTL is how long a member's roles in the beta server are remembered
|
||||||
} else {
|
// when they had to be fetched from Discord.
|
||||||
for _, cmd := range old {
|
const betaRolesTTL = 30 * time.Second
|
||||||
if isBeta[cmd.Name] {
|
|
||||||
if err := b.Session.ApplicationCommandDelete(b.AppID, previous, cmd.ID); err != nil {
|
// betaMemberRoles answers beta.SetRolesLookup: someone's roles in the beta
|
||||||
log.Printf("beta: cannot remove '%v' from the previous server: %v", cmd.Name, err)
|
// server. The gateway cache is used when it has them (it is kept current by
|
||||||
|
// member updates), otherwise Discord is asked, at most once per
|
||||||
|
// betaRolesTTL per person.
|
||||||
|
func (b *Bot) betaMemberRoles(guildID, userID string) ([]string, error) {
|
||||||
|
if m, err := b.Session.State.Member(guildID, userID); err == nil && m != nil {
|
||||||
|
return m.Roles, nil
|
||||||
|
}
|
||||||
|
key := guildID + ":" + userID
|
||||||
|
if v, ok := b.betaRoles.Load(key); ok {
|
||||||
|
if c := v.(cachedRoles); time.Since(c.at) < betaRolesTTL {
|
||||||
|
return c.roles, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
m, err := b.Session.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
b.betaRoles.Store(key, cachedRoles{roles: m.Roles, at: time.Now()})
|
||||||
|
return m.Roles, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if guildID == "" {
|
// syncBetaCommands makes the beta commands exist in exactly the servers where
|
||||||
|
// beta features are on (the beta server and the ones that opted in), and
|
||||||
|
// nowhere else. The servers it registered them in are remembered in the
|
||||||
|
// database, so a server that opted out while the bot was offline is cleaned up
|
||||||
|
// at the next start. A step that failed is retried on the next round.
|
||||||
|
func (b *Bot) syncBetaCommands(ctx context.Context, store BetaStore) {
|
||||||
|
if !b.betaLoaded {
|
||||||
|
known, err := store.ListBetaRegistered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("beta: cannot read where beta commands were registered: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, id := range known {
|
||||||
|
b.betaKnown[id] = true
|
||||||
|
}
|
||||||
|
b.betaLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
|
want := map[string]bool{}
|
||||||
|
if g := beta.GuildID(); g != "" {
|
||||||
|
want[g] = true
|
||||||
|
}
|
||||||
|
for _, id := range beta.Servers() {
|
||||||
|
want[id] = true
|
||||||
|
}
|
||||||
|
for id := range b.betaKnown {
|
||||||
|
if want[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !b.removeBetaCommands(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := store.SetBetaRegistered(ctx, id, false); err != nil {
|
||||||
|
log.Printf("beta: cannot forget server %s: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
delete(b.betaKnown, id)
|
||||||
|
delete(b.betaRegistered, id)
|
||||||
|
}
|
||||||
|
for id := range want {
|
||||||
|
if b.betaRegistered[id] || !b.addBetaCommands(id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.betaRegistered[id] = true
|
||||||
|
if err := store.SetBetaRegistered(ctx, id, true); err != nil {
|
||||||
|
log.Printf("beta: cannot remember server %s: %v", id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.betaKnown[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addBetaCommands registers the beta commands in a server. In the beta server
|
||||||
|
// it also removes commands this bot left there that no longer exist (when the
|
||||||
|
// beta server is also the bot's main server, its regular commands are kept).
|
||||||
|
// /beta is for the bot's owner and is only registered in the beta server. It
|
||||||
|
// reports whether everything was registered.
|
||||||
|
func (b *Bot) addBetaCommands(guildID string) bool {
|
||||||
|
isBetaServer := guildID == beta.GuildID()
|
||||||
keep := map[string]bool{}
|
keep := map[string]bool{}
|
||||||
for _, cmd := range b.Commands {
|
for _, cmd := range b.Commands {
|
||||||
if b.GuildID == guildID {
|
if b.GuildID == guildID {
|
||||||
keep[cmd.Name] = true
|
keep[cmd.Name] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ok := true
|
||||||
for _, cmd := range commands.BetaCommands {
|
for _, cmd := range commands.BetaCommands {
|
||||||
|
if cmd.Name == betacmd.Beta.Name && !isBetaServer {
|
||||||
|
continue
|
||||||
|
}
|
||||||
keep[cmd.Name] = true
|
keep[cmd.Name] = true
|
||||||
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
|
if _, err := b.Session.ApplicationCommandCreate(b.AppID, guildID, cmd); err != nil {
|
||||||
log.Printf("beta: cannot create '%v' command: %v", cmd.Name, err)
|
log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
|
||||||
|
ok = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !isBetaServer {
|
||||||
|
return ok
|
||||||
|
}
|
||||||
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
|
log.Printf("beta: cannot list commands, leaving stale ones alone: %v", err)
|
||||||
return
|
return ok
|
||||||
}
|
}
|
||||||
for _, cmd := range existing {
|
for _, cmd := range existing {
|
||||||
if keep[cmd.Name] {
|
if keep[cmd.Name] {
|
||||||
@@ -289,6 +385,47 @@ func (b *Bot) registerBetaCommands(previous, guildID string) {
|
|||||||
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
|
log.Printf("beta: cannot remove stale '%v' command: %v", cmd.Name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// goneFromServer reports whether an error from Discord means the bot isn't in
|
||||||
|
// that server any more (or it no longer exists), so there is nothing left to
|
||||||
|
// clean up there.
|
||||||
|
func goneFromServer(err error) bool {
|
||||||
|
var rest *discordgo.RESTError
|
||||||
|
if errors.As(err, &rest) && rest.Response != nil {
|
||||||
|
return rest.Response.StatusCode == http.StatusForbidden || rest.Response.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeBetaCommands takes the beta commands out of a server that is no longer
|
||||||
|
// part of the beta, and leaves everything else there alone. It reports whether
|
||||||
|
// the server is clean (or out of reach for good).
|
||||||
|
func (b *Bot) removeBetaCommands(guildID string) bool {
|
||||||
|
isBeta := map[string]bool{}
|
||||||
|
for _, cmd := range commands.BetaCommands {
|
||||||
|
isBeta[cmd.Name] = true
|
||||||
|
}
|
||||||
|
existing, err := b.Session.ApplicationCommands(b.AppID, guildID)
|
||||||
|
if err != nil {
|
||||||
|
if goneFromServer(err) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Printf("beta: cannot list commands in %s: %v", guildID, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
clean := true
|
||||||
|
for _, cmd := range existing {
|
||||||
|
if !isBeta[cmd.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := b.Session.ApplicationCommandDelete(b.AppID, guildID, cmd.ID); err != nil && !goneFromServer(err) {
|
||||||
|
log.Printf("beta: cannot remove '%v' from %s: %v", cmd.Name, guildID, err)
|
||||||
|
clean = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) Close() error {
|
func (b *Bot) Close() error {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
package betacmd
|
package betacmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
"velox-bot/internal/beta"
|
"velox-bot/internal/beta"
|
||||||
|
|
||||||
@@ -66,12 +67,12 @@ func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
switch {
|
switch {
|
||||||
case !isOwner(s, userID):
|
case !isOwner(s, userID):
|
||||||
msg = "Only the bot's owner can use this command."
|
msg = "Only the bot's owner can use this command."
|
||||||
case !st.InBetaGuild:
|
case !st.ServerEnabled:
|
||||||
msg = "Beta features aren't available here."
|
msg = "Beta features are off in this server."
|
||||||
case !st.RoleConfigured:
|
case !st.RoleConfigured:
|
||||||
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
|
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
|
||||||
case st.HasRole:
|
case st.HasRole:
|
||||||
msg = "You're a beta tester: beta features are unlocked for you in this server."
|
msg = fmt.Sprintf("You're a beta tester. Beta features are on in this server, and in %d other server(s) that opted in.", len(beta.Servers()))
|
||||||
default:
|
default:
|
||||||
msg = "You don't have the Beta Tester role, so beta features are locked for you."
|
msg = "You don't have the Beta Tester role, so beta features are locked for you."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,3 +180,79 @@ func TestGetBetaSettings(t *testing.T) {
|
|||||||
t.Fatalf("role = %q, want %q", role, "42")
|
t.Fatalf("role = %q, want %q", role, "42")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListBetaGuilds(t *testing.T) {
|
||||||
|
r, _ := testRepo(t)
|
||||||
|
db := r.db
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_guilds (
|
||||||
|
guild_id BIGINT PRIMARY KEY, enabled_by BIGINT NOT NULL, enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const g = 8800000000000004242
|
||||||
|
_, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g)
|
||||||
|
t.Cleanup(func() { _, _ = db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, g) })
|
||||||
|
|
||||||
|
has := func() bool {
|
||||||
|
ids, err := r.ListBetaGuilds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == "8800000000000004242" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if has() {
|
||||||
|
t.Fatal("not listed before opting in")
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, 1)`, g); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !has() {
|
||||||
|
t.Fatal("listed after opting in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBetaRegistered(t *testing.T) {
|
||||||
|
r, _ := testRepo(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := r.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS beta_registered (guild_id BIGINT PRIMARY KEY)`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const g = "8800000000000005151"
|
||||||
|
_ = r.SetBetaRegistered(ctx, g, false)
|
||||||
|
t.Cleanup(func() { _ = r.SetBetaRegistered(ctx, g, false) })
|
||||||
|
|
||||||
|
has := func() bool {
|
||||||
|
ids, err := r.ListBetaRegistered(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == g {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if has() {
|
||||||
|
t.Fatal("not remembered at first")
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ { // twice: harmless
|
||||||
|
if err := r.SetBetaRegistered(ctx, g, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !has() {
|
||||||
|
t.Fatal("remembered after registering")
|
||||||
|
}
|
||||||
|
if err := r.SetBetaRegistered(ctx, g, false); err != nil || has() {
|
||||||
|
t.Fatalf("forgotten after removing: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.SetBetaRegistered(ctx, "not-a-number", true); err == nil {
|
||||||
|
t.Error("a bad ID is refused")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -422,3 +422,56 @@ func (r *Repo) GetBetaSettings(ctx context.Context) (guildID, roleID string, err
|
|||||||
}
|
}
|
||||||
return guildID, roleID, nil
|
return guildID, roleID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBetaGuilds returns the servers whose owner opted in to beta features
|
||||||
|
// (set on the dashboard), as ID strings.
|
||||||
|
func (r *Repo) ListBetaGuilds(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_guilds ORDER BY enabled_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBetaRegistered returns the servers where the bot registered beta
|
||||||
|
// commands, so it can clean up after itself even if a server opted out while
|
||||||
|
// it was offline.
|
||||||
|
func (r *Repo) ListBetaRegistered(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.db.QueryContext(ctx, `SELECT guild_id FROM beta_registered`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, strconv.FormatInt(id, 10))
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBetaRegistered records (or forgets) that beta commands exist in a server.
|
||||||
|
func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered bool) error {
|
||||||
|
id, err := strconv.ParseInt(guildID, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if registered {
|
||||||
|
_, err = r.db.ExecContext(ctx, `INSERT INTO beta_registered (guild_id) VALUES ($1) ON CONFLICT DO NOTHING`, id)
|
||||||
|
} else {
|
||||||
|
_, err = r.db.ExecContext(ctx, `DELETE FROM beta_registered WHERE guild_id = $1`, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ func main() {
|
|||||||
defer bot.Close()
|
defer bot.Close()
|
||||||
|
|
||||||
// Who the beta testers are is set on the dashboard; follow it.
|
// Who the beta testers are is set on the dashboard; follow it.
|
||||||
bot.WatchBeta(context.Background(), settingsRepo.GetBetaSettings)
|
bot.WatchBeta(context.Background(), settingsRepo)
|
||||||
|
|
||||||
fmt.Println("Bot is running...")
|
fmt.Println("Bot is running...")
|
||||||
|
|
||||||
|
|||||||
+15
@@ -84,6 +84,21 @@ CREATE TABLE IF NOT EXISTS beta_settings (
|
|||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Servers whose owner opted in to beta features (API migration 000022). The
|
||||||
|
-- beta server itself is always eligible and isn't listed. The bot only reads it.
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_guilds (
|
||||||
|
guild_id BIGINT PRIMARY KEY,
|
||||||
|
enabled_by BIGINT NOT NULL,
|
||||||
|
enabled_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Servers where the bot registered beta commands, so it can remove them even
|
||||||
|
-- if the server opted out while the bot was offline. Bot-owned (the API's
|
||||||
|
-- migration 000022 creates it for a combined database).
|
||||||
|
CREATE TABLE IF NOT EXISTS beta_registered (
|
||||||
|
guild_id BIGINT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
-- A server can have several lobbies (at most 10; the trigger enforces it, and
|
-- 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 same as the API's migration 000020). meeting_settings.lobby_channel_id
|
||||||
-- is the old single-lobby column, no longer read.
|
-- is the old single-lobby column, no longer read.
|
||||||
|
|||||||
Reference in new issue
Block a user