Commands for everything a dashboard can do

- /music skip, pause, resume, stop, repeat, remove, move (DJ role)
- /config view, clear, toggletwitch, streamers, beta-features (server owner)
- /slvl status, remove-reward; /moderation bans, timeouts; /security history
- /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status
- betasettings service and repo methods, with integration tests

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 02:10:05 +01:00
1 parent acdb5ead35
commit 132a2cd986
26 files changed
+1062 -65

No files matched your search

+2 -2
View File
@@ -171,8 +171,8 @@ func TestBetaCommandsFollowTheServers(t *testing.T) {
t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got) t.Errorf("an opted-in server gets the beta commands but not /beta: %v", got)
} }
got := d.names(betaServer) got := d.names(betaServer)
if !contains(got, "beta") || !contains(got, "betatest") || contains(got, "oldthing") { if !contains(got, "betatest") || contains(got, "oldthing") || contains(got, "beta") {
t.Errorf("the beta server gets /beta and the beta commands, and loses stale ones: %v", got) t.Errorf("the beta server gets the beta commands (not /beta, which lives in DMs) and loses stale ones: %v", got)
} }
if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] { if store.registered[leftServer] || !store.registered[optedIn] || !store.registered[betaServer] {
t.Errorf("the database remembers where the commands are now: %v", store.registered) t.Errorf("the database remembers where the commands are now: %v", store.registered)
+7 -4
View File
@@ -9,7 +9,6 @@ import (
"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"
@@ -121,6 +120,13 @@ func (b *Bot) Start() error {
} }
} }
// The owner's commands live in direct messages with the bot, not in a server.
for _, cmd := range commands.OwnerCommands {
if _, err := b.Session.ApplicationCommandCreate(b.AppID, "", cmd); err != nil {
log.Printf("Cannot create '%v' command: %v", cmd.Name, err)
}
}
b.Session.AddHandler(commands.HandleInteraction) b.Session.AddHandler(commands.HandleInteraction)
b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) { b.Session.AddHandler(func(s *discordgo.Session, c *discordgo.ChannelDelete) {
@@ -357,9 +363,6 @@ func (b *Bot) addBetaCommands(guildID string) bool {
} }
ok := 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 in %s: %v", cmd.Name, guildID, err) log.Printf("beta: cannot create '%v' command in %s: %v", cmd.Name, guildID, err)
+189 -36
View File
@@ -1,31 +1,59 @@
// Package betacmd is the /beta command: it shows how beta access is set up. // Package betacmd is /beta, the bot owner's control over the beta program.
// It exists only in the beta server (see commands.BetaCommands), is hidden // It is a command you use in a direct message with the bot, so it exists in no
// from everyone but administrators in Discord's command picker, and only the // server's command menu, and only the owner of the Discord application can run
// bot's owner can actually run it (the owner is looked up from the Discord // it (the owner is looked up from the application, so there is nothing to
// application, so there's nothing to configure). // configure). It chooses the beta server and the Beta Tester role, and
// removes servers from the beta; a server's own owner opts it in with
// /config beta-features. Nothing about the beta needs a dashboard.
package betacmd package betacmd
import ( import (
"context"
"fmt" "fmt"
"log"
"regexp"
"strconv"
"strings"
"sync" "sync"
"time"
"velox-bot/internal/beta" "velox-bot/internal/beta"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
var ( var (
noDM = false ownerIDs sync.Map // user ID -> struct{}, filled once from the application
noPerms = int64(0) // hides the command from everyone but administrators
ownerIDs sync.Map // user ID -> struct{}, filled once from the application
loadOnce sync.Mutex loadOnce sync.Mutex
loaded bool loaded bool
snowflake = regexp.MustCompile(`^[0-9]{1,20}$`)
dmOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextBotDM}
) )
var Beta = &discordgo.ApplicationCommand{ var Beta = &discordgo.ApplicationCommand{
Name: "beta", Name: "beta",
Description: "Show how beta access is set up (bot owner only)", Description: "Manage the beta program (bot owner, in a DM with the bot)",
DMPermission: &noDM, Contexts: dmOnly,
DefaultMemberPermissions: &noPerms, Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show how beta access is set up"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "set", Description: "Choose the beta server and the Beta Tester role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The beta server's ID (the bot must be in it)", Required: true, MaxLength: 20},
{Type: discordgo.ApplicationCommandOptionString, Name: "role-id", Description: "The Beta Tester role's ID in that server", Required: true, MaxLength: 20},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "off", Description: "Turn beta features off for everyone (clears the role)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "servers", Description: "List the servers that opted in to beta features"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-server", Description: "Take a server out of the beta",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "server-id", Description: "The server's ID", Required: true, MaxLength: 20},
},
},
},
} }
// isOwner reports whether the user owns the bot's Discord application (or is // isOwner reports whether the user owns the bot's Discord application (or is
@@ -57,31 +85,156 @@ func isOwner(s *discordgo.Session, userID string) bool {
return ok return ok
} }
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) { func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
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.ServerEnabled:
msg = "Beta features are off in this server."
case !st.RoleConfigured:
msg = "No Beta Tester role is set up, so beta features are locked for everyone."
case st.HasRole:
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:
msg = "You don't have the Beta Tester role, so beta features are locked for you."
}
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{ Data: &discordgo.InteractionResponseData{Content: msg, AllowedMentions: &discordgo.MessageAllowedMentions{}},
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
AllowedMentions: &discordgo.MessageAllowedMentions{},
},
}) })
} }
func guildName(s *discordgo.Session, id string) string {
if g, err := s.State.Guild(id); err == nil && g != nil && g.Name != "" {
return g.Name
}
if g, err := s.Guild(id); err == nil && g != nil {
return g.Name
}
return "unknown server"
}
func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) {
userID := ""
switch {
case i.User != nil:
userID = i.User.ID
case i.Member != nil && i.Member.User != nil:
userID = i.Member.User.ID
}
if !isOwner(s, userID) {
reply(s, i, "Only the bot's owner can use this command.")
return
}
if i.GuildID != "" {
reply(s, i, "Use this command in a direct message with me, not in a server.")
return
}
svc := services.Global
if svc == nil || svc.BetaSettings == nil {
reply(s, i, "Beta settings aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fail := func(err error) {
reply(s, i, "Something went wrong. Try again in a moment.")
log.Printf("beta %s: %v", sub.Name, err)
}
switch sub.Name {
case "status":
guildID, roleID, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
opted, _ := svc.BetaSettings.OptedIn(ctx)
var b strings.Builder
switch {
case guildID == "":
b.WriteString("No beta server is set. Choose one with `/beta set`.\n")
case roleID == "":
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nNo Beta Tester role is set, so beta features are locked for everyone. Set one with `/beta set`.\n", guildName(s, guildID), guildID)
default:
fmt.Fprintf(&b, "Beta server: **%s** (`%s`).\nBeta Tester role: `%s`. Members of the beta server who have it are testers.\n", guildName(s, guildID), guildID, roleID)
}
fmt.Fprintf(&b, "%d other server(s) opted in. The bot is using: server `%s`, %d opted-in server(s).", len(opted), beta.GuildID(), len(beta.Servers()))
reply(s, i, b.String())
case "set":
guildArg, roleArg := strings.TrimSpace(args["server-id"].StringValue()), strings.TrimSpace(args["role-id"].StringValue())
if !snowflake.MatchString(guildArg) || !snowflake.MatchString(roleArg) {
reply(s, i, "Both IDs must be numbers. In Discord, turn on Developer Mode, then right-click the server or role and choose Copy ID.")
return
}
roles, err := s.GuildRoles(guildArg)
if err != nil {
reply(s, i, "I couldn't read that server. Check the ID, and that I'm in it.")
return
}
found := false
for _, r := range roles {
if r.ID == roleArg && r.ID != guildArg && !r.Managed {
found = true
}
}
if !found {
reply(s, i, "That role isn't a normal role of that server.")
return
}
g, _ := strconv.ParseInt(guildArg, 10, 64)
r, _ := strconv.ParseInt(roleArg, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, r, by); err != nil {
fail(err)
return
}
reply(s, i, fmt.Sprintf("Saved. **%s** is the beta server, and members with the role `%s` are beta testers. The bot picks this up within 30 seconds.", guildName(s, guildArg), roleArg))
case "off":
guildID, _, err := svc.BetaSettings.Get(ctx)
if err != nil {
fail(err)
return
}
if guildID == "" {
reply(s, i, "No beta server is set, so beta features are already off.")
return
}
g, _ := strconv.ParseInt(guildID, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetServer(ctx, g, 0, by); err != nil {
fail(err)
return
}
reply(s, i, "Beta features are off for everyone. Choose a role again with `/beta set` to turn them back on.")
case "servers":
opted, err := svc.BetaSettings.OptedIn(ctx)
if err != nil {
fail(err)
return
}
if len(opted) == 0 {
reply(s, i, "No other server has opted in. A server's owner does that with `/config beta-features`.")
return
}
var b strings.Builder
for _, id := range opted {
fmt.Fprintf(&b, "• %s (`%s`)\n", guildName(s, id), id)
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
reply(s, i, "**Servers in the beta**\n"+out)
case "remove-server":
id := strings.TrimSpace(args["server-id"].StringValue())
if !snowflake.MatchString(id) {
reply(s, i, "The ID must be a number.")
return
}
g, _ := strconv.ParseInt(id, 10, 64)
by, _ := strconv.ParseInt(userID, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, false); err != nil {
fail(err)
return
}
reply(s, i, "Removed. Its beta commands disappear within a minute. Only that server's owner can put it back.")
}
}
+6 -2
View File
@@ -86,10 +86,14 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
// wrapped so it runs only for members with the Beta Tester role (see the beta // 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 // package). To put a feature in beta, add it with RegisterBeta instead of to
// AllCommands and handlers; to release it, move it there. // AllCommands and handlers; to release it, move it there.
var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta} var BetaCommands = []*discordgo.ApplicationCommand{}
// OwnerCommands are registered globally but can only be used in a direct
// message with the bot, so they are in no server's menu. /beta is the bot
// owner's control over the beta program (see betacmd).
var OwnerCommands = []*discordgo.ApplicationCommand{betacmd.Beta}
func init() { func init() {
// /beta itself is open to the whole beta server (see betacmd).
handlers[betacmd.Beta.Name] = betacmd.Handler handlers[betacmd.Beta.Name] = betacmd.Handler
} }
+6 -2
View File
@@ -14,7 +14,7 @@ import (
var Config = &discordgo.ApplicationCommand{ var Config = &discordgo.ApplicationCommand{
Name: "config", Name: "config",
Description: "Server configuration", Description: "Server configuration",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "addstreamer", Name: "addstreamer",
@@ -145,7 +145,7 @@ var Config = &discordgo.ApplicationCommand{
Name: "disablelogging", Name: "disablelogging",
Description: "Disable server logging events", Description: "Disable server logging events",
}, },
}, }, extraOptions()...),
} }
func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -168,6 +168,10 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
return return
} }
if handleExtra(s, i, data.Options[0]) {
return
}
switch data.Options[0].Name { switch data.Options[0].Name {
case "addstreamer": case "addstreamer":
log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
+214
View File
@@ -0,0 +1,214 @@
package public
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Everything else /config needs so that no setting depends on a dashboard:
// seeing the settings, clearing one, switching Twitch alerts on and off,
// listing streamers, and the server owner's beta features switch.
var clearChoices = []string{"welcome-channel", "welcome-message", "welcome-dm", "welcome-gif", "default-role", "log-channel"}
func extraOptions() []*discordgo.ApplicationCommandOption {
clear := &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString, Name: "setting", Description: "What to clear", Required: true,
}
for _, c := range clearChoices {
clear.Choices = append(clear.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "view", Description: "Show the welcome, role, logging and Twitch settings"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "clear", Description: "Clear a welcome, role or log setting", Options: []*discordgo.ApplicationCommandOption{clear}},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggletwitch", Description: "Turn Twitch live notifications on or off",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off", Required: true}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "streamers", Description: "List the Twitch streamers being watched"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "beta-features",
Description: "Let testers use beta features here. Only the server owner can change it",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionBoolean, Name: "enabled", Description: "On or off. Leave out to see the state"}},
},
}
}
func guildInt(i *discordgo.InteractionCreate) int64 {
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
return n
}
func ownerOf(s *discordgo.Session, guildID string) (string, error) {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID, nil
}
g, err := s.Guild(guildID)
if err != nil {
return "", err
}
return g.OwnerID, nil
}
// handleExtra runs the subcommands above. It reports whether it handled name.
func handleExtra(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) bool {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
g := guildInt(i)
svc := services.Global
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
fail := func(err error) {
respondEphemeral(s, i, "Something went wrong. Try again in a moment.")
log.Printf("config %s: %v", sub.Name, err)
}
switch sub.Name {
case "view":
var b strings.Builder
if w, err := svc.Welcome.GetSettings(ctx, g); err == nil {
fmt.Fprintf(&b, "**Welcome**\nChannel: %s\nMessage: %s\nDM: %s\nGIF: %s\n", mention("#", w.ChannelID), textOr(w.WelcomeMessage), textOr(w.WelcomeDM), textOr(w.WelcomeGIFURL))
}
if r, err := svc.DefaultRole.GetRole(ctx, g); err == nil {
fmt.Fprintf(&b, "**Default role:** %s\n", mention("@&", r))
}
if l, err := svc.LogSettings.GetSettings(ctx, g); err == nil && l != nil {
state := "off"
if l.IsEnabled {
state = "on"
}
fmt.Fprintf(&b, "**Logging:** %s, channel %s\n", state, mention("#", l.ChannelID))
} else {
b.WriteString("**Logging:** off\n")
}
if on, err := svc.Twitch.IsEnabled(ctx, g); err == nil {
ch, _, _ := svc.Twitch.GetNotificationChannel(ctx, g)
users, _ := svc.Twitch.ListUsersForGuild(ctx, g)
state := "off"
if on {
state = "on"
}
fmt.Fprintf(&b, "**Twitch alerts:** %s, channel %s, %d streamer(s)\n", state, mention("#", ch), len(users))
}
respondEphemeral(s, i, clipConfig(b.String(), 1900))
case "clear":
var err error
setting := args["setting"].StringValue()
switch setting {
case "welcome-channel":
err = svc.Welcome.SetChannel(ctx, g, 0)
case "welcome-message":
err = svc.Welcome.SetMessage(ctx, g, "")
case "welcome-dm":
err = svc.Welcome.SetDM(ctx, g, "")
case "welcome-gif":
err = svc.Welcome.SetGIF(ctx, g, "")
case "default-role":
err = svc.DefaultRole.SetRole(ctx, g, 0)
case "log-channel":
err = svc.LogSettings.SetChannel(ctx, g, 0)
default:
respondEphemeral(s, i, "Unknown setting.")
return true
}
if err != nil {
fail(err)
return true
}
respondEphemeral(s, i, "Cleared **"+setting+"**.")
case "toggletwitch":
on := args["enabled"].BoolValue()
if err := svc.Twitch.SetEnabled(ctx, g, on); err != nil {
fail(err)
return true
}
if on {
if ch, ok, _ := svc.Twitch.GetNotificationChannel(ctx, g); !ok || ch == 0 {
respondEphemeral(s, i, "Twitch alerts are **on**, but there is no channel yet. Set one with `/config settwitchnotificationchannel`.")
return true
}
respondEphemeral(s, i, "Twitch alerts are **on**.")
} else {
respondEphemeral(s, i, "Twitch alerts are **off**.")
}
case "streamers":
users, err := svc.Twitch.ListUsersForGuild(ctx, g)
if err != nil {
fail(err)
return true
}
if len(users) == 0 {
respondEphemeral(s, i, "No streamers yet. Add one with `/config addstreamer`.")
return true
}
respondEphemeral(s, i, clipConfig("Watching: "+strings.Join(users, ", "), 1900))
case "beta-features":
on, err := svc.BetaSettings.IsOptedIn(ctx, g)
if err != nil {
fail(err)
return true
}
state := "off"
if on {
state = "on"
}
want, ok := args["enabled"]
if !ok {
respondEphemeral(s, i, "Beta features are **"+state+"** in this server. Testers can use them here when this is on. Only the server owner can change it.")
return true
}
owner, err := ownerOf(s, i.GuildID)
if err != nil {
fail(err)
return true
}
if i.Member == nil || i.Member.User == nil || i.Member.User.ID != owner {
respondEphemeral(s, i, "Only the server owner can change this. Beta features can act on the whole server.")
return true
}
by, _ := strconv.ParseInt(owner, 10, 64)
if err := svc.BetaSettings.SetOptedIn(ctx, g, by, want.BoolValue()); err != nil {
fail(err)
return true
}
if want.BoolValue() {
respondEphemeral(s, i, "Beta features are **on**. Beta commands appear here within a minute, for testers only.")
} else {
respondEphemeral(s, i, "Beta features are **off**. Beta commands disappear from this server within a minute.")
}
default:
return false
}
return true
}
func mention(prefix string, id int64) string {
if id == 0 {
return "not set"
}
return fmt.Sprintf("<%s%d>", prefix, id)
}
func textOr(v string) string {
if strings.TrimSpace(v) == "" {
return "not set"
}
return "`" + clipConfig(v, 120) + "`"
}
func clipConfig(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
@@ -15,6 +15,8 @@ var Slvl = &discordgo.ApplicationCommand{
slvlSetChannelOption(), slvlSetChannelOption(),
slvlSetMessageOption(), slvlSetMessageOption(),
slvlSetRewardOption(), slvlSetRewardOption(),
slvlRemoveRewardOption(),
slvlStatusOption(),
}, },
} }
@@ -34,6 +36,10 @@ func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
slvlSetMessageHandler(s, i) slvlSetMessageHandler(s, i)
case "set-reward": case "set-reward":
slvlSetRewardHandler(s, i) slvlSetRewardHandler(s, i)
case "remove-reward":
slvlRemoveRewardHandler(s, i)
case "status":
slvlStatusHandler(s, i)
default: default:
shared.RespondEphemeral(s, i, "Invalid subcommand.") shared.RespondEphemeral(s, i, "Invalid subcommand.")
} }
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"context"
"fmt"
"strconv"
"strings"
"velox-bot/internal/commands/level/shared"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// Looking at the leveling settings and removing a role reward, so nothing
// about leveling needs a dashboard.
func slvlRemoveRewardOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove-reward", Description: "Remove the role reward of a level",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionInteger, Name: "level", Description: "The level", Required: true},
},
}
}
func slvlStatusOption() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show the leveling settings",
}
}
func slvlRemoveRewardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSystemEnabled(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
level := int(shared.SubcommandOptionMap(i)["level"].IntValue())
removed, err := services.Global.LevelSettings.DeleteRoleRewardForLevel(context.Background(), guildID, level)
switch {
case err != nil:
shared.RespondEphemeral(s, i, "Failed to remove the reward.")
case !removed:
shared.RespondEphemeral(s, i, "Level "+strconv.Itoa(level)+" has no role reward.")
default:
shared.RespondEphemeral(s, i, "Removed the reward for level "+strconv.Itoa(level)+".")
}
}
func slvlStatusHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !shared.RequireManageGuild(s, i) || !shared.RequireLevelSettingsService(s, i) {
return
}
guildID, ok := shared.ParseGuildID(s, i)
if !ok {
return
}
ctx := context.Background()
ls := services.Global.LevelSettings
enabled, err := ls.IsLevelSystemEnabled(ctx, guildID)
if err != nil {
shared.RespondEphemeral(s, i, "Failed to load the settings.")
return
}
state := "off"
if enabled {
state = "on"
}
var b strings.Builder
fmt.Fprintf(&b, "**Leveling:** %s\n", state)
if ch, ok, _ := ls.GetLevelUpChannelID(ctx, guildID); ok && ch != 0 {
fmt.Fprintf(&b, "**Level-up channel:** <#%d>\n", ch)
} else {
b.WriteString("**Level-up channel:** the channel of the message\n")
}
if msg, err := ls.GetLevelUpMessage(ctx, guildID); err == nil {
fmt.Fprintf(&b, "**Level-up message:** %s\n", msg)
}
rewards, _ := ls.ListRoleRewards(ctx, guildID, 25)
if len(rewards) == 0 {
b.WriteString("**Role rewards:** none")
} else {
b.WriteString("**Role rewards:** ")
for n, r := range rewards {
if n > 0 {
b.WriteString(", ")
}
fmt.Fprintf(&b, "level %d → <@&%d>", r.Level, r.RoleID)
}
}
out := b.String()
if r := []rune(out); len(r) > 1900 {
out = string(r[:1899]) + "…"
}
shared.RespondEphemeral(s, i, out)
}
+1 -1
View File
@@ -76,7 +76,7 @@ func lower(s string) string {
} }
func TestCommandsStayWithinDiscordLimits(t *testing.T) { func TestCommandsStayWithinDiscordLimits(t *testing.T) {
all := append(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...) all := append(append(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...), OwnerCommands...)
names := map[string]bool{} names := map[string]bool{}
for _, c := range all { for _, c := range all {
if names[c.Name] { if names[c.Name] {
@@ -0,0 +1,106 @@
package public
import (
"context"
"fmt"
"strings"
"time"
"github.com/bwmarrin/discordgo"
)
// Looking at who is banned and who is timed out, so unbanning and ending a
// timeout never needs a dashboard to find the person first.
func listOptions() []*discordgo.ApplicationCommandOption {
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "bans", Description: "List banned users, to use with /moderation unban"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "timeouts", Description: "List members who are timed out right now"},
}
}
const listShown = 20
func handleBans(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionBanMembers) {
respondEphemeral(s, i, "You need **Ban Members** for this command.")
return
}
bans, err := s.GuildBans(i.GuildID, 1000, "", "")
if err != nil {
respondEphemeral(s, i, "I couldn't read the ban list. I need the **Ban Members** permission.")
return
}
if len(bans) == 0 {
respondEphemeral(s, i, "Nobody is banned.")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Banned users (%d)**\n", len(bans))
for n, ban := range bans {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(bans)-listShown)
break
}
name := "unknown"
id := ""
if ban.User != nil {
name, id = ban.User.Username, ban.User.ID
}
reason := strings.TrimSpace(ban.Reason)
if reason == "" {
reason = "no reason"
}
fmt.Fprintf(&b, "• %s (`%s`): %s\n", escape(name), id, escape(clip(reason, 80)))
}
b.WriteString("Unban with `/moderation unban user_id:<id>`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func handleTimeouts(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !hasPerm(i, discordgo.PermissionModerateMembers) {
respondEphemeral(s, i, "You need **Moderate Members** for this command.")
return
}
_, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
members, err := s.GuildMembers(i.GuildID, "", 1000)
if err != nil {
respondEphemeral(s, i, "I couldn't read the member list.")
return
}
now := time.Now()
var out []*discordgo.Member
for _, m := range members {
if m.CommunicationDisabledUntil != nil && m.CommunicationDisabledUntil.After(now) {
out = append(out, m)
}
}
if len(out) == 0 {
respondEphemeral(s, i, "Nobody is timed out (among the first 1000 members).")
return
}
var b strings.Builder
fmt.Fprintf(&b, "**Timed out (%d)**\n", len(out))
for n, m := range out {
if n == listShown {
fmt.Fprintf(&b, "…and %d more.\n", len(out)-listShown)
break
}
fmt.Fprintf(&b, "• <@%s> until <t:%d:f>\n", m.User.ID, m.CommunicationDisabledUntil.Unix())
}
b.WriteString("End one with `/moderation untimeout`.")
respondEphemeral(s, i, clip(b.String(), 1950))
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
// escape keeps names and reasons from becoming markdown or mentions.
func escape(s string) string {
return strings.NewReplacer("`", "'", "*", "", "_", " ", "~", "", "@", "@​", "|", "/").Replace(s)
}
@@ -14,7 +14,7 @@ import (
var Moderation = &discordgo.ApplicationCommand{ var Moderation = &discordgo.ApplicationCommand{
Name: "moderation", Name: "moderation",
Description: "High-value moderation tools", Description: "High-value moderation tools",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "purge", Name: "purge",
@@ -155,7 +155,7 @@ var Moderation = &discordgo.ApplicationCommand{
}, },
}, },
}, },
}, }, listOptions()...),
} }
func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -185,6 +185,10 @@ func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleUntimeout(s, i) handleUntimeout(s, i)
case "slowmode": case "slowmode":
handleSlowmode(s, i) handleSlowmode(s, i)
case "bans":
handleBans(s, i)
case "timeouts":
handleTimeouts(s, i)
default: default:
respondEphemeral(s, i, "Unknown subcommand.") respondEphemeral(s, i, "Unknown subcommand.")
} }
+126
View File
@@ -0,0 +1,126 @@
package public
import (
"fmt"
"strings"
"velox-bot/internal/commands/music/shared"
"velox-bot/internal/music"
"github.com/bwmarrin/discordgo"
)
// The player controls. Everything a dashboard can do to the queue (skip, pause,
// resume, stop, repeat, remove and move a track) is also a command, so a
// server never needs one. They need the DJ role, like /play, /queue and
// /volume. Turning music on or off needs Manage Server.
func positionOption(name, desc string) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionInteger, Name: name, Description: desc,
Required: true, MinValue: ptrFloat(1), MaxValue: 1000,
}
}
func playerOptions() []*discordgo.ApplicationCommandOption {
sub := func(name, desc string, o ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: name, Description: desc, Options: o}
}
return []*discordgo.ApplicationCommandOption{
sub("skip", "Skip the current song (DJ)"),
sub("pause", "Pause playback (DJ)"),
sub("resume", "Resume playback (DJ)"),
sub("stop", "Stop playback and clear the queue (DJ)"),
sub("repeat", "Repeat the current song, the whole queue, or nothing (DJ)",
&discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionString, Name: "mode", Description: "What repeats", Required: true,
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "off", Value: "off"}, {Name: "song", Value: "song"}, {Name: "queue", Value: "queue"}},
}),
sub("remove", "Remove a song from the queue (DJ)", positionOption("position", "Its number in /queue")),
sub("move", "Move a song to another place in the queue (DJ)",
positionOption("from", "Its number in /queue"), positionOption("to", "The number it should have")),
}
}
// queueEntry returns the queued song at position n (1 is the first one after
// the song that is playing), as /queue numbers them.
func queueEntry(guildID string, n int) (*music.TrackEntry, int) {
_, rest := music.GetQueue(guildID)
if n < 1 || n > len(rest) {
return nil, len(rest)
}
e := rest[n-1]
return &e, len(rest)
}
func title(e *music.TrackEntry) string {
return strings.TrimSpace(e.Track.Info.Title)
}
// handlePlayer runs a player subcommand. The caller has checked that music is
// on and that the member has the DJ role.
func handlePlayer(s *discordgo.Session, i *discordgo.InteractionCreate, sub *discordgo.ApplicationCommandInteractionDataOption) {
g := i.GuildID
args := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, o := range sub.Options {
args[o.Name] = o
}
current, _ := music.GetQueue(g)
needsPlayer := sub.Name != "repeat" && sub.Name != "remove" && sub.Name != "move"
if needsPlayer && current == nil {
shared.RespondEphemeral(s, i, "Nothing is playing.")
return
}
var err error
var msg string
switch sub.Name {
case "skip":
err, msg = music.Skip(g), "Skipped."
case "pause":
err, msg = music.Pause(g, true), "Paused. `/music resume` continues."
case "resume":
err, msg = music.Pause(g, false), "Resumed."
case "stop":
err, msg = music.Stop(g), "Stopped, and the queue is cleared."
case "repeat":
switch args["mode"].StringValue() {
case "song":
music.SetRepeat(g, true, false)
msg = "The current song repeats."
case "queue":
music.SetRepeat(g, false, true)
msg = "The whole queue repeats."
default:
music.SetRepeat(g, false, false)
msg = "Nothing repeats."
}
case "remove":
n := int(args["position"].IntValue())
e, size := queueEntry(g, n)
if e == nil {
shared.RespondEphemeral(s, i, fmt.Sprintf("There is no song number %d in the queue (it has %d).", n, size))
return
}
err, msg = music.RemoveFromQueue(g, e.QueueID), fmt.Sprintf("Removed **%s**.", title(e))
case "move":
from, to := int(args["from"].IntValue()), int(args["to"].IntValue())
e, size := queueEntry(g, from)
if e == nil {
shared.RespondEphemeral(s, i, fmt.Sprintf("There is no song number %d in the queue (it has %d).", from, size))
return
}
if to > size {
to = size
}
err, msg = music.Reorder(g, e.QueueID, to), fmt.Sprintf("Moved **%s** to number %d.", title(e), to)
default:
shared.RespondEphemeral(s, i, "Unknown subcommand.")
return
}
if err != nil {
shared.RespondEphemeral(s, i, "That didn't work: "+err.Error())
return
}
shared.RespondEphemeral(s, i, msg)
}
+21 -9
View File
@@ -10,14 +10,14 @@ import (
var Music = &discordgo.ApplicationCommand{ var Music = &discordgo.ApplicationCommand{
Name: "music", Name: "music",
Description: "Music settings", Description: "Music settings and player controls",
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{ {
Type: discordgo.ApplicationCommandOptionSubCommand, Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "toggle", Name: "toggle",
Description: "Turn the music player on or off for this server", Description: "Turn the music player on or off for this server (Manage Server)",
}, },
}, }, playerOptions()...),
} }
func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -25,9 +25,6 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "This command can only be used in a server.") shared.RespondEphemeral(s, i, "This command can only be used in a server.")
return return
} }
if !shared.RequireManageGuild(s, i) {
return
}
if !shared.RequireMusicService(s, i) { if !shared.RequireMusicService(s, i) {
return return
} }
@@ -37,11 +34,26 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
shared.RespondEphemeral(s, i, "Missing subcommand.") shared.RespondEphemeral(s, i, "Missing subcommand.")
return return
} }
sub := data.Options[0]
switch data.Options[0].Name { if sub.Name == "toggle" {
case "toggle": if !shared.RequireManageGuild(s, i) {
return
}
handleToggle(s, i) handleToggle(s, i)
return
} }
// Every other subcommand controls the player: music has to be on and the
// member needs the DJ role, like /play, /queue and /volume.
if !shared.RequireMusicEnabled(s, i) {
return
}
if !memberHasDJRole(s, i.GuildID, i.Member) {
shared.RespondEphemeral(s, i, "You need the **DJ** role to use music commands.")
return
}
handlePlayer(s, i, sub)
} }
func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) { func handleToggle(s *discordgo.Session, i *discordgo.InteractionCreate) {
+30
View File
@@ -70,6 +70,7 @@ func tuneOptions() []*discordgo.ApplicationCommandOption {
} }
return []*discordgo.ApplicationCommandOption{ return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings", Description: "Show every security setting"}, {Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings", Description: "Show every security setting"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "history", Description: "Show the latest security events (raids, quarantines, setting changes)"},
group("joingate", "Tune the Join Gate (owner or trusted)", group("joingate", "Tune the Join Gate (owner or trusted)",
sub("check", "Choose what happens when a check catches a new member", sub("check", "Choose what happens when a check catches a new member",
choiceOption("check", "Which check", true, joinGateChecks...), choiceOption("check", "Which check", true, joinGateChecks...),
@@ -145,6 +146,35 @@ func handleTune(ctx context.Context, s *discordgo.Session, i *discordgo.Interact
return true return true
} }
reply(s, i, describeSettings(st)) reply(s, i, describeSettings(st))
case "history":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security history.")
return true
}
events, err := e.Events(ctx, i.GuildID, 15)
if err != nil {
reply(s, i, message("history", err))
return true
}
if len(events) == 0 {
reply(s, i, "Nothing has happened yet.")
return true
}
var b strings.Builder
for _, ev := range events {
fmt.Fprintf(&b, "<t:%d:R> **%s**", ev.CreatedAt.Unix(), ev.Kind)
if ev.ActorID != 0 {
fmt.Fprintf(&b, " by <@%d>", ev.ActorID)
}
if ev.TargetID != 0 {
fmt.Fprintf(&b, " on <@%d>", ev.TargetID)
}
if ev.Detail != "" {
fmt.Fprintf(&b, ": %s", clip(ev.Detail, 120))
}
b.WriteString("\n")
}
reply(s, i, clip(b.String(), 1950))
case "joingate": case "joingate":
if len(subcmd.Options) == 0 { if len(subcmd.Options) == 0 {
return true return true
+29 -5
View File
@@ -509,11 +509,12 @@ func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) err
// ---- Events ---- // ---- Events ----
type Event struct { type Event struct {
GuildID int64 GuildID int64
Kind string Kind string
ActorID int64 ActorID int64
TargetID int64 TargetID int64
Detail string Detail string
CreatedAt time.Time // set when read
} }
// Event kinds. // Event kinds.
@@ -716,3 +717,26 @@ func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) (
} }
return out, rows.Err() return out, rows.Err()
} }
// ListEvents returns a server's most recent security events, newest first.
func (r *Repo) ListEvents(ctx context.Context, guildID int64, limit int) ([]Event, error) {
if limit < 1 || limit > 100 {
limit = 20
}
rows, err := r.db.QueryContext(ctx, `
SELECT kind, COALESCE(actor_id, 0), COALESCE(target_id, 0), detail, created_at
FROM security_events WHERE guild_id = $1 ORDER BY id DESC LIMIT $2`, guildID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Event
for rows.Next() {
e := Event{GuildID: guildID}
if err := rows.Scan(&e.Kind, &e.ActorID, &e.TargetID, &e.Detail, &e.CreatedAt); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
@@ -229,3 +229,27 @@ func TestRaidsSnapshotsAndHeatSettings(t *testing.T) {
t.Error("with a zero max age every anti-nuke guild is due") t.Error("with a zero max age every anti-nuke guild is due")
} }
} }
func TestListEvents(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
for _, kind := range []string{"settings", "raid_started", "quarantine"} {
if err := r.AddEvent(ctx, Event{GuildID: g, Kind: kind, ActorID: 5, Detail: "d"}); err != nil {
t.Fatal(err)
}
}
if err := r.AddEvent(ctx, Event{GuildID: g + 1, Kind: "other server"}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.Exec(`DELETE FROM security_events WHERE guild_id = $1`, g+1) })
got, err := r.ListEvents(ctx, g, 2)
if err != nil || len(got) != 2 || got[0].Kind != "quarantine" || got[1].Kind != "raid_started" {
t.Fatalf("newest first, limited: %+v %v", got, err)
}
if got[0].CreatedAt.IsZero() || got[0].ActorID != 5 || got[0].TargetID != 0 {
t.Errorf("fields: %+v", got[0])
}
if all, _ := r.ListEvents(ctx, g, 0); len(all) != 3 {
t.Errorf("only this server's events: %v", all)
}
}
@@ -256,3 +256,66 @@ func TestBetaRegistered(t *testing.T) {
t.Error("a bad ID is refused") t.Error("a bad ID is refused")
} }
} }
func TestBetaSettingsFromCommands(t *testing.T) {
r, _ := testRepo(t)
ctx := context.Background()
var wasG, wasR sql.NullInt64
hadRow := r.db.QueryRowContext(ctx, `SELECT guild_id, role_id FROM beta_settings WHERE id`).Scan(&wasG, &wasR) == nil
t.Cleanup(func() {
if hadRow {
_, _ = r.db.ExecContext(ctx, `UPDATE beta_settings SET guild_id=$1, role_id=$2 WHERE id`, wasG, wasR)
} else {
_, _ = r.db.ExecContext(ctx, `DELETE FROM beta_settings`)
}
_ = r.SetBetaGuild(ctx, 8800000000000007171, 1, false)
})
if err := r.SetBetaServer(ctx, 700000000000000001, 800000000000000002, 5); err != nil {
t.Fatal(err)
}
g, role, err := r.GetBetaSettings(ctx)
if err != nil || g != "700000000000000001" || role != "800000000000000002" {
t.Fatalf("settings: %q %q %v", g, role, err)
}
if err := r.SetBetaServer(ctx, 700000000000000001, 0, 5); err != nil {
t.Fatal(err)
}
if g, role, _ = r.GetBetaSettings(ctx); g != "700000000000000001" || role != "" {
t.Errorf("a zero role clears the role and keeps the server: %q %q", g, role)
}
const guild = 8800000000000007171
for i := 0; i < 2; i++ {
if err := r.SetBetaGuild(ctx, guild, 9, true); err != nil {
t.Fatal(err)
}
}
if on, _ := r.IsBetaGuild(ctx, guild); !on {
t.Error("opted in")
}
if err := r.SetBetaGuild(ctx, guild, 9, false); err != nil {
t.Fatal(err)
}
if on, _ := r.IsBetaGuild(ctx, guild); on {
t.Error("opted out")
}
}
func TestDeleteRoleReward(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if err := r.SetRoleRewardForLevel(ctx, g, 5, 77); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _, _ = r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1`, g) })
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 6); err != nil || removed {
t.Errorf("nothing at level 6: %v %v", removed, err)
}
if removed, err := r.DeleteRoleRewardForLevel(ctx, g, 5); err != nil || !removed {
t.Errorf("remove level 5: %v %v", removed, err)
}
if _, ok, _ := r.GetRoleRewardForLevel(ctx, g, 5); ok {
t.Error("the reward is gone")
}
}
+40
View File
@@ -475,3 +475,43 @@ func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered
} }
return err return err
} }
// SetBetaServer saves the beta server and the Beta Tester role (0 clears the
// role, which turns beta features off for everyone).
func (r *Repo) SetBetaServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
const q = `
INSERT INTO beta_settings (id, guild_id, role_id, updated_by, updated_at)
VALUES (TRUE, $1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE SET guild_id = EXCLUDED.guild_id, role_id = EXCLUDED.role_id,
updated_by = EXCLUDED.updated_by, updated_at = NOW()`
_, err := r.db.ExecContext(ctx, q, guildID, sql.NullInt64{Int64: roleID, Valid: roleID != 0}, updatedBy)
return err
}
// SetBetaGuild opts a server in or out of beta features.
func (r *Repo) SetBetaGuild(ctx context.Context, guildID, enabledBy int64, enabled bool) error {
if enabled {
_, err := r.db.ExecContext(ctx, `INSERT INTO beta_guilds (guild_id, enabled_by) VALUES ($1, $2) ON CONFLICT (guild_id) DO NOTHING`, guildID, enabledBy)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM beta_guilds WHERE guild_id = $1`, guildID)
return err
}
// IsBetaGuild reports whether a server opted in to beta features.
func (r *Repo) IsBetaGuild(ctx context.Context, guildID int64) (bool, error) {
var on bool
err := r.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM beta_guilds WHERE guild_id = $1)`, guildID).Scan(&on)
return on, err
}
// DeleteRoleRewardForLevel removes the role reward of a level and reports
// whether there was one.
func (r *Repo) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
res, err := r.db.ExecContext(ctx, `DELETE FROM levelrewards WHERE guild_id = $1 AND levelreq = $2`, guildID, level)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
@@ -0,0 +1,39 @@
// Package betasettings is where the beta server, the Beta Tester role and the
// servers that opted in to beta features are read and changed. The bot polls
// the same tables, so a change here reaches the beta gate within 30 seconds.
package betasettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
// Get returns the beta server and role as ID strings ("" when not set).
func (s *Service) Get(ctx context.Context) (guildID, roleID string, err error) {
return s.settings.GetBetaSettings(ctx)
}
func (s *Service) SetServer(ctx context.Context, guildID, roleID, updatedBy int64) error {
return s.settings.SetBetaServer(ctx, guildID, roleID, updatedBy)
}
func (s *Service) OptedIn(ctx context.Context) ([]string, error) {
return s.settings.ListBetaGuilds(ctx)
}
func (s *Service) IsOptedIn(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsBetaGuild(ctx, guildID)
}
func (s *Service) SetOptedIn(ctx context.Context, guildID, by int64, on bool) error {
return s.settings.SetBetaGuild(ctx, guildID, by, on)
}
@@ -55,3 +55,11 @@ func (s *Service) ListRoleRewards(ctx context.Context, guildID int64, limit int)
func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) { func (s *Service) IsLevelSystemEnabled(ctx context.Context, guildID int64) (bool, error) {
return s.settings.IsLevelSystemEnabled(ctx, guildID) return s.settings.IsLevelSystemEnabled(ctx, guildID)
} }
func (s *Service) DeleteRoleRewardForLevel(ctx context.Context, guildID int64, level int) (bool, error) {
return s.settings.DeleteRoleRewardForLevel(ctx, guildID, level)
}
func (s *Service) GetLevelUpChannelID(ctx context.Context, guildID int64) (int64, bool, error) {
return s.settings.GetLevelupChannelID(ctx, guildID)
}
+4 -1
View File
@@ -1,6 +1,7 @@
package services package services
import ( import (
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -28,11 +29,12 @@ type Services struct {
DefaultRole *defaultrole.Service DefaultRole *defaultrole.Service
LogSettings *logsettings.Service LogSettings *logsettings.Service
MusicSettings *musicsettings.Service MusicSettings *musicsettings.Service
BetaSettings *betasettings.Service
} }
var Global *Services var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service) *Services { func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service, musicSvc *musicsettings.Service, betaSvc *betasettings.Service) *Services {
s := &Services{ s := &Services{
Level: level, Level: level,
LevelSettings: levelSettings, LevelSettings: levelSettings,
@@ -46,6 +48,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
DefaultRole: defaultRoleSvc, DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc, LogSettings: logSettingsSvc,
MusicSettings: musicSvc, MusicSettings: musicSvc,
BetaSettings: betaSvc,
} }
Global = s Global = s
return s return s
+17
View File
@@ -686,6 +686,23 @@ func ToggleRepeatQueue(guildID string) (bool, bool) {
return repeatSong, repeatQueue return repeatSong, repeatQueue
} }
// RepeatFlags reports whether the current song or the whole queue repeats.
func RepeatFlags(guildID string) (song, queue bool) { return repeatFlags(guildID) }
// SetRepeat sets the repeat mode directly. At most one of the two applies;
// asking for both repeats the song.
func SetRepeat(guildID string, song, queue bool) {
if manager == nil {
return
}
gp := getOrCreateGuildPlayer(guildID)
manager.mu.Lock()
gp.RepeatSong = song
gp.RepeatQueue = queue && !song
manager.mu.Unlock()
syncState(guildID)
}
func repeatFlags(guildID string) (bool, bool) { func repeatFlags(guildID string) (bool, bool) {
if manager == nil { if manager == nil {
return false, false return false, false
+11
View File
@@ -307,6 +307,17 @@ func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error {
f.events = append(f.events, e) f.events = append(f.events, e)
return nil return nil
} }
func (f *fakeStore) ListEvents(_ context.Context, g int64, limit int) ([]securityrepo.Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []securityrepo.Event
for k := len(f.events) - 1; k >= 0 && len(out) < limit; k-- {
if f.events[k].GuildID == g {
out = append(out, f.events[k])
}
}
return out, nil
}
func (f *fakeStore) eventKinds() []string { func (f *fakeStore) eventKinds() []string {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+5
View File
@@ -182,6 +182,11 @@ func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error
} }
// Quarantines lists who is quarantined in a guild. // Quarantines lists who is quarantined in a guild.
// Events returns the server's recent security history, newest first.
func (e *Engine) Events(ctx context.Context, guildID string, limit int) ([]securityrepo.Event, error) {
return e.st.ListEvents(ctx, parseID(guildID), limit)
}
func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) { func (e *Engine) Quarantines(ctx context.Context, guildID string) ([]securityrepo.Quarantine, error) {
return e.st.ListQuarantines(ctx, parseID(guildID)) return e.st.ListQuarantines(ctx, parseID(guildID))
} }
+1
View File
@@ -61,6 +61,7 @@ type Store interface {
ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error) ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error)
DeleteLockdown(ctx context.Context, guildID, channelID int64) error DeleteLockdown(ctx context.Context, guildID, channelID int64) error
AddEvent(ctx context.Context, e securityrepo.Event) error AddEvent(ctx context.Context, e securityrepo.Event) error
ListEvents(ctx context.Context, guildID int64, limit int) ([]securityrepo.Event, error)
ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error) ListUnprocessedCommands(ctx context.Context) ([]securityrepo.Command, error)
MarkCommandProcessed(ctx context.Context, id int64) error MarkCommandProcessed(ctx context.Context, id int64) error
SaveRaid(ctx context.Context, x securityrepo.Raid) error SaveRaid(ctx context.Context, x securityrepo.Raid) error
+2 -1
View File
@@ -25,6 +25,7 @@ import (
"velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/repos/usersettingsrepo"
"velox-bot/internal/db/repos/welcomerepo" "velox-bot/internal/db/repos/welcomerepo"
"velox-bot/internal/db/services" "velox-bot/internal/db/services"
"velox-bot/internal/db/services/betasettings"
"velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level" "velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings" "velox-bot/internal/db/services/levelsettings"
@@ -78,7 +79,7 @@ func main() {
welcomeService := welcome.New(welcomeRepo) welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo) defaultRoleService := defaultrole.New(defaultRoleRepo)
musicSettingsService := musicsettings.New(settingsRepo) musicSettingsService := musicsettings.New(settingsRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService) services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService, musicSettingsService, betasettings.New(settingsRepo))
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services, musicRepo, config.DBHost)
if err != nil { if err != nil {