// 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 ( 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: "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 // on its team). If the application can't be read, nobody is the owner. func isOwner(s *discordgo.Session, userID string) bool { if userID == "" { return false } loadOnce.Lock() if !loaded { app, err := s.Application("@me") if err == nil && app != nil { if app.Owner != nil { ownerIDs.Store(app.Owner.ID, struct{}{}) } if app.Team != nil { ownerIDs.Store(app.Team.OwnerID, struct{}{}) for _, m := range app.Team.Members { if m != nil && m.User != nil { ownerIDs.Store(m.User.ID, struct{}{}) } } } loaded = true } } loadOnce.Unlock() _, ok := ownerIDs.Load(userID) return ok } func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, 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.") } }