From 08183b82d978cf5fe7f84cb3b3ba59320ce0c7f7 Mon Sep 17 00:00:00 2001 From: Riley Date: Mon, 9 Aug 2021 22:49:43 -0700 Subject: [PATCH] new argument parser --- arguments.go | 393 ++++++++++++++++++++++++++++++++++++++----------- commands.go | 53 ++++--- consts.go | 8 +- guilds.go | 33 +++-- interaction.go | 101 +++++++++++-- response.go | 79 +++++++++- util.go | 53 +++---- 7 files changed, 567 insertions(+), 153 deletions(-) diff --git a/arguments.go b/arguments.go index f8c6dc2..589c964 100644 --- a/arguments.go +++ b/arguments.go @@ -2,8 +2,10 @@ package framework import ( "errors" + "fmt" "github.com/QPixel/orderedmap" "github.com/bwmarrin/discordgo" + "github.com/dlclark/regexp2" "strconv" "strings" ) @@ -36,7 +38,10 @@ var ( Channel ArgTypeGuards = "channel" User ArgTypeGuards = "user" Role ArgTypeGuards = "role" + GuildArg ArgTypeGuards = "guild" + Message ArgTypeGuards = "message" Boolean ArgTypeGuards = "bool" + Id ArgTypeGuards = "id" SubCmd ArgTypeGuards = "subcmd" SubCmdGrp ArgTypeGuards = "subcmdgrp" ArrString ArgTypeGuards = "arrString" @@ -52,7 +57,7 @@ type ArgInfo struct { Flag bool DefaultOption string Choices []string - Regex string + Regex *regexp2.Regexp } // CommandArg @@ -113,14 +118,28 @@ func (cI *CommandInfo) AddArg(argument string, typeGuard ArgTypeGuards, match Ar Match: match, DefaultOption: defaultOption, Choices: nil, - Regex: "", + Regex: nil, }) return cI } // AddFlagArg // Adds a flag arg, which is a special type of argument +// This type of argument allows for the user to place the "phrase" (e.g: --debug) anywhere +// in the command string and the parser will find it. func (cI *CommandInfo) AddFlagArg(flag string, typeGuard ArgTypeGuards, match ArgTypes, description string, required bool, defaultOption string) *CommandInfo { + regexString := flag + if match == ArgOption { + // Currently, it only supports a limited character set. + // todo figure out how to detect any character + regexString = fmt.Sprintf("--%s (([a-zA-Z0-9:/.]+)|(\"[a-zA-Z0-9:/. ]+\"))", flag) + } else { + regexString = fmt.Sprintf("--%s", flag) + } + regex, err := regexp2.Compile(regexString, 0) + if err != nil { + log.Fatalf("Unable to create regex for flag on command %s flag: %s", cI.Trigger, flag) + } cI.Arguments.Set(flag, &ArgInfo{ Description: description, Required: required, @@ -128,7 +147,7 @@ func (cI *CommandInfo) AddFlagArg(flag string, typeGuard ArgTypeGuards, match Ar Match: match, TypeGuard: typeGuard, DefaultOption: defaultOption, - Regex: "", + Regex: regex, }) return cI } @@ -153,87 +172,61 @@ func (cI *CommandInfo) SetTyping(isTyping bool) *CommandInfo { return cI } +//todo subcommand stuff +//// BindToChoice +//// Bind an arg to choice (subcmd) +//func (cI *CommandInfo) BindToChoice(arg string, choice string) { +// +//} + +// CreateAppOptSt +// Creates an ApplicationOptionsStruct for all the args. +func (cI *CommandInfo) CreateAppOptSt() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{} +} + // -- Argument Parser -- // ParseArguments -// Parses the arguments into a pointer to an Arguments struct +// Version two of the argument parser func ParseArguments(args string, infoArgs *orderedmap.OrderedMap) *Arguments { ar := make(Arguments) + if args == "" || len(infoArgs.Keys()) < 1 { return &ar } // Split string on spaces to get every "phrase" - splitString := strings.Split(args, " ") - - // Current Position in the infoArgs map - currentPos := 0 + // bool to parse content strings + moreContent := false // Keys of infoArgs k := infoArgs.Keys() + var modK []string + // First find all flags in the string. + splitString, ar, modK := findAllFlags(args, k, infoArgs, &ar) + // Find all the option args (e.g. single 'phrases' or quoted strings) + // Then return the currentPos, so we can index k and find remaining keys. + // Also return a modified Arguments struct - for i := 0; i < len(splitString); i++ { - for n := currentPos; n <= len(k); n++ { - if n > len(k)+1 || currentPos+1 > len(k) { - break - } - v, _ := infoArgs.Get(k[currentPos]) - vv := v.(*ArgInfo) - switch vv.Match { - case ArgOption: - // Lets first check the typeguard to see if the str matches the arg - if checkTypeGuard(splitString[i], vv.TypeGuard) { - // todo abstract this into handleArgOption - // Handle quoted ArgOptions separately - if strings.Contains(splitString[i], "\"") { - st := CommandArg{} - st, i = handleQuotedString(splitString, *vv, i) - ar[k[currentPos]] = st - currentPos++ - break - } - // Handle ArgOption - ar[k[currentPos]] = handleArgOption(splitString[i], *vv) - currentPos++ - break - } - if n+1 > len(splitString) { - break - } - // If the TypeGuard does not match check to see if the Arg is required or not - if vv.Required { - // Set the CommandArg to the default option, which is usually "" - ar[k[currentPos]] = CommandArg{ - info: *vv, - Value: vv.DefaultOption, - } - currentPos++ - break - } else { - // If it's not required, we set the CommandArg to "" - ar[k[currentPos]] = CommandArg{ - info: *vv, - Value: "", - } - currentPos++ - break - } - case ArgContent: - // Takes the splitString and currentPos to find how many more elements in the slice - // need to join together - contentString := "" - contentString, i = createContentString(splitString, i) - ar[k[currentPos]] = CommandArg{ - info: *vv, - Value: contentString, - } - break - default: - break - } - continue + ar, moreContent, splitString, modK = findAllOptionArgs(splitString, modK, infoArgs, &ar) + + // If there is more content, lets find it + if moreContent == true { + v, ok := infoArgs.Get(modK[0]) + if !ok { + return &ar } + vv := v.(*ArgInfo) + commandContent, _ := createContentString(splitString, 0) + ar[modK[0]] = CommandArg{ + info: *vv, + Value: commandContent, + } + return &ar + // Else return the args struct + } else { + return &ar } - return &ar } /* Argument Parsing Helpers */ @@ -247,22 +240,254 @@ func createContentString(splitString []string, currentPos int) (string, int) { return strings.TrimSuffix(str, " "), currentPos } -func handleQuotedString(splitString []string, argInfo ArgInfo, currentPos int) (CommandArg, int) { - str := "" - splitString[currentPos] = strings.TrimPrefix(splitString[currentPos], "\"") - for i := currentPos; i < len(splitString); i++ { - if !strings.HasSuffix(splitString[i], "\"") { - str += splitString[i] + " " +// Finds all the 'option' type args +func findAllOptionArgs(argString []string, keys []string, infoArgs *orderedmap.OrderedMap, args *Arguments) (Arguments, bool, []string, []string) { + if len(keys) == 0 || keys == nil { + return *args, false, []string{}, []string{} + } + modifiedArgString := "" + var modKeys []string + var indexes []int + // If the length of the argString is equal to the length of keys + // We can just set each value in argString to a CommandArg + // If a match field is equal to the content we will return early. + // This is a rare occurrence. But this allows for a faster result + if len(argString) == len(keys) { + for i, v := range argString { + // error handling + iA, ok := infoArgs.Get(keys[i]) + if !ok { + err := errors.New(fmt.Sprintf("Unable to find map relating to key: %s", keys[i])) + SendErrorReport("", "", "", "Argument Parsing error", err) + continue + } + vv := iA.(*ArgInfo) + // ArgContent type should always be the last item in the slice + // Should be safe to return early + if vv.Match == ArgContent { + modifiedArgString = strings.Join(argString[i:], " ") + modKeys = RemoveItems(keys, indexes) + return *args, true, createSplitString(modifiedArgString), modKeys + } + if checkTypeGuard(v, vv.TypeGuard) { + (*args)[keys[i]] = handleArgOption(v, *vv) + indexes = append(indexes, i) + } + } + return *args, false, createSplitString(modifiedArgString), modKeys + } + // (semi) Brute force method + // First lets find all required args + currentPos := 0 + for i, v := range keys { + // error handling + iA, ok := infoArgs.Get(v) + if !ok { + err := errors.New(fmt.Sprintf("Unable to find map relating to key: %s", keys[i])) + SendErrorReport("", "", "", "Argument Parsing error", err) + continue + } + vv := iA.(*ArgInfo) + if vv.Required { + if vv.TypeGuard != String { + var value string + value, argString = findTypeGuard(strings.Join(argString, " "), argString, vv.TypeGuard) + (*args)[v] = handleArgOption(value, *vv) + indexes = append(indexes, i) + } else if checkTypeGuard(argString[currentPos], vv.TypeGuard) { + (*args)[v] = handleArgOption(argString[currentPos], *vv) + currentPos++ + indexes = append(indexes, i) + } else { + (*args)[v] = handleArgOption(vv.DefaultOption, *vv) + indexes = append(indexes, i) + continue + } } else { - str += strings.TrimSuffix(splitString[i], "\"") - currentPos = i break } } - return CommandArg{ - info: argInfo, - Value: str, - }, currentPos + // Remove already found keys and clear the index list + // We also reset some values that we reuse + //if + modKeys = RemoveItems(keys, indexes) + argString = argString[currentPos:] + indexes = nil + currentPos = 0 + // Now lets find the not required args + for _, v := range modKeys { + // error handling + iA, ok := infoArgs.Get(v) + if !ok { + err := errors.New(fmt.Sprintf("Unable to find map relating to key: %s", v)) + SendErrorReport("", "", "", "Argument Parsing error", err) + continue + } + vv := iA.(*ArgInfo) + // If we find an arg that is required send an error and return + if vv.Required { + err := errors.New(fmt.Sprintf("Found a required arg where there is supposed to be none %s", v)) + SendErrorReport("", "", "", "Argument Parsing error", err) + break + } + if vv.Match == ArgContent { + modifiedArgString = strings.Join(argString, " ") + return *args, true, createSplitString(modifiedArgString), modKeys + } + // Break early if current pos is the length of the array + if currentPos == len(argString) { + break + } + if vv.TypeGuard != String { + var value string + value, argString = findTypeGuard(strings.Join(argString, " "), argString, vv.TypeGuard) + (*args)[v] = handleArgOption(value, *vv) + } else if checkTypeGuard(argString[currentPos], vv.TypeGuard) { + (*args)[v] = handleArgOption(argString[currentPos], *vv) + currentPos++ + } else { + + } + } + // + return *args, false, createSplitString(modifiedArgString), modKeys +} + +func findTypeGuard(input string, array []string, typeguard ArgTypeGuards) (string, []string) { + switch typeguard { + case Int: + if match, isMatch := Misc["int"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + case Channel: + if match, isMatch := MentionStringRegexes["channel"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } else if match, isMatch := MentionStringRegexes["id"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + case Role: + if match, isMatch := MentionStringRegexes["role"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } else if match, isMatch := MentionStringRegexes["id"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + case User: + if match, isMatch := MentionStringRegexes["user"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } else if match, isMatch := MentionStringRegexes["id"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + case ArrString: + if match, isMatch := TypeGuard["arrString"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + case Message: + if match, isMatch := TypeGuard["message_url"].FindStringMatch(input); isMatch == nil && match != nil { + return match.String(), RemoveItem(array, match.String()) + } + return "", array + } + return "", array +} + +func findAllFlags(argString string, keys []string, infoArgs *orderedmap.OrderedMap, args *Arguments) ([]string, Arguments, []string) { + modifiedArgString := argString + var indexes []int + var modKeys []string + for index, a := range keys { + v, _ := infoArgs.Get(a) + vv := v.(*ArgInfo) + // Skip because the argument has no flag + if !vv.Flag { + continue + } + // Use the compiled regex to search the arg string for a matching result. + match, err := vv.Regex.FindStringMatch(argString) + // Error handling/no match + if err != nil || match == nil { + if vv.Match == ArgOption { + (*args)[a] = handleArgOption(vv.DefaultOption, *vv) + } else { + (*args)[a] = CommandArg{info: *vv, Value: "false"} + } + // Set the modified arg string to the mod string + indexes = append(indexes, index) + continue + } + + // Check to see if the flag is a string 'option' or a boolean 'flag' + if vv.Match == ArgOption { + val := strings.Trim(strings.SplitN(match.String(), " ", 2)[1], "\"") + if checkTypeGuard(val, vv.TypeGuard) { + (*args)[a] = handleArgOption(val, *vv) + } + } else if vv.Match == ArgFlag { + (*args)[a] = CommandArg{info: *vv, Value: "true"} + } // todo figure out if indexes need to put an else statement here + + // Replace all reference to the flag in the string. + modString, err := vv.Regex.Replace(modifiedArgString, "", -1, -1) + if err != nil { + continue + } + // Set the modified arg string to the mod string + modifiedArgString = modString + indexes = append(indexes, index) + } + if len(indexes) > 0 { + // set keys to nil if flags have already gotten all the args + if len(indexes) == len(keys) { + modKeys = nil + return []string{}, *args, keys + } + modKeys = RemoveItems(keys, indexes) + } + if modifiedArgString == "" { + modifiedArgString = argString + } + if len(modKeys) == 0 || modKeys == nil { + modKeys = keys + } + return createSplitString(modifiedArgString), *args, modKeys +} + +// Creates a "split" string (array of strings that is split off of spaces +func createSplitString(argString string) []string { + splitStr := strings.SplitAfter(argString, " ") + var newSplitStr []string + quotedStringBuffer := "" + isQuotedString := false + for _, v := range splitStr { + if v == "" || v == " " { + continue + } + // Checks to see if the string is a quoted argument. + // If so, it will combine it into one string + if strings.Contains(v, "\"") || isQuotedString { + if strings.HasSuffix(strings.Trim(v, " "), "\"") { + // Trim quotes and trim space suffix + quotedStringBuffer = strings.TrimSuffix(strings.Trim(quotedStringBuffer+strings.Trim(v, " "), "\""), " ") + newSplitStr = append(newSplitStr, quotedStringBuffer) + + isQuotedString = false + quotedStringBuffer = "" + continue + } + isQuotedString = true + quotedStringBuffer = quotedStringBuffer + v + continue + } else { + // If the string suffix contains a whitespace character, we need to remove that + v = strings.TrimSuffix(v, " ") + newSplitStr = append(newSplitStr, v) + } + } + return newSplitStr } func handleArgOption(str string, info ArgInfo) CommandArg { @@ -309,8 +534,12 @@ func checkTypeGuard(str string, typeguard ArgTypeGuards) bool { return true } return false + case Message: + if isMatch, _ := TypeGuard["message_url"].MatchString(str); isMatch { + return true + } + return false } - return false } diff --git a/commands.go b/commands.go index dd4e307..9806d10 100644 --- a/commands.go +++ b/commands.go @@ -2,19 +2,18 @@ package framework import ( "github.com/QPixel/orderedmap" + "runtime" "strings" "github.com/bwmarrin/discordgo" ) -// TODO Clean up this file // commands.go // This file contains everything required to add core commands to the bot, and parse commands from a message // GroupTypes const ( Moderation = "moderation" - Module = "module" Utility = "utility" ) @@ -26,8 +25,8 @@ type CommandInfo struct { Description string // A short description of what the command does Group string // The group this command belongs to ParentID string // The ID of the parent command - Public bool // Whether or not non-admins and non-mods can use this command - IsTyping bool // Whether or not the command will show a typing thing when ran. + Public bool // Whether non-admins and non-mods can use this command + IsTyping bool // Whether the command will show a typing thing when ran. IsParent bool // If the command is the parent of a subcommand tree IsChild bool // If the command is the child Trigger string // The string that will trigger the command @@ -46,7 +45,7 @@ type Context struct { // BotFunction // This type defines the functions that are called when commands are triggered -// Contexts are also passed as pointers so they are not re-allocated when passed through +// Contexts are also passed as pointers, so they are not re-allocated when passed through type BotFunction func(ctx *Context) // Command @@ -56,6 +55,8 @@ type Command struct { Function BotFunction } +// ChildCommand +// Defines how child commands are stored type ChildCommand map[string]map[string]Command // CustomCommand @@ -63,16 +64,16 @@ type ChildCommand map[string]map[string]Command type CustomCommand struct { Content string // The content of the custom command. Custom commands are just special strings after all InvokeCount int64 // How many times the command has been invoked; int64 for easier use with json - Public bool // Whether or not non-admins and non-mods can use this command + Public bool // Whether non-admins and non-mods can use this command } // commands -// All of the registered core commands (not custom commands) +// All the registered core commands (not custom commands) // This is private so that other commands cannot modify it var commands = make(map[string]Command) // childCommands -// All of the registered childcommands (subcmdgrps) +// All the registered ChildCommands (SubCmdGrps) // This is private so other commands cannot modify it var childCommands = make(ChildCommand) @@ -81,7 +82,7 @@ var childCommands = make(ChildCommand) var commandAliases = make(map[string]string) // slashCommands -// All of the registered core commands that are also slash commands +// All the registered core commands that are also slash commands // This is also private so other commands cannot modify it var slashCommands = make(map[string]discordgo.ApplicationCommand) @@ -128,8 +129,16 @@ func AddChildCommand(info *CommandInfo, function BotFunction) { // Adds a slash command to the bot // Allows for separation between normal commands and slash commands func AddSlashCommand(info *CommandInfo) { - s := createSlashCommandStruct(info) - slashCommands[strings.ToLower(info.Trigger)] = *s + if !info.IsParent || !info.IsChild { + s := createSlashCommandStruct(info) + slashCommands[strings.ToLower(info.Trigger)] = *s + return + } + if info.IsParent { + s := createSlashSubCmdStruct(info, childCommands[info.Trigger]) + slashCommands[strings.ToLower(info.Trigger)] = *s + return + } } // AddSlashCommands @@ -176,12 +185,6 @@ func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate } } - // If we are in DMs, ignore the message - // In the future, this can be used to handle special DM-only commands - if channel.Type == discordgo.ChannelTypeDM { - return - } - // Ignore messages sent by the bot if message.Author.ID == session.State.User.ID { return @@ -250,6 +253,7 @@ func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate if command.Info.IsTyping && g.Info.ResponseChannelId == "" { _ = Session.ChannelTyping(message.ChannelID) } + defer handleCommandError(g.ID, channel.ID, message.Author.ID) if command.Info.IsParent { handleChildCommand(*argString, command, message.Message, g) return @@ -312,3 +316,18 @@ func handleChildCommand(argString string, command Command, message *discordgo.Me }) return } + +func handleCommandError(gID string, cId string, uId string) { + if r := recover(); r != nil { + log.Warningf("Recovering from panic: %s", r) + log.Warningf("Sending Error report to admins") + SendErrorReport(gID, cId, uId, "Error!", r.(runtime.Error)) + message, err := Session.ChannelMessageSend(cId, "Error!") + if err != nil { + log.Errorf("err sending message %s", err) + } + _ = Session.ChannelMessageDelete(cId, message.ID) + return + } + return +} diff --git a/consts.go b/consts.go index 7b987a1..960fa52 100644 --- a/consts.go +++ b/consts.go @@ -20,5 +20,11 @@ var ( "channel": regexp2.MustCompile("<((#?\\d+))>", 0), "id": regexp2.MustCompile("^[0-9]{18}$", 0), } - TypeGuard = regex{} + TypeGuard = regex{ + "message_url": regexp2.MustCompile("((https:\\/\\/canary.discord.com\\/channels\\/)+([0-9]{18})\\/+([0-9]{18})\\/+([0-9]{18})$)", regexp2.IgnoreCase|regexp2.Multiline), + } + Misc = regex{ + "quoted_string": regexp2.MustCompile("^\"[a-zA-Z0-9]+\"$", 0), + "int": regexp2.MustCompile("\\b(0*(?:[0-9]{1,8}))\\b", 0), + } ) diff --git a/guilds.go b/guilds.go index 27827f4..e8fff75 100644 --- a/guilds.go +++ b/guilds.go @@ -17,7 +17,6 @@ import ( type GuildInfo struct { AddedDate int64 `json:"addedDate"` // The date the bot was added to the server Prefix string `json:"prefix"` // The bot prefix - GuildLanguage string `json:"guildLanguage"` // The guilds language ModeratorIds []string `json:"moderatorIds"` // The list of user/role IDs allowed to run mod-only commands WhitelistIds []string `json:"whitelistIds"` // List of user/role Ids that a user MUST have one of in order to run any commands, including public ones IgnoredIds []string `json:"ignoredIds"` // List of user/role IDs that can never run commands, even public ones @@ -59,6 +58,30 @@ var muteLock = make(map[string]*sync.Mutex) // If the guild doesn't exist, initialize a new guild and save it before returning // Return a pointer to the guild object and pass that around instead, to avoid information desync func getGuild(guildId string) *Guild { + // The command is being ran as a dm, send back an empty guild object with default fields + if guildId == "" { + return &Guild{ + ID: "", + Info: GuildInfo{ + AddedDate: time.Now().Unix(), + Prefix: "!", + DeletePolicy: false, + ResponseChannelId: "", + MuteRoleId: "", + GlobalDisabledTriggers: nil, + ChannelDisabledTriggers: make(map[string][]string), + CustomCommands: make(map[string]CustomCommand), + ModeratorIds: nil, + IgnoredIds: nil, + BannedWordDetector: false, + GuildBannedWords: nil, + BannedWordDetectorRoles: nil, + BannedWordDetectorChannels: nil, + MutedUsers: make(map[string]int64), + Storage: make(map[string]interface{}), + }, + } + } if guild, ok := Guilds[guildId]; ok { return guild } else { @@ -67,7 +90,6 @@ func getGuild(guildId string) *Guild { ID: guildId, Info: GuildInfo{ AddedDate: time.Now().Unix(), - GuildLanguage: "en", Prefix: "!", DeletePolicy: false, ResponseChannelId: "", @@ -255,13 +277,6 @@ func (g *Guild) SetPrefix(newPrefix string) { g.save() } -// SetLang -// Set the prefix, then save the guild data -func (g *Guild) SetLang(lang string) { - g.Info.GuildLanguage = lang - g.save() -} - // IsMod // Check if a given ID is a moderator or not func (g *Guild) IsMod(checkId string) bool { diff --git a/interaction.go b/interaction.go index bbcc08b..6f6f6a8 100644 --- a/interaction.go +++ b/interaction.go @@ -1,20 +1,16 @@ package framework import ( + "fmt" "github.com/bwmarrin/discordgo" + "runtime" ) -// -//// TODO clean up this file and move interaction specific functions here -// -//import ( -// "github.com/bwmarrin/discordgo" -// "strings" -//) -// -//// slashCommandTypes -//// A map of *short hand* slash commands types to their discordgo counterparts -//// TODO move this over to interaction.go +// -- Types and Structs -- + +// slashCommandTypes +// A map of *short hand* slash commands types to their discordgo counterparts +// TODO move this over to interaction.go var slashCommandTypes = map[ArgTypeGuards]discordgo.ApplicationCommandOptionType{ Int: discordgo.ApplicationCommandOptionInteger, String: discordgo.ApplicationCommandOptionString, @@ -26,6 +22,8 @@ var slashCommandTypes = map[ArgTypeGuards]discordgo.ApplicationCommandOptionType //SubCmdGrp: discordgo.ApplicationCommandOptionSubCommandGroup, } +//var componentHandlers + // // getSlashCommandStruct // Creates a slash command struct @@ -66,6 +64,29 @@ func createSlashCommandStruct(info *CommandInfo) (st *discordgo.ApplicationComma return } +// Creates a slash subcmd struct +func createSlashSubCmdStruct(info *CommandInfo, childCmds map[string]Command) (st *discordgo.ApplicationCommand) { + st = &discordgo.ApplicationCommand{ + Name: info.Trigger, + Description: info.Description, + Options: make([]*discordgo.ApplicationCommandOption, len(childCmds)), + } + currentPos := 0 + for _, v := range childCmds { + // Stupid inline thing + if ar, _ := v.Info.Arguments.Get(v.Info.Arguments.Keys()[0]); ar.(*ArgInfo).TypeGuard == SubCmdGrp { + + } else { + //Pixel: + //Yes I know this is O(N^2). Most likely I could get something better + //todo: refactor so this isn't as bad for performance + st.Options[currentPos] = v.Info.CreateAppOptSt() + currentPos++ + } + } + return st +} + // -- Interaction Handlers -- // handleInteraction @@ -84,6 +105,39 @@ func handleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { // handleInteractionCommand // Handles a slash command func handleInteractionCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.ApplicationCommandData().Name == "rickroll-em" { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Operation rickroll has begun", + Flags: 1 << 6, + }, + }) + if err != nil { + panic(err) + } + + ch, err := s.UserChannelCreate( + i.ApplicationCommandData().TargetID, + ) + if err != nil { + _, err = s.FollowupMessageCreate(Session.State.User.ID, i.Interaction, true, &discordgo.WebhookParams{ + Content: fmt.Sprintf("Mission failed. Cannot send a message to this user: %q", err.Error()), + Flags: 1 << 6, + }) + if err != nil { + panic(err) + } + } + _, err = s.ChannelMessageSend( + ch.ID, + fmt.Sprintf("%s sent you this: https://youtu.be/dQw4w9WgXcQ", i.Member.Mention()), + ) + if err != nil { + panic(err) + } + return + } g := getGuild(i.GuildID) if g.Info.DeletePolicy { @@ -121,6 +175,7 @@ func handleInteractionCommand(s *discordgo.Session, i *discordgo.InteractionCrea if IsAdmin(i.Member.User.ID) || command.Info.Public || g.IsMod(i.Member.User.ID) { // Check if the command is public, or if the current user is a bot moderator // Bot admins supercede both checks + defer handleSlashCommandError(*i.Interaction) command.Function(&Context{ Guild: g, Cmd: command.Info, @@ -204,3 +259,27 @@ func RemoveGuildSlashCommands(guildID string) { } } } + +func handleSlashCommandError(i discordgo.Interaction) { + if r := recover(); r != nil { + log.Warningf("Recovering from panic: %s", r) + log.Warningf("Sending Error report to admins") + SendErrorReport(i.GuildID, i.ChannelID, i.Member.User.ID, "Error!", r.(runtime.Error)) + message, err := Session.InteractionResponseEdit(Session.State.User.ID, &i, &discordgo.WebhookEdit{ + Content: "error executing command", + }) + if err != nil { + Session.InteractionRespond(&i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: 1 << 6, + Content: "error executing command", + }, + }) + log.Errorf("err sending message %s", err) + } + Session.ChannelMessageDelete(i.ChannelID, message.ID) + return + } + return +} diff --git a/response.go b/response.go index 05a6807..ab686e3 100644 --- a/response.go +++ b/response.go @@ -1,6 +1,7 @@ package framework import ( + "fmt" "time" "github.com/bwmarrin/discordgo" @@ -24,6 +25,7 @@ type Response struct { Success bool Loading bool Ephemeral bool + Reply bool Embed *discordgo.MessageEmbed ResponseComponents *ResponseComponents } @@ -71,6 +73,7 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response }, Loading: ctx.Cmd.IsTyping, Ephemeral: ephemeral, + Reply: ephemeral, } if messageComponents { r.ResponseComponents.Components = CreateComponentFields() @@ -90,8 +93,74 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, }) } - } + // If the command context is not empty, append the command + if ctx.Cmd.Trigger != "" { + // Get the command used as a string, and all interpreted arguments, so it can be a part of the output + commandUsed := "" + if r.Ctx.Cmd.IsChild { + commandUsed = fmt.Sprintf("%s%s %s", r.Ctx.Guild.Info.Prefix, r.Ctx.Cmd.ParentID, r.Ctx.Cmd.Trigger) + } else { + commandUsed = r.Ctx.Guild.Info.Prefix + r.Ctx.Cmd.Trigger + } + // Just makes the thing prettier + if ctx.Interaction != nil { + commandUsed = "/" + r.Ctx.Cmd.Trigger + } + for _, k := range r.Ctx.Cmd.Arguments.Keys() { + arg := ctx.Args[k] + if arg.StringValue() == "" { + continue + } + vv, ok := r.Ctx.Cmd.Arguments.Get(k) + + if ok { + argInfo := vv.(*ArgInfo) + switch argInfo.TypeGuard { + case Int: + fallthrough + case Boolean: + fallthrough + case String: + commandUsed += " " + arg.StringValue() + break + case User: + user, err := arg.UserValue(Session) + if err != nil { + commandUsed += " " + arg.StringValue() + } else { + commandUsed += " " + user.Mention() + } + case Role: + role, err := arg.RoleValue(Session, r.Ctx.Guild.ID) + if err != nil { + commandUsed += " " + arg.StringValue() + } else { + commandUsed += " " + role.Mention() + } + case Channel: + channel, err := arg.ChannelValue(Session) + if err != nil { + commandUsed += " " + arg.StringValue() + } else { + commandUsed += " " + channel.Mention() + } + } + } else { + commandUsed += " " + arg.StringValue() + } + } + + commandUsed = "```\n" + commandUsed + "\n```" + + r.AppendField("Command used:", commandUsed, false) + } + + // If the message is not nil, append an invoker + if ctx.Message != nil { + r.AppendField("Invoked by:", r.Ctx.Message.Author.Mention(), false) + } + return r } @@ -317,7 +386,7 @@ func (r *Response) Send(success bool, title string, description string) { Embed: r.Embed, Components: r.ResponseComponents.Components, }) - if err != nil { + if err != nil && r.Reply { // Reply to user if no output channel _, err = ReplyToUser(r.Ctx.Message.ChannelID, &discordgo.MessageSend{ Embed: r.Embed, @@ -334,6 +403,12 @@ func (r *Response) Send(success bool, title string, description string) { if err != nil { SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Message.ChannelID, r.Ctx.Message.Author.ID, "Ultimately failed to send bot response", err) } + } else if !r.Reply { + // If the command does not want to reply lets just send it to the channel the command was invoked + _, err = Session.ChannelMessageSendComplex(r.Ctx.Message.ChannelID, &discordgo.MessageSend{ + Embed: r.Embed, + Components: r.ResponseComponents.Components, + }) } } diff --git a/util.go b/util.go index fc76e1e..3d7a04b 100644 --- a/util.go +++ b/util.go @@ -3,12 +3,12 @@ package framework import ( "errors" "github.com/bwmarrin/discordgo" - "github.com/dlclark/regexp2" + "regexp" "strconv" "strings" ) -// language.go +// util.go // This file contains utility functions, simplifying redundant tasks // RemoveItem @@ -23,45 +23,43 @@ func RemoveItem(slice []string, delete string) []string { return newSlice } +// RemoveItems +// Removes items from a slice by index +func RemoveItems(slice []string, indexes []int) []string { + newSlice := make([]string, len(slice)) + copy(newSlice, slice) + for _, v := range indexes { + newSlice[v] = newSlice[len(newSlice)-1] + newSlice[len(newSlice)-1] = "" + newSlice = newSlice[:len(newSlice)-1] + } + return newSlice +} + // EnsureNumbers // Given a string, ensure it contains only numbers // This is useful for stripping letters and formatting characters from user/role pings func EnsureNumbers(in string) string { - reg, err := regexp2.Compile("[^0-9]+", 0) + reg, err := regexp.Compile("[^0-9]+") if err != nil { log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) return "" } - if ok, _ := reg.MatchString(in); ok { - str, err := reg.Replace(in, "", -1, -1) - if err != nil { - log.Errorf("Unable to replace text in EnsureNumbers") - return "" - } - return str - } - return in + + return reg.ReplaceAllString(in, "") } // EnsureLetters // Given a string, ensure it contains only letters // This is useful for stripping numbers from mute durations, and possibly other things func EnsureLetters(in string) string { - reg, err := regexp2.Compile("[^a-zA-Z]+", 0) + reg, err := regexp.Compile("[^a-zA-Z]+") if err != nil { log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) return "" } - if ok, _ := reg.MatchString(in); ok { - str, err := reg.Replace(in, "", -1, -1) - if err != nil { - log.Errorf("Unable to replace text in EnsureLetters") - return "" - } - return str - } - return in + return reg.ReplaceAllString(in, "") } // CleanId @@ -140,15 +138,8 @@ func ExtractCommand(guild *GuildInfo, message string) (*string, *string) { if content == "" { return nil, nil } - // Attempt to pull the trigger out of the command content by splitting on spaces - trigger := strings.Fields(content)[0] - - // With the trigger identified, split the command content on the trigger to obtain everything BUT the trigger - // Ensure only 2 fields are returned so it can be split further. Then, get only the second field + trigger := strings.ToLower(strings.Fields(content)[0]) fullArgs := strings.SplitN(content, trigger, 2)[1] - fullArgs = strings.TrimPrefix(fullArgs, " ") - // Avoids issues with strings that are case sensitive - trigger = strings.ToLower(trigger) return &trigger, &fullArgs } else { return nil, nil @@ -190,7 +181,7 @@ func SendErrorReport(guildId string, channelId string, userId string, title stri log.Errorf("[REPORT] %s (%s)", title, err) // Iterate through all the admins - for admin, _ := range botAdmins { + for admin := range botAdmins { // Get the channel ID of the user to DM dmChannel, dmCreateErr := Session.UserChannelCreate(admin)