Commands for everything a dashboard can do
- /music skip, pause, resume, stop, repeat, remove, move (DJ role) - /config view, clear, toggletwitch, streamers, beta-features (server owner) - /slvl status, remove-reward; /moderation bans, timeouts; /security history - /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status - betasettings service and repo methods, with integration tests Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
acdb5ead35
commit
132a2cd986
26 files changed
+1062
-65
No files matched your search
@@ -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.")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user