From 132a2cd986df008e86f51355c63485782ab0ec42 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Fri, 23 Oct 2026 02:10:05 +0100 Subject: [PATCH] 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 --- internal/bot/beta_sync_test.go | 4 +- internal/bot/bot.go | 11 +- internal/commands/betacmd/betacmd.go | 225 +++++++++++++++--- internal/commands/commands.go | 8 +- internal/commands/config/public/config.go | 8 +- internal/commands/config/public/extras.go | 214 +++++++++++++++++ internal/commands/level/config/command.go | 6 + internal/commands/level/config/extra.go | 99 ++++++++ internal/commands/limits_test.go | 2 +- internal/commands/moderation/public/lists.go | 106 +++++++++ .../commands/moderation/public/moderation.go | 8 +- internal/commands/music/public/player.go | 126 ++++++++++ internal/commands/music/public/toggle.go | 30 ++- internal/commands/security/public/tune.go | 30 +++ internal/db/repos/securityrepo/repo.go | 34 ++- .../securityrepo/repo_integration_test.go | 24 ++ .../settingsrepo/meeting_integration_test.go | 63 +++++ internal/db/repos/settingsrepo/repo.go | 40 ++++ internal/db/services/betasettings/service.go | 39 +++ internal/db/services/levelsettings/service.go | 8 + internal/db/services/services.go | 5 +- internal/music/manager.go | 17 ++ internal/security/fakes_test.go | 11 + internal/security/quarantine.go | 5 + internal/security/security.go | 1 + main.go | 3 +- 26 files changed, 1062 insertions(+), 65 deletions(-) create mode 100644 internal/commands/config/public/extras.go create mode 100644 internal/commands/level/config/extra.go create mode 100644 internal/commands/moderation/public/lists.go create mode 100644 internal/commands/music/public/player.go create mode 100644 internal/db/services/betasettings/service.go diff --git a/internal/bot/beta_sync_test.go b/internal/bot/beta_sync_test.go index 63b57e3..3fecca6 100644 --- a/internal/bot/beta_sync_test.go +++ b/internal/bot/beta_sync_test.go @@ -171,8 +171,8 @@ func TestBetaCommandsFollowTheServers(t *testing.T) { 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 !contains(got, "betatest") || contains(got, "oldthing") || contains(got, "beta") { + 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] { t.Errorf("the database remembers where the commands are now: %v", store.registered) diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 32ff8be..40f7e6e 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -9,7 +9,6 @@ import ( "time" "velox-bot/internal/beta" "velox-bot/internal/commands" - "velox-bot/internal/commands/betacmd" "velox-bot/internal/db/repos/musicrepo" "velox-bot/internal/db/services" "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(func(s *discordgo.Session, c *discordgo.ChannelDelete) { @@ -357,9 +363,6 @@ func (b *Bot) addBetaCommands(guildID string) bool { } ok := true for _, cmd := range commands.BetaCommands { - if cmd.Name == betacmd.Beta.Name && !isBetaServer { - continue - } keep[cmd.Name] = true 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) diff --git a/internal/commands/betacmd/betacmd.go b/internal/commands/betacmd/betacmd.go index 81a72c0..20ba6f9 100644 --- a/internal/commands/betacmd/betacmd.go +++ b/internal/commands/betacmd/betacmd.go @@ -1,31 +1,59 @@ -// Package betacmd is the /beta command: it shows how beta access is set up. -// It exists only in the beta server (see commands.BetaCommands), is hidden -// from everyone but administrators in Discord's command picker, and only the -// bot's owner can actually run it (the owner is looked up from the Discord -// application, so there's nothing to configure). +// Package betacmd is /beta, the bot owner's control over the beta program. +// It is a command you use in a direct message with the bot, so it exists in no +// server's command menu, and only the owner of the Discord application can run +// it (the owner is looked up from the application, so there is nothing to +// 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 import ( + "context" "fmt" + "log" + "regexp" + "strconv" + "strings" "sync" + "time" + "velox-bot/internal/beta" + "velox-bot/internal/db/services" "github.com/bwmarrin/discordgo" ) var ( - noDM = false - noPerms = int64(0) // hides the command from everyone but administrators - ownerIDs sync.Map // user ID -> struct{}, filled once from the application + ownerIDs sync.Map // user ID -> struct{}, filled once from the application loadOnce sync.Mutex loaded bool + + snowflake = regexp.MustCompile(`^[0-9]{1,20}$`) + dmOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextBotDM} ) var Beta = &discordgo.ApplicationCommand{ - Name: "beta", - Description: "Show how beta access is set up (bot owner only)", - DMPermission: &noDM, - DefaultMemberPermissions: &noPerms, + Name: "beta", + Description: "Manage the beta program (bot owner, in a DM with the bot)", + Contexts: dmOnly, + 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 @@ -57,31 +85,156 @@ func isOwner(s *discordgo.Session, userID string) bool { return ok } -func Handler(s *discordgo.Session, i *discordgo.InteractionCreate) { - var userID string - if i.Member != nil && i.Member.User != nil { - userID = i.Member.User.ID - } - var msg string - st := beta.StatusOf(i.GuildID, i.Member) - switch { - case !isOwner(s, userID): - msg = "Only the bot's owner can use this command." - case !st.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." - } +func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: msg, - Flags: discordgo.MessageFlagsEphemeral, - AllowedMentions: &discordgo.MessageAllowedMentions{}, - }, + Data: &discordgo.InteractionResponseData{Content: msg, 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.") + } +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 4af1251..a079612 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -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 // package). To put a feature in beta, add it with RegisterBeta instead of to // AllCommands and handlers; to release it, move it there. -var BetaCommands = []*discordgo.ApplicationCommand{betacmd.Beta} +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() { - // /beta itself is open to the whole beta server (see betacmd). handlers[betacmd.Beta.Name] = betacmd.Handler } diff --git a/internal/commands/config/public/config.go b/internal/commands/config/public/config.go index 9adbd9e..0813af9 100644 --- a/internal/commands/config/public/config.go +++ b/internal/commands/config/public/config.go @@ -14,7 +14,7 @@ import ( var Config = &discordgo.ApplicationCommand{ Name: "config", Description: "Server configuration", - Options: []*discordgo.ApplicationCommandOption{ + Options: append([]*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "addstreamer", @@ -145,7 +145,7 @@ var Config = &discordgo.ApplicationCommand{ Name: "disablelogging", Description: "Disable server logging events", }, - }, + }, extraOptions()...), } func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -168,6 +168,10 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { return } + if handleExtra(s, i, data.Options[0]) { + return + } + switch data.Options[0].Name { case "addstreamer": log.Printf("twitch: /config addstreamer invoked by %s in guild %s", i.Member.User.ID, i.GuildID) diff --git a/internal/commands/config/public/extras.go b/internal/commands/config/public/extras.go new file mode 100644 index 0000000..bb7f392 --- /dev/null +++ b/internal/commands/config/public/extras.go @@ -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 +} diff --git a/internal/commands/level/config/command.go b/internal/commands/level/config/command.go index 0a6afed..f70fa4d 100644 --- a/internal/commands/level/config/command.go +++ b/internal/commands/level/config/command.go @@ -15,6 +15,8 @@ var Slvl = &discordgo.ApplicationCommand{ slvlSetChannelOption(), slvlSetMessageOption(), slvlSetRewardOption(), + slvlRemoveRewardOption(), + slvlStatusOption(), }, } @@ -34,6 +36,10 @@ func SlvlHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { slvlSetMessageHandler(s, i) case "set-reward": slvlSetRewardHandler(s, i) + case "remove-reward": + slvlRemoveRewardHandler(s, i) + case "status": + slvlStatusHandler(s, i) default: shared.RespondEphemeral(s, i, "Invalid subcommand.") } diff --git a/internal/commands/level/config/extra.go b/internal/commands/level/config/extra.go new file mode 100644 index 0000000..ab5cf02 --- /dev/null +++ b/internal/commands/level/config/extra.go @@ -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) +} diff --git a/internal/commands/limits_test.go b/internal/commands/limits_test.go index ab6e68f..c7a0615 100644 --- a/internal/commands/limits_test.go +++ b/internal/commands/limits_test.go @@ -76,7 +76,7 @@ func lower(s string) string { } 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{} for _, c := range all { if names[c.Name] { diff --git a/internal/commands/moderation/public/lists.go b/internal/commands/moderation/public/lists.go new file mode 100644 index 0000000..2a6f3c9 --- /dev/null +++ b/internal/commands/moderation/public/lists.go @@ -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:`.") + 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 \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) +} diff --git a/internal/commands/moderation/public/moderation.go b/internal/commands/moderation/public/moderation.go index 3565a97..d6e5d32 100644 --- a/internal/commands/moderation/public/moderation.go +++ b/internal/commands/moderation/public/moderation.go @@ -14,7 +14,7 @@ import ( var Moderation = &discordgo.ApplicationCommand{ Name: "moderation", Description: "High-value moderation tools", - Options: []*discordgo.ApplicationCommandOption{ + Options: append([]*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, Name: "purge", @@ -155,7 +155,7 @@ var Moderation = &discordgo.ApplicationCommand{ }, }, }, - }, + }, listOptions()...), } func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { @@ -185,6 +185,10 @@ func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { handleUntimeout(s, i) case "slowmode": handleSlowmode(s, i) + case "bans": + handleBans(s, i) + case "timeouts": + handleTimeouts(s, i) default: respondEphemeral(s, i, "Unknown subcommand.") } diff --git a/internal/commands/music/public/player.go b/internal/commands/music/public/player.go new file mode 100644 index 0000000..ae30bc6 --- /dev/null +++ b/internal/commands/music/public/player.go @@ -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) +} diff --git a/internal/commands/music/public/toggle.go b/internal/commands/music/public/toggle.go index 0a61941..e7a8590 100644 --- a/internal/commands/music/public/toggle.go +++ b/internal/commands/music/public/toggle.go @@ -10,14 +10,14 @@ import ( var Music = &discordgo.ApplicationCommand{ Name: "music", - Description: "Music settings", - Options: []*discordgo.ApplicationCommandOption{ + Description: "Music settings and player controls", + Options: append([]*discordgo.ApplicationCommandOption{ { Type: discordgo.ApplicationCommandOptionSubCommand, 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) { @@ -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.") return } - if !shared.RequireManageGuild(s, i) { - return - } if !shared.RequireMusicService(s, i) { return } @@ -37,11 +34,26 @@ func MusicHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { shared.RespondEphemeral(s, i, "Missing subcommand.") return } + sub := data.Options[0] - switch data.Options[0].Name { - case "toggle": + if sub.Name == "toggle" { + if !shared.RequireManageGuild(s, i) { + return + } 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) { diff --git a/internal/commands/security/public/tune.go b/internal/commands/security/public/tune.go index 02ce940..f4c9a9d 100644 --- a/internal/commands/security/public/tune.go +++ b/internal/commands/security/public/tune.go @@ -70,6 +70,7 @@ func tuneOptions() []*discordgo.ApplicationCommandOption { } return []*discordgo.ApplicationCommandOption{ {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)", sub("check", "Choose what happens when a check catches a new member", choiceOption("check", "Which check", true, joinGateChecks...), @@ -145,6 +146,35 @@ func handleTune(ctx context.Context, s *discordgo.Session, i *discordgo.Interact return true } 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, " **%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": if len(subcmd.Options) == 0 { return true diff --git a/internal/db/repos/securityrepo/repo.go b/internal/db/repos/securityrepo/repo.go index 4368405..ac11313 100644 --- a/internal/db/repos/securityrepo/repo.go +++ b/internal/db/repos/securityrepo/repo.go @@ -509,11 +509,12 @@ func (r *Repo) DeleteLockdown(ctx context.Context, guildID, channelID int64) err // ---- Events ---- type Event struct { - GuildID int64 - Kind string - ActorID int64 - TargetID int64 - Detail string + GuildID int64 + Kind string + ActorID int64 + TargetID int64 + Detail string + CreatedAt time.Time // set when read } // Event kinds. @@ -716,3 +717,26 @@ func (r *Repo) GuildsDueForSnapshot(ctx context.Context, maxAge time.Duration) ( } 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() +} diff --git a/internal/db/repos/securityrepo/repo_integration_test.go b/internal/db/repos/securityrepo/repo_integration_test.go index 0c9e4ba..2043b39 100644 --- a/internal/db/repos/securityrepo/repo_integration_test.go +++ b/internal/db/repos/securityrepo/repo_integration_test.go @@ -229,3 +229,27 @@ func TestRaidsSnapshotsAndHeatSettings(t *testing.T) { 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) + } +} diff --git a/internal/db/repos/settingsrepo/meeting_integration_test.go b/internal/db/repos/settingsrepo/meeting_integration_test.go index eda782a..117668f 100644 --- a/internal/db/repos/settingsrepo/meeting_integration_test.go +++ b/internal/db/repos/settingsrepo/meeting_integration_test.go @@ -256,3 +256,66 @@ func TestBetaRegistered(t *testing.T) { 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") + } +} diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index cb8d34d..2f40027 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -475,3 +475,43 @@ func (r *Repo) SetBetaRegistered(ctx context.Context, guildID string, registered } 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 +} diff --git a/internal/db/services/betasettings/service.go b/internal/db/services/betasettings/service.go new file mode 100644 index 0000000..1ecf89a --- /dev/null +++ b/internal/db/services/betasettings/service.go @@ -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) +} diff --git a/internal/db/services/levelsettings/service.go b/internal/db/services/levelsettings/service.go index a97eb00..d191263 100644 --- a/internal/db/services/levelsettings/service.go +++ b/internal/db/services/levelsettings/service.go @@ -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) { 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) +} diff --git a/internal/db/services/services.go b/internal/db/services/services.go index abec50d..28a56a9 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -1,6 +1,7 @@ package services import ( + "velox-bot/internal/db/services/betasettings" "velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" @@ -28,11 +29,12 @@ type Services struct { DefaultRole *defaultrole.Service LogSettings *logsettings.Service MusicSettings *musicsettings.Service + BetaSettings *betasettings.Service } 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{ Level: level, LevelSettings: levelSettings, @@ -46,6 +48,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee DefaultRole: defaultRoleSvc, LogSettings: logSettingsSvc, MusicSettings: musicSvc, + BetaSettings: betaSvc, } Global = s return s diff --git a/internal/music/manager.go b/internal/music/manager.go index e740543..615466b 100644 --- a/internal/music/manager.go +++ b/internal/music/manager.go @@ -686,6 +686,23 @@ func ToggleRepeatQueue(guildID string) (bool, bool) { 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) { if manager == nil { return false, false diff --git a/internal/security/fakes_test.go b/internal/security/fakes_test.go index 511dbbb..aa5e93c 100644 --- a/internal/security/fakes_test.go +++ b/internal/security/fakes_test.go @@ -307,6 +307,17 @@ func (f *fakeStore) AddEvent(_ context.Context, e securityrepo.Event) error { f.events = append(f.events, e) 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 { f.mu.Lock() defer f.mu.Unlock() diff --git a/internal/security/quarantine.go b/internal/security/quarantine.go index 71cae46..1eeb6b4 100644 --- a/internal/security/quarantine.go +++ b/internal/security/quarantine.go @@ -182,6 +182,11 @@ func (e *Engine) Release(ctx context.Context, guildID, userID, by string) error } // 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) { return e.st.ListQuarantines(ctx, parseID(guildID)) } diff --git a/internal/security/security.go b/internal/security/security.go index 64df151..c468834 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -61,6 +61,7 @@ type Store interface { ListLockdowns(ctx context.Context, guildID int64, serverWideOnly bool) ([]securityrepo.Lockdown, error) DeleteLockdown(ctx context.Context, guildID, channelID int64) 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) MarkCommandProcessed(ctx context.Context, id int64) error SaveRaid(ctx context.Context, x securityrepo.Raid) error diff --git a/main.go b/main.go index 5516cc0..42c869f 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "velox-bot/internal/db/repos/usersettingsrepo" "velox-bot/internal/db/repos/welcomerepo" "velox-bot/internal/db/services" + "velox-bot/internal/db/services/betasettings" "velox-bot/internal/db/services/defaultrole" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" @@ -78,7 +79,7 @@ func main() { welcomeService := welcome.New(welcomeRepo) defaultRoleService := defaultrole.New(defaultRoleRepo) 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) if err != nil {