Every dashboard feature is also a command #30

Merged
FernandoJVideira merged 4 commits from ft/commands-parity into dev 2026-10-23 01:12:37 +00:00
26 changed files with 1062 additions and 65 deletions
Showing only changes of commit 132a2cd986 - Show all commits

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)
}
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)
+7 -4
View File
@@ -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)
+189 -36
View File
@@ -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.")
}
}
+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
// 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
}
+6 -2
View File
@@ -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)
+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(),
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.")
}
+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) {
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] {
@@ -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{
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.")
}
+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{
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) {
+30
View File
@@ -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, "<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":
if len(subcmd.Options) == 0 {
return true
+29 -5
View File
@@ -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()
}
@@ -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)
}
}
@@ -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")
}
}
+40
View File
@@ -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
}
@@ -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) {
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
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
+17
View File
@@ -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
+11
View File
@@ -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()
+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.
// 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))
}
+1
View File
@@ -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
+2 -1
View File
@@ -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 {