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