diff --git a/.circleci/config.yml b/.circleci/config.yml index 3625054532..09d36d6daa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ jobs: # basic units of work in a run build: # runs not using Workflows must have a `build` job as entry point docker: # run the steps with Docker # CircleCI Go images available at: https://hub.docker.com/r/circleci/golang/ - - image: cimg/go:1.25.0 # + - image: cimg/go:1.25.4 # # directory where steps are run. Path must conform to the Go Workspace requirements working_directory: ~/app diff --git a/.gitignore b/.gitignore index c93de57dca..c301797fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ cmd/yagpdb/static/index.html cmd/yagpdb/cert cmd/yagpdb/access_log cmd/yagpdb/access.log +cmd/yagpdb/*.log cmd/yagpdb/static/report.html cmd/yagpdb/yagpdb cmd/yagpdb/templates/plugins/*.html diff --git a/automod/assets/automod.html b/automod/assets/automod.html index 1fac951c5e..0991083b77 100644 --- a/automod/assets/automod.html +++ b/automod/assets/automod.html @@ -338,7 +338,7 @@

List: - {{textChannelOptions .ActiveGuild.Channels .BulkRole.NotificationChannel true "None"}} + {{textOnlyChannelOptions .ActiveGuild.Channels .BulkRole.NotificationChannel true "None"}} diff --git a/cmd/yagpdb/main.go b/cmd/yagpdb/main.go index 5e6e570906..6e6d8cd14a 100644 --- a/cmd/yagpdb/main.go +++ b/cmd/yagpdb/main.go @@ -9,6 +9,8 @@ import ( "github.com/botlabs-gg/yagpdb/v2/common/run" "github.com/botlabs-gg/yagpdb/v2/lib/confusables" "github.com/botlabs-gg/yagpdb/v2/trivia" + "github.com/botlabs-gg/yagpdb/v2/twitch" + "github.com/botlabs-gg/yagpdb/v2/voiceroles" "github.com/botlabs-gg/yagpdb/v2/web/discorddata" // Core yagpdb packages @@ -100,6 +102,8 @@ func main() { rss.RegisterPlugin() bulkrole.RegisterPlugin() personalizer.RegisterPlugin() + twitch.RegisterPlugin() + voiceroles.RegisterPlugin() // Register confusables replacer confusables.Init() diff --git a/commands/tmplexec.go b/commands/tmplexec.go index 4d2c785de6..3efd955b5d 100644 --- a/commands/tmplexec.go +++ b/commands/tmplexec.go @@ -122,62 +122,11 @@ func execCmd(tmplCtx *templates.Context, dryRun bool, m *discordgo.MessageCreate fakeMsg := *m.Message fakeMsg.Mentions = make([]*discordgo.User, 0) - cmdLine := cmd + " " - - for _, arg := range args { - if arg == nil { - return "", errors.New("Nil arg passed") - } - - switch t := arg.(type) { - case string: - if strings.HasPrefix(t, "-") { - // Don't put quotes around switches - cmdLine += t - } else if strings.HasPrefix(t, "\\-") { - // Escaped - - cmdLine += "\"" + t[1:] + "\"" - } else { - cmdLine += "\"" + t + "\"" - } - case int: - cmdLine += strconv.FormatInt(int64(t), 10) - case int32: - cmdLine += strconv.FormatInt(int64(t), 10) - case int64: - cmdLine += strconv.FormatInt(t, 10) - case uint: - cmdLine += strconv.FormatUint(uint64(t), 10) - case uint8: - cmdLine += strconv.FormatUint(uint64(t), 10) - case uint16: - cmdLine += strconv.FormatUint(uint64(t), 10) - case uint32: - cmdLine += strconv.FormatUint(uint64(t), 10) - case uint64: - cmdLine += strconv.FormatUint(t, 10) - case float32: - cmdLine += strconv.FormatFloat(float64(t), 'E', -1, 32) - case float64: - cmdLine += strconv.FormatFloat(t, 'E', -1, 64) - case *discordgo.User: - cmdLine += "<@" + strconv.FormatInt(t.ID, 10) + ">" - fakeMsg.Mentions = append(fakeMsg.Mentions, t) - case discordgo.User: - cmdLine += "<@" + strconv.FormatInt(t.ID, 10) + ">" - fakeMsg.Mentions = append(fakeMsg.Mentions, &t) - case []string: - for i, str := range t { - if i != 0 { - cmdLine += " " - } - cmdLine += str - } - default: - return "", errors.New("Unknown type in exec, only strings, numbers, users and string slices are supported") - } - cmdLine += " " + cmdLine, mentions, err := buildExecCmdLine(cmd, args...) + if err != nil { + return "", err } + fakeMsg.Mentions = append(fakeMsg.Mentions, mentions...) logger.Infof("Custom template is executing a command: %s for guild %v", cmdLine, tmplCtx.Msg.GuildID) @@ -240,7 +189,7 @@ func execCmd(tmplCtx *templates.Context, dryRun bool, m *discordgo.MessageCreate } if cd > 0 { - return "", errors.NewPlain("this command is on guild scope cooldown") + return "", errors.NewPlain("This command is on cooldown, try again in " + strconv.Itoa(cd) + "seconds") } resp, err := runFunc(data) @@ -265,3 +214,69 @@ func execCmd(tmplCtx *templates.Context, dryRun bool, m *discordgo.MessageCreate return "", nil } + +func buildExecCmdLine(cmd string, args ...any) (string, []*discordgo.User, error) { + cmdLine := cmd + " " + var mentions []*discordgo.User + + for _, arg := range args { + if arg == nil { + return "", nil, errors.New("Nil arg passed") + } + + switch t := arg.(type) { + case string: + if strings.HasPrefix(t, "-") { + // Don't put quotes around switches + cmdLine += t + } else if strings.HasPrefix(t, "\\-") { + // Escaped - + cmdLine += "\"" + t[1:] + "\"" + } else { + cmdLine += "\"" + t + "\"" + } + case int: + cmdLine += strconv.FormatInt(int64(t), 10) + case int32: + cmdLine += strconv.FormatInt(int64(t), 10) + case int64: + cmdLine += strconv.FormatInt(t, 10) + case uint: + cmdLine += strconv.FormatUint(uint64(t), 10) + case uint8: + cmdLine += strconv.FormatUint(uint64(t), 10) + case uint16: + cmdLine += strconv.FormatUint(uint64(t), 10) + case uint32: + cmdLine += strconv.FormatUint(uint64(t), 10) + case uint64: + cmdLine += strconv.FormatUint(t, 10) + case float32: + cmdLine += strconv.FormatFloat(float64(t), 'E', -1, 32) + case float64: + cmdLine += strconv.FormatFloat(t, 'E', -1, 64) + case *discordgo.User: + cmdLine += "<@" + strconv.FormatInt(t.ID, 10) + ">" + mentions = append(mentions, t) + case discordgo.User: + cmdLine += "<@" + strconv.FormatInt(t.ID, 10) + ">" + mentions = append(mentions, &t) + case []string: + for i, str := range t { + if i != 0 { + cmdLine += " " + } + cmdLine += str + } + default: + return "", nil, errors.New("Unknown type in exec, only strings, numbers, users and string slices are supported") + } + cmdLine += " " + + if len(cmdLine) > templates.MaxStringLength { + return "", nil, templates.ErrStringTooLong + } + } + + return cmdLine, mentions, nil +} diff --git a/commands/yagcommmand.go b/commands/yagcommmand.go index 715d46ab85..9221e789b0 100644 --- a/commands/yagcommmand.go +++ b/commands/yagcommmand.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strconv" "strings" "sync" "sync/atomic" @@ -496,7 +497,7 @@ func (yc *YAGCommand) checkCanExecuteCommand(data *dcmd.Data) (canExecute bool, if cdLeft > 0 { resp = &CanExecuteError{ Type: ReasonCooldown, - Message: "Command is on cooldown", + Message: "Command is on cooldown, try again in " + strconv.Itoa(cdLeft) + " seconds", } return false, resp, settings, nil } diff --git a/common/mqueue/discordprocessor.go b/common/mqueue/discordprocessor.go index f6764fc114..bcf9d608da 100644 --- a/common/mqueue/discordprocessor.go +++ b/common/mqueue/discordprocessor.go @@ -1,7 +1,6 @@ package mqueue import ( - "encoding/json" "time" "emperror.dev/errors" @@ -74,25 +73,19 @@ var disableOnError = []int{ discordgo.ErrCodeUnknownChannel, discordgo.ErrCodeMissingAccess, discordgo.ErrCodeMissingPermissions, + discordgo.ErrCodeCannotSendMessagesInVoiceChannel, + discordgo.ErrCodeUnknownWebhook, 30007, // max number of webhooks 220001, // webhook points to a forum channel } func maybeDisableFeed(source PluginWithSourceDisabler, elem *QueuedElement, err *discordgo.RESTError) { - // source.HandleMQueueError(elem, errors.Cause(err)) + l := logger.WithError(err).WithField("source", elem.Source).WithField("sourceid", elem.SourceItemID).WithField("guild_id", elem.GuildID) if err.Message == nil || !common.ContainsIntSlice(disableOnError, err.Message.Code) { - // don't disable - l := logger.WithError(err).WithField("source", elem.Source).WithField("sourceid", elem.SourceItemID) - if elem.MessageEmbed != nil { - serializedEmbed, _ := json.Marshal(elem.MessageEmbed) - l = l.WithField("embed", serializedEmbed) - } - l.Error("error sending mqueue message") return } - - logger.WithError(err).Warnf("disabling feed item %s from %s", elem.SourceItemID, elem.Source) + l.Warn("disabling feed item") source.DisableFeed(elem, err) } diff --git a/common/run/gen-docs.go b/common/run/gen-docs.go index 94c38aceef..157037d126 100644 --- a/common/run/gen-docs.go +++ b/common/run/gen-docs.go @@ -40,7 +40,11 @@ func GenCommandsDocs() { nameStr += entry.Cmd.Trigger.Names[0] // then aliases - aliases := strings.Join(entry.Cmd.Trigger.Names[1:], "/") + var as bytes.Buffer + for _, alias := range entry.Cmd.Trigger.Names[1:] { + as.WriteString("- " + alias + "\n") + } + aliases := as.String() // arguments and switches args := stdHelpFmt.ArgDefs(entry.Cmd, mockCmdData) @@ -59,29 +63,26 @@ func GenCommandsDocs() { } } + anchor := strings.ReplaceAll(nameStr, " ", "-") out.WriteString("### " + nameStr + "\n\n") if aliases != "" { - out.WriteString("**Aliases:** " + aliases + "\n\n") + out.WriteString("#### Aliases{#" + anchor + "-aliases}\n\n" + aliases + "\n") } out.WriteString(desc) - out.WriteString("\n\n") + out.WriteString("\n") - out.WriteString("**Usage:**\n") - out.WriteString("```\n" + args + "\n```\n") + out.WriteString("#### Usage{#" + anchor + "-usage}\n\n") + out.WriteString("```txt\n" + args + "\n```\n") if switches != "" { - out.WriteString("```\n" + switches + "\n```\n") + out.WriteString("\n```txt\n" + switches + "\n```\n") } - out.WriteString("\n") } - } os.Stdout.Write(out.Bytes()) - - return } func GenConfigDocs() { @@ -127,7 +128,7 @@ func GenConfigDocs() { out.WriteString("\n") properKey := strings.ToUpper(v.Name) - properKey = strings.Replace(properKey, ".", "_", -1) + properKey = strings.ReplaceAll(properKey, ".", "_") out.WriteString(properKey + "\n\n") } diff --git a/common/run/run.go b/common/run/run.go index 233864378b..a55f390a30 100644 --- a/common/run/run.go +++ b/common/run/run.go @@ -93,7 +93,7 @@ func Init() { AddSyslogHooks() } - if !flagRunBot && !flagRunWeb && flagRunFeeds == "" && !flagRunEverything && !flagDryRun && !flagRunBWC && !flagGenConfigDocs { + if !flagRunBot && !flagRunWeb && flagRunFeeds == "" && !flagRunEverything && !flagDryRun && !flagRunBWC && !flagGenConfigDocs && !FlagGenCmdDocs { log.Error("Didnt specify what to run, see -h for more info") os.Exit(1) } diff --git a/common/templates/components.go b/common/templates/components.go index 3cfaabbf8c..7c8805e72c 100644 --- a/common/templates/components.go +++ b/common/templates/components.go @@ -16,18 +16,18 @@ import ( "github.com/botlabs-gg/yagpdb/v2/lib/discordgo" ) -func CreateComponent(expectedType discordgo.ComponentType, values ...interface{}) (discordgo.MessageComponent, error) { +func CreateComponent(expectedType discordgo.ComponentType, values ...any) (discordgo.MessageComponent, error) { if len(values) < 1 && expectedType != discordgo.ActionsRowComponent { return discordgo.ActionsRow{}, errors.New("no values passed to component builder") } - var m map[string]interface{} + var m map[string]any switch t := values[0].(type) { case SDict: m = t case *SDict: m = *t - case map[string]interface{}: + case map[string]any: m = t default: dict, err := StringKeyDictionary(values...) @@ -102,6 +102,10 @@ func CreateComponent(expectedType discordgo.ComponentType, values ...interface{} comp := discordgo.Container{} err = json.Unmarshal(encoded, &comp) component = comp + case discordgo.LabelComponent: + comp := discordgo.Label{} + err = json.Unmarshal(encoded, &comp) + component = comp } if err != nil { @@ -111,14 +115,63 @@ func CreateComponent(expectedType discordgo.ComponentType, values ...interface{} return component, nil } -func CreateButton(values ...interface{}) (*discordgo.Button, error) { - var messageSdict map[string]interface{} +func CreateLabel(values ...any) (*discordgo.Label, error) { + var messageSdict map[string]any + switch t := values[0].(type) { + case SDict: + messageSdict = t + case *SDict: + messageSdict = *t + case map[string]any: + messageSdict = t + case *discordgo.Label: + return t, nil + default: + dict, err := StringKeyDictionary(values...) + if err != nil { + return nil, err + } + messageSdict = dict + } + + convertedLabel := make(map[string]any) + for k, v := range messageSdict { + switch strings.ToLower(k) { + case "custom_id": + c, err := validateCustomID(ToString(v), nil) + if err != nil { + return nil, err + } + convertedLabel[k] = c + case "label": + convertedLabel[k] = v + case "description": + convertedLabel[k] = v + case "component": + if c, ok := v.(discordgo.InteractiveComponent); ok && c.IsAllowedInLabel() { + convertedLabel[k] = c + } else { + return nil, errors.New("unsupported component in label") + } + } + } + + l, err := CreateComponent(discordgo.LabelComponent, convertedLabel) + if err != nil { + return nil, err + } + label := l.(discordgo.Label) + return &label, nil +} + +func CreateButton(values ...any) (*discordgo.Button, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Button: return t, nil @@ -130,7 +183,7 @@ func CreateButton(values ...interface{}) (*discordgo.Button, error) { messageSdict = dict } - convertedButton := make(map[string]interface{}) + convertedButton := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "style": @@ -188,14 +241,14 @@ func CreateButton(values ...interface{}) (*discordgo.Button, error) { return &button, err } -func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { - var messageSdict map[string]interface{} +func CreateSelectMenu(values ...any) (*discordgo.SelectMenu, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.SelectMenu: return t, nil @@ -209,7 +262,7 @@ func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { menuType := discordgo.SelectMenuComponent - convertedMenu := make(map[string]interface{}) + convertedMenu := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "type": @@ -263,15 +316,15 @@ func CreateSelectMenu(values ...interface{}) (*discordgo.SelectMenu, error) { return &menu, err } -func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { +func createThumbnail(values ...any) (discordgo.Thumbnail, error) { thumb := discordgo.Thumbnail{} - var messageSdict map[string]interface{} + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Thumbnail: return *t, nil @@ -285,7 +338,7 @@ func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { messageSdict = dict } - convertedThumbnail := make(map[string]interface{}) + convertedThumbnail := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "media": @@ -302,14 +355,14 @@ func createThumbnail(values ...interface{}) (discordgo.Thumbnail, error) { return thumb, err } -func CreateSection(values ...interface{}) (*discordgo.Section, error) { - var messageSdict map[string]interface{} +func CreateSection(values ...any) (*discordgo.Section, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Section: return t, nil @@ -321,7 +374,7 @@ func CreateSection(values ...interface{}) (*discordgo.Section, error) { messageSdict = dict } - convertedSection := make(map[string]interface{}) + convertedSection := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "text": @@ -368,9 +421,9 @@ func CreateSection(values ...interface{}) (*discordgo.Section, error) { return §ion, err } -func CreateTextDisplay(value interface{}) (*discordgo.TextDisplay, error) { +func CreateTextDisplay(value any) (*discordgo.TextDisplay, error) { var display discordgo.TextDisplay - d, err := CreateComponent(discordgo.TextDisplayComponent, map[string]interface{}{ + d, err := CreateComponent(discordgo.TextDisplayComponent, map[string]any{ "content": ToString(value), }) if err == nil { @@ -379,20 +432,59 @@ func CreateTextDisplay(value interface{}) (*discordgo.TextDisplay, error) { return &display, err } -func createUnfurledMedia(value interface{}) discordgo.UnfurledMediaItem { +func CreateTextInput(values ...any) (*discordgo.TextInput, error) { + var messageSdict map[string]any + switch t := values[0].(type) { + case SDict: + messageSdict = t + case *SDict: + messageSdict = *t + case map[string]any: + messageSdict = t + default: + dict, err := StringKeyDictionary(values...) + if err != nil { + return nil, err + } + messageSdict = dict + } + var textInput discordgo.TextInput + convertedTextInput := make(map[string]any) + for k, v := range messageSdict { + switch strings.ToLower(k) { + case "custom_id": + c, err := validateCustomID(TemplateCustomIDPrefix+ToString(v), nil) + if err != nil { + return nil, err + } + convertedTextInput[k] = c + default: + convertedTextInput[k] = v + } + } + + t, err := CreateComponent(discordgo.TextInputComponent, convertedTextInput) + if err == nil { + textInput = t.(discordgo.TextInput) + } + + return &textInput, err +} + +func createUnfurledMedia(value any) discordgo.UnfurledMediaItem { return discordgo.UnfurledMediaItem{ URL: ToString(value), } } -func createGalleryItem(values ...interface{}) (item discordgo.MediaGalleryItem, err error) { - var messageSdict map[string]interface{} +func createGalleryItem(values ...any) (item discordgo.MediaGalleryItem, err error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case discordgo.MediaGalleryItem: item = t @@ -423,7 +515,7 @@ func createGalleryItem(values ...interface{}) (item discordgo.MediaGalleryItem, return } -func CreateGallery(values interface{}) (*discordgo.MediaGallery, error) { +func CreateGallery(values any) (*discordgo.MediaGallery, error) { convertedGallery := &discordgo.MediaGallery{} val, _ := indirect(reflect.ValueOf(values)) if val.Kind() == reflect.Slice { @@ -447,14 +539,14 @@ func CreateGallery(values interface{}) (*discordgo.MediaGallery, error) { return convertedGallery, nil } -func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo.ComponentFile, error) { - var messageSdict map[string]interface{} +func CreateFile(msgFiles *[]*discordgo.File, values ...any) (*discordgo.ComponentFile, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.ComponentFile: return t, nil @@ -470,7 +562,7 @@ func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo. ContentType: "text/plain", Name: "attachment_" + time.Now().Format("2006-01-02_15-04-05") + ".txt", } - convertedFile := make(map[string]interface{}) + convertedFile := make(map[string]any) for k, v := range messageSdict { switch strings.ToLower(k) { case "content": @@ -501,7 +593,7 @@ func CreateFile(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo. return &cFile, err } -func CreateSeparator(large interface{}) *discordgo.Separator { +func CreateSeparator(large any) *discordgo.Separator { spacing := discordgo.SeparatorSpacingSmall if large != nil && large != false { spacing = discordgo.SeparatorSpacingLarge @@ -511,7 +603,7 @@ func CreateSeparator(large interface{}) *discordgo.Separator { } } -func CreateComponentArray(msgFiles *[]*discordgo.File, values ...interface{}) ([]discordgo.TopLevelComponent, error) { +func CreateComponentArray(msgFiles *[]*discordgo.File, values ...any) ([]discordgo.TopLevelComponent, error) { if len(values) < 1 { return nil, nil } @@ -733,14 +825,14 @@ func CreateComponentArray(msgFiles *[]*discordgo.File, values ...interface{}) ([ return components, nil } -func CreateContainer(msgFiles *[]*discordgo.File, values ...interface{}) (*discordgo.Container, error) { - var messageSdict map[string]interface{} +func CreateContainer(msgFiles *[]*discordgo.File, values ...any) (*discordgo.Container, error) { + var messageSdict map[string]any switch t := values[0].(type) { case SDict: messageSdict = t case *SDict: messageSdict = *t - case map[string]interface{}: + case map[string]any: messageSdict = t case *discordgo.Container: return t, nil @@ -778,7 +870,7 @@ func CreateContainer(msgFiles *[]*discordgo.File, values ...interface{}) (*disco func distributeComponentsIntoActionsRows(components reflect.Value) (returnComponents []discordgo.TopLevelComponent, err error) { if components.Len() < 1 { - return + return make([]discordgo.TopLevelComponent, 0), nil } const maxRows = 5 // Discord limitation @@ -855,22 +947,15 @@ func validateCustomID(id string, used map[string]bool) (string, error) { id = fmt.Sprint(len(used)) } - if !strings.HasPrefix(id, "templates-") { - id = fmt.Sprint("templates-", id) + if !strings.HasPrefix(id, TemplateCustomIDPrefix) { + id = fmt.Sprint(TemplateCustomIDPrefix, id) } const maxCIDLength = 100 // discord limitation if len(id) > maxCIDLength { - return "", errors.New("custom id too long (max 90 chars)") // maxCIDLength - len("templates-") + return "", fmt.Errorf("custom id too long (max %d chars)", maxCIDLength-len(TemplateCustomIDPrefix)) } - if used == nil { - return id, nil - } - - if _, ok := used[id]; ok { - return "", errors.New("duplicate custom ids used") - } return id, nil } diff --git a/common/templates/context.go b/common/templates/context.go index f6ccfaf27b..571c51526e 100644 --- a/common/templates/context.go +++ b/common/templates/context.go @@ -26,13 +26,17 @@ import ( "github.com/botlabs-gg/yagpdb/v2/web/discorddata" "github.com/sirupsen/logrus" "github.com/vmihailenco/msgpack" + "golang.org/x/text/cases" + "golang.org/x/text/language" ) var ( - StandardFuncMap = map[string]interface{}{ + titleCaser = cases.Title(language.Und) + + StandardFuncMap = map[string]any{ // conversion functions "str": ToString, - "toString": ToString, // don't ask why we have 2 of these + "toString": ToString, // don't ask why we have 2 of these, update: the answer is always "deprecated but not removed" "toInt": tmplToInt, "toInt64": ToInt64, "toFloat": ToFloat64, @@ -47,7 +51,7 @@ var ( "lower": strings.ToLower, "slice": slice, "split": strings.Split, - "title": strings.Title, + "title": titleCaser.String, "trimSpace": strings.TrimSpace, "upper": strings.ToUpper, "urlescape": url.PathEscape, @@ -89,20 +93,30 @@ var ( "bitwiseLeftShift": tmplBitwiseLeftShift, "bitwiseRightShift": tmplBitwiseRightShift, - // misc - "humanizeThousands": tmplHumanizeThousands, - "dict": Dictionary, - "sdict": StringKeyDictionary, - "structToSdict": StructToSdict, - "componentBuilder": CreateComponentBuilder, + // message component builders + "componentBuilder": CreateComponentBuilder, + "modalBuilder": CreateModalBuilder, + "cbutton": CreateButton, + "cmenu": CreateSelectMenu, + "cmodal": CreateModal, + "clabel": CreateLabel, + "ctextInput": CreateTextInput, + "ctextDisplay": CreateTextDisplay, + + // message builders "cembed": CreateEmbed, - "cbutton": CreateButton, - "cmenu": CreateSelectMenu, - "cmodal": CreateModal, - "cslice": CreateSlice, "complexMessage": CreateMessageSend, "complexMessageEdit": CreateMessageEdit, - "kindOf": KindOf, + + // misc + "humanizeThousands": tmplHumanizeThousands, + "dict": Dictionary, + "sdict": StringKeyDictionary, + "structToSdict": StructToSdict, + + "cslice": CreateSlice, + + "kindOf": KindOf, "adjective": common.RandomAdjective, "in": in, @@ -663,8 +677,6 @@ func baseContextFuncs(c *Context) { c.addContextFunc("deleteResponse", c.tmplDelResponse) c.addContextFunc("deleteTrigger", c.tmplDelTrigger) - c.addContextFunc("editComponentMessage", c.tmplEditComponentsMessage(true)) - c.addContextFunc("editComponentMessageNoEscape", c.tmplEditComponentsMessage(false)) c.addContextFunc("editMessage", c.tmplEditMessage(true)) c.addContextFunc("editMessageNoEscape", c.tmplEditMessage(false)) c.addContextFunc("getMessage", c.tmplGetMessage) @@ -675,11 +687,15 @@ func baseContextFuncs(c *Context) { // Message send functions c.addContextFunc("sendDM", c.tmplSendDM) + + //TODO: Remove these component functions c.addContextFunc("sendComponentMessageRetID", c.tmplSendComponentsMessage(true, true)) c.addContextFunc("sendComponentMessage", c.tmplSendComponentsMessage(true, false)) c.addContextFunc("sendComponentMessageNoEscape", c.tmplSendComponentsMessage(false, false)) c.addContextFunc("sendComponentMessageNoEscapeRetID", c.tmplSendComponentsMessage(false, true)) - c.addContextFunc("sendComponentMessageRetID", c.tmplSendComponentsMessage(true, true)) + c.addContextFunc("editComponentMessage", c.tmplEditComponentsMessage(true)) + c.addContextFunc("editComponentMessageNoEscape", c.tmplEditComponentsMessage(false)) + c.addContextFunc("sendMessage", c.tmplSendMessage(true, false)) c.addContextFunc("sendMessageNoEscape", c.tmplSendMessage(false, false)) c.addContextFunc("sendMessageNoEscapeRetID", c.tmplSendMessage(false, true)) @@ -1015,7 +1031,7 @@ func (d Dict) MarshalJSON() ([]byte, error) { return json.Marshal(md) } -type SDict map[string]interface{} +type SDict map[string]any func (d SDict) Set(key string, value interface{}) (string, error) { d[key] = value @@ -1140,12 +1156,95 @@ func (s Slice) StringSlice(flag ...bool) interface{} { return StringSlice } +type ModalBuilder struct { + Title string + CustomID string + Components []discordgo.TopLevelComponent +} + +func (s *ModalBuilder) Set(key string, value any) (*ModalBuilder, error) { + switch key { + case "title": + s.Title = ToString(value) + case "custom_id": + cID, err := validateCustomID(ToString(value), nil) + if err != nil { + return nil, err + } + s.CustomID = cID + case "components": + val, _ := indirect(reflect.ValueOf(value)) + s.Components = make([]discordgo.TopLevelComponent, 0) + if val.Kind() == reflect.Slice { + for i := 0; i < val.Len(); i++ { + _, err := s.addComponent(val.Index(i).Interface()) + if err != nil { + return nil, err + } + } + } else { + return nil, errors.New("components must be a slice of Labels or TextFields") + } + default: + return nil, errors.New("invalid key, accepted keys are: title, custom_id, components") + } + return s, nil +} + +func (s *ModalBuilder) addComponent(comp any) (*ModalBuilder, error) { + if len(s.Components) == 5 { + return nil, errors.New("modal builder can only have maximum 5 top level components") + } + + if comp, ok := comp.(discordgo.TopLevelComponent); ok { + if !comp.IsModalSupported() { + return nil, errors.New("invalid top level component passed to modal builder") + } + s.Components = append(s.Components, comp) + } else { + return nil, errors.New("invalid top level component passed to modal builder") + } + + return s, nil +} + +func (s *ModalBuilder) AddComponents(comps ...any) (*ModalBuilder, error) { + for _, comp := range comps { + val, _ := indirect(reflect.ValueOf(comp)) + if val.Kind() == reflect.Slice { + for i := 0; i < val.Len(); i++ { + _, err := s.addComponent(val.Index(i).Interface()) + if err != nil { + return nil, err + } + } + } else { + _, err := s.addComponent(comp) + if err != nil { + return nil, err + } + } + } + return s, nil +} + +func (s *ModalBuilder) toModal() (*discordgo.InteractionResponse, error) { + return &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + Title: s.Title, + CustomID: s.CustomID, + Components: s.Components, + }, + }, nil +} + type ComponentBuilder struct { Components []string - Values []interface{} + Values []any } -func (s *ComponentBuilder) Add(key string, value interface{}) (interface{}, error) { +func (s *ComponentBuilder) Add(key string, value any) (interface{}, error) { if len(s.Components)+1 > MaxSliceLength { return nil, errors.New("resulting slice exceeds slice size limit") } diff --git a/common/templates/context_funcs.go b/common/templates/context_funcs.go index 0c6dc1ba5c..8800c2067f 100644 --- a/common/templates/context_funcs.go +++ b/common/templates/context_funcs.go @@ -22,9 +22,10 @@ import ( ) var ( - ErrTooManyCalls = errors.New("too many calls to this function") - ErrTooManyAPICalls = errors.New("too many potential Discord API calls") - ErrRegexCacheLimit = errors.New("too many unique regular expressions (regex)") + ErrTooManyCalls = errors.New("too many calls to this function") + ErrTooManyAPICalls = errors.New("too many potential Discord API calls") + ErrFuncRemovedTemporarily = errors.New("this function is removed temporarily") + ErrRegexCacheLimit = errors.New("too many unique regular expressions (regex)") ) func (c *Context) tmplSendDM(s ...interface{}) string { @@ -327,17 +328,17 @@ func (c *Context) checkSafeDictNoRecursion(d Dict, n int) bool { return true } -func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) func(channel interface{}, msg interface{}) interface{} { +func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) func(channel any, msg any) (any, error) { - return func(channel interface{}, msg interface{}) interface{} { + return func(channel any, msg any) (any, error) { if c.IncreaseCheckGenericAPICall() { - return "" + return "", nil } sendType := sendMessageGuildChannel cid := c.ChannelArg(channel) if cid == 0 { - return "" + return "", nil } if cid != c.ChannelArgNoDM(channel) { @@ -363,10 +364,9 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun msgSend.Reference.ChannelID = cid } case *ComponentBuilder: - msgSend, _ = typedMsg.ToComplexMessage() - if msgSend.Reference != nil { - msgSend.Reference.GuildID = c.GS.ID - msgSend.Reference.ChannelID = cid + msgSend, err = typedMsg.ToComplexMessage() + if err != nil { + return "", err } default: msgSend.Content = ToString(msg) @@ -389,7 +389,11 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun msgSend.AllowedMentions = discordgo.AllowedMentions{Parse: parseMentions, RepliedUser: repliedUser} } - if msgSend.Reference != nil { + if msgSend.Reference != nil && msgSend.Reference.MessageID != 0 { + msgSend.Reference.GuildID = c.GS.ID + if msgSend.Reference.ChannelID == 0 { + msgSend.Reference.ChannelID = cid + } if msgSend.Reference.Type == discordgo.MessageReferenceTypeForward { if originChannel := c.ChannelArgNoDM(msgSend.Reference.ChannelID); originChannel != 0 { hasPerms, _ := bot.BotHasPermissionGS(c.GS, originChannel, discordgo.PermissionViewChannel|discordgo.PermissionReadMessageHistory) @@ -403,12 +407,15 @@ func (c *Context) tmplSendMessage(filterSpecialMentions bool, returnID bool) fun } m, err = common.BotSession.ChannelMessageSendComplex(cid, msgSend) + if err != nil { + return "", err + } - if err == nil && returnID { - return m.ID + if returnID { + return m.ID, nil } - return "" + return "", nil } } @@ -592,7 +599,7 @@ func (c *Context) tmplEditComponentsMessage(filterSpecialMentions bool) func(cha func (c *Context) tmplPinMessage(unpin bool) func(channel, msgID interface{}) (string, error) { return func(channel, msgID interface{}) (string, error) { - if c.IncreaseCheckCallCounter("message_pins", 5) { + if c.IncreaseCheckCallCounter("message_pins", 2) { return "", ErrTooManyCalls } @@ -1697,7 +1704,7 @@ func (c *Context) tmplGetChannelOrThread(channel interface{}) (*CtxChannel, erro func (c *Context) tmplGetChannelPins(pinCount bool) func(channel interface{}) (interface{}, error) { return func(channel interface{}) (interface{}, error) { - if c.IncreaseCheckCallCounterPremium("channel_pins", 2, 4) { + if c.IncreaseCheckCallCounterPremium("channel_pins", 1, 2) { return 0, ErrTooManyCalls } @@ -1706,21 +1713,28 @@ func (c *Context) tmplGetChannelPins(pinCount bool) func(channel interface{}) (i return 0, errors.New("unknown channel") } - msg, err := common.BotSession.ChannelMessagesPinned(cID) - if err != nil { - return 0, err + hasMore := true + var before *time.Time + msgs := make([]discordgo.Message, 0) + for hasMore { + pinned, err := common.BotSession.ChannelMessagesPinned(cID, 50, before) + if err != nil { + return 0, err + } + hasMore = pinned.HasMore + if hasMore && len(pinned.Items) > 0 { + before = &pinned.Items[len(pinned.Items)-1].PinnedAt + } + for _, item := range pinned.Items { + msgs = append(msgs, *item.Message) + } } if pinCount { - return len(msg), nil - } - - pinnedMessages := make([]discordgo.Message, 0, len(msg)) - for _, m := range msg { - pinnedMessages = append(pinnedMessages, *m) + return len(msgs), nil } - return pinnedMessages, nil + return msgs, nil } } diff --git a/common/templates/context_interactions.go b/common/templates/context_interactions.go index 3ccbbd6b61..3493b89bdd 100644 --- a/common/templates/context_interactions.go +++ b/common/templates/context_interactions.go @@ -12,6 +12,8 @@ import ( var ErrTooManyInteractionResponses = errors.New("cannot respond to an interaction > 1 time; consider using a followup") +const TemplateCustomIDPrefix = "templates-" + func interactionContextFuncs(c *Context) { c.addContextFunc("deleteInteractionResponse", c.tmplDeleteInteractionResponse) c.addContextFunc("editResponse", c.tmplEditInteractionResponse(true)) @@ -27,7 +29,26 @@ func interactionContextFuncs(c *Context) { c.addContextFunc("updateMessageNoEscape", c.tmplUpdateMessage(false)) } -func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) { +func CreateModalBuilder(customID string, title string, components ...any) (*ModalBuilder, error) { + cid, err := validateCustomID(customID, nil) + if err != nil { + return nil, err + } + modal := &ModalBuilder{ + Title: title, + CustomID: cid, + } + for _, component := range components { + _, err := modal.addComponent(component) + if err != nil { + return nil, err + } + } + + return modal, nil +} + +func CreateModal(values ...any) (*discordgo.InteractionResponse, error) { if len(values) < 1 { return &discordgo.InteractionResponse{}, errors.New("no values passed to component builder") } @@ -38,7 +59,11 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) m = t case *SDict: m = *t - case map[string]interface{}: + case ModalBuilder: + return t.toModal() + case *ModalBuilder: + return t.toModal() + case map[string]any: m = t default: dict, err := StringKeyDictionary(values...) @@ -48,14 +73,29 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) m = dict } - modal := &discordgo.InteractionResponseData{CustomID: "templates-0"} // default cID if not set + modalBuilder := &ModalBuilder{ + CustomID: TemplateCustomIDPrefix + "-0", + } + _, hasComponentsKey := m["components"] + _, hasFieldsKey := m["fields"] + if hasComponentsKey && hasFieldsKey { + return nil, errors.New("cannot have both 'components' and 'fields' in a cmodal") + } for key, val := range m { switch key { case "title": - modal.Title = ToString(val) + modalBuilder.Title = ToString(val) case "custom_id": - modal.CustomID = "templates-" + ToString(val) + cid, err := validateCustomID(ToString(val), nil) + if err != nil { + return nil, err + } + modalBuilder.CustomID = cid + case "components": + modalBuilder.Set("components", val) + + //TODO: Deprecate this key in future versions case "fields": if val == nil { continue @@ -79,7 +119,7 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) return nil, err } usedCustomIDs[field.CustomID] = true - modal.Components = append(modal.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) + modalBuilder.Components = append(modalBuilder.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) } } else { f, err := CreateComponent(discordgo.TextInputComponent, val) @@ -94,7 +134,7 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) if err != nil { return nil, err } - modal.Components = append(modal.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) + modalBuilder.Components = append(modalBuilder.Components, discordgo.ActionsRow{Components: []discordgo.InteractiveComponent{field}}) } default: return nil, errors.New(`invalid key "` + key + `" passed to send message builder`) @@ -102,10 +142,7 @@ func CreateModal(values ...interface{}) (*discordgo.InteractionResponse, error) } - return &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseModal, - Data: modal, - }, nil + return modalBuilder.toModal() } func (c *Context) tmplDeleteInteractionResponse(interactionToken, msgID interface{}, delaySeconds ...interface{}) (interface{}, error) { @@ -276,11 +313,15 @@ func (c *Context) tmplSendModal(modal interface{}) (interface{}, error) { var typedModal *discordgo.InteractionResponse var err error switch m := modal.(type) { + case ModalBuilder: + typedModal, err = m.toModal() + case *ModalBuilder: + typedModal, err = m.toModal() case *discordgo.InteractionResponse: typedModal = m case discordgo.InteractionResponse: typedModal = &m - case SDict, *SDict, map[string]interface{}: + case SDict, *SDict, map[string]any: typedModal, err = CreateModal(m) default: return "", errors.New("invalid modal passed to sendModal") diff --git a/common/templates/general.go b/common/templates/general.go index 3a5a798a79..c038f827ee 100644 --- a/common/templates/general.go +++ b/common/templates/general.go @@ -381,7 +381,7 @@ func CreateMessageSend(values ...interface{}) (*discordgo.MessageSend, error) { v, _ := indirect(reflect.ValueOf(val)) if v.Kind() == reflect.Slice { buttons := []*discordgo.Button{} - const maxButtons = 25 // Discord limitation + const maxButtons = 40 // Discord limitation for i := 0; i < v.Len() && i < maxButtons; i++ { button, err := CreateButton(v.Index(i).Interface()) if err != nil { @@ -535,6 +535,7 @@ func CreateMessageEdit(values ...interface{}) (*discordgo.MessageEdit, error) { v, _ := indirect(reflect.ValueOf(val)) if v.Kind() == reflect.Slice { const maxEmbeds = 10 // Discord limitation + msg.Embeds = make([]*discordgo.MessageEmbed, 0, maxEmbeds) for i := 0; i < v.Len() && i < maxEmbeds; i++ { embed, err := CreateEmbed(v.Index(i).Interface()) if err != nil { @@ -767,39 +768,39 @@ func in(l interface{}, v interface{}) bool { lv, _ := indirect(reflect.ValueOf(l)) vv := reflect.ValueOf(v) - if !reflect.ValueOf(vv).IsZero() { - switch lv.Kind() { - case reflect.Array, reflect.Slice: - for i := 0; i < lv.Len(); i++ { - lvv := lv.Index(i) - lvv, isNil := indirect(lvv) - if isNil { - continue + if reflect.ValueOf(vv).IsZero() { + return false + } + + switch lv.Kind() { + case reflect.String: + if vv.Type() == lv.Type() && strings.Contains(lv.String(), vv.String()) { + return true + } + case reflect.Array, reflect.Slice: + for i := range lv.Len() { + lvv := lv.Index(i) + lvv, isNil := indirect(lvv) + if isNil { + continue + } + switch { + case lvv.Kind() == reflect.String: + if vv.Type() == lvv.Type() && vv.String() == lvv.String() { + return true } - switch lvv.Kind() { - case reflect.String: - if vv.Type() == lvv.Type() && vv.String() == lvv.String() { - return true - } - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - switch vv.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - if vv.Int() == lvv.Int() { - return true - } - } - case reflect.Float32, reflect.Float64: - switch vv.Kind() { - case reflect.Float32, reflect.Float64: - if vv.Float() == lvv.Float() { - return true - } - } + case lvv.CanInt() && vv.CanInt(): + if vv.Int() == lvv.Int() { + return true + } + case lvv.CanUint() && vv.CanUint(): + if vv.Uint() == lvv.Uint() { + return true + } + case lvv.CanFloat() && vv.CanFloat(): + if vv.Float() == lvv.Float() { + return true } - } - case reflect.String: - if vv.Type() == lv.Type() && strings.Contains(lv.String(), vv.String()) { - return true } } } @@ -1335,7 +1336,7 @@ func sequence(start, stop int) ([]int, error) { } if stop-start > MaxSliceLength { - return nil, fmt.Errorf("Sequence max length is %d", MaxSliceLength) + return nil, fmt.Errorf("sequence max length is %d", MaxSliceLength) } out := make([]int, stop-start) @@ -1376,7 +1377,11 @@ func shuffle(seq interface{}) (interface{}, error) { return shuffled.Interface(), nil } -func tmplToInt(from interface{}) int { +func tmplToInt(from any, base ...int) int { + b := 10 + if len(base) > 0 { + b = base[0] + } t := reflect.ValueOf(from) switch { case t.CanInt(): @@ -1386,14 +1391,18 @@ func tmplToInt(from interface{}) int { case t.CanUint(): return int(t.Uint()) case t.Kind() == reflect.String: - parsed, _ := strconv.ParseInt(t.String(), 10, 64) + parsed, _ := strconv.ParseInt(t.String(), b, 64) return int(parsed) default: return 0 } } -func ToInt64(from interface{}) int64 { +func ToInt64(from any, base ...int) int64 { + b := 10 + if len(base) > 0 { + b = base[0] + } t := reflect.ValueOf(from) switch { case t.CanInt(): @@ -1403,7 +1412,7 @@ func ToInt64(from interface{}) int64 { case t.CanUint(): return int64(t.Uint()) case t.Kind() == reflect.String: - parsed, _ := strconv.ParseInt(t.String(), 10, 64) + parsed, _ := strconv.ParseInt(t.String(), b, 64) return parsed default: return 0 diff --git a/customcommands/assets/customcommands-database.html b/customcommands/assets/customcommands-database.html index d64ee60b0f..93d0506506 100644 --- a/customcommands/assets/customcommands-database.html +++ b/customcommands/assets/customcommands-database.html @@ -30,7 +30,7 @@

Database Usage - {{.DatabaseUsagePercent}}% Full

{{.TotalDatabaseUsage}} Entries used of total {{.TotalDatabaseCapacity}}

{{sub .TotalDatabaseCapacity .TotalDatabaseUsage}} available

Total calculated by member count multiplied by 50{{if .IsGuildPremium}}0{{end}} ({{.ActiveGuild.MemberCount}}*50{{if .IsGuildPremium}}0{{end}}).{{if not .IsGuildPremium}} Get 10 times the - database space with premium!{{end}} + database space with premium!{{end}}

diff --git a/customcommands/assets/customcommands-editcmd.html b/customcommands/assets/customcommands-editcmd.html index 483f67e563..fb44c42a71 100644 --- a/customcommands/assets/customcommands-editcmd.html +++ b/customcommands/assets/customcommands-editcmd.html @@ -14,6 +14,9 @@

Editing Custom Command #{{.CC.LocalID}}

text-decoration: line-through; color: #d2322d !important; } + #cc-responses { + flex-wrap: unset !important; + } @@ -65,16 +68,16 @@

{{if eq (call .GetCCIntervalType .CC) 1}}hour(s){{else}}minute(s){{end}}{{end}}

-
+ +
- +
-

+ No trigger: can only be triggered manually by other custom commands -

-

- Command Trigger: Works the same way as normal commands by using the - command - prefix or bot mention followed by the trigger.
-

-

+ + + Works the same way as normal commands by using the + command prefix or bot mention followed by the trigger. + + Any message that starts with the trigger will run the command. -

-

+ + Any message that contains the trigger will run the command. -

-

+ + Any message that matches the provided regex will trigger the command. -

-

+ + Any message that is equal to the trigger will run the command. -

-

+ + The command will trigger on the specified reaction events. -

-

+ + The command will run at a hourly interval, for example every 5 hours. -

-

+ + The command will run at a minute interval, for example every 10 minutes. -

-

+ + The command will run when a component (a button or a select menu) whose - custom ID matches the given regex is used. BETA FEATURE. -

-

- The command will run when a modal whose custom ID matches the given regex is submitted. BETA FEATURE. -

-

- The command will run on a crontab interval, for example 45 23 * * 6 (23:45 every Saturday). -

+ custom ID matches the given regex is used. +
+ + The command will run when a modal whose custom ID matches the given regex is submitted. + + + The command will run on a crontab interval, for example + 45 23 * * 6 (23:45 every Saturday). + + + The command will run when a role is assigned to or removed from a member, + has a cooldown of 5 minutes per user-role combination, reduced to 1 minute for premium servers +
@@ -219,7 +227,7 @@

-

Deferring a response becomes the original interaction response. Use editResponse to edit a deferred response.

+ Deferring a response becomes the original interaction response. Use editResponse to edit a deferred response. @@ -253,6 +261,41 @@

+

formaction="/manage/{{$guild}}/customcommands/commands/{{.CC.LocalID}}/delete">Delete -
+
-

Tip: Alt + Shift + S also saves the custom command

@@ -501,9 +556,9 @@

Copy Import Link

{{end}}
-
+

+ +
+
+ + +
+
+ + +
+
+
+
+ {{end}}
@@ -266,30 +302,11 @@

-

An optional name that can be used to identify the command.

-

-
-
- - {{if .User}} - - - {{else}} -
- Log in to import this CC into your server. + An optional name that can be used to identify the command.
- {{end}}
-
+
@@ -320,6 +337,26 @@

+
+
+ + {{if .User}} + + + {{else}} + + {{end}} +
+
@@ -334,16 +371,16 @@

WARNING: IMPORTING A CUSTOM COMMAND