new argument parser

This commit is contained in:
vel 2021-08-09 22:49:43 -07:00
parent 98d784a5bd
commit 08183b82d9
Signed by: velvox
GPG Key ID: 1C8200C1D689CEF5
7 changed files with 567 additions and 153 deletions

View File

@ -2,8 +2,10 @@ package framework
import ( import (
"errors" "errors"
"fmt"
"github.com/QPixel/orderedmap" "github.com/QPixel/orderedmap"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/dlclark/regexp2"
"strconv" "strconv"
"strings" "strings"
) )
@ -36,7 +38,10 @@ var (
Channel ArgTypeGuards = "channel" Channel ArgTypeGuards = "channel"
User ArgTypeGuards = "user" User ArgTypeGuards = "user"
Role ArgTypeGuards = "role" Role ArgTypeGuards = "role"
GuildArg ArgTypeGuards = "guild"
Message ArgTypeGuards = "message"
Boolean ArgTypeGuards = "bool" Boolean ArgTypeGuards = "bool"
Id ArgTypeGuards = "id"
SubCmd ArgTypeGuards = "subcmd" SubCmd ArgTypeGuards = "subcmd"
SubCmdGrp ArgTypeGuards = "subcmdgrp" SubCmdGrp ArgTypeGuards = "subcmdgrp"
ArrString ArgTypeGuards = "arrString" ArrString ArgTypeGuards = "arrString"
@ -52,7 +57,7 @@ type ArgInfo struct {
Flag bool Flag bool
DefaultOption string DefaultOption string
Choices []string Choices []string
Regex string Regex *regexp2.Regexp
} }
// CommandArg // CommandArg
@ -113,14 +118,28 @@ func (cI *CommandInfo) AddArg(argument string, typeGuard ArgTypeGuards, match Ar
Match: match, Match: match,
DefaultOption: defaultOption, DefaultOption: defaultOption,
Choices: nil, Choices: nil,
Regex: "", Regex: nil,
}) })
return cI return cI
} }
// AddFlagArg // AddFlagArg
// Adds a flag arg, which is a special type of argument // 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 { 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{ cI.Arguments.Set(flag, &ArgInfo{
Description: description, Description: description,
Required: required, Required: required,
@ -128,7 +147,7 @@ func (cI *CommandInfo) AddFlagArg(flag string, typeGuard ArgTypeGuards, match Ar
Match: match, Match: match,
TypeGuard: typeGuard, TypeGuard: typeGuard,
DefaultOption: defaultOption, DefaultOption: defaultOption,
Regex: "", Regex: regex,
}) })
return cI return cI
} }
@ -153,87 +172,61 @@ func (cI *CommandInfo) SetTyping(isTyping bool) *CommandInfo {
return cI 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 -- // -- Argument Parser --
// ParseArguments // 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 { func ParseArguments(args string, infoArgs *orderedmap.OrderedMap) *Arguments {
ar := make(Arguments) ar := make(Arguments)
if args == "" || len(infoArgs.Keys()) < 1 { if args == "" || len(infoArgs.Keys()) < 1 {
return &ar return &ar
} }
// Split string on spaces to get every "phrase" // 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 // Keys of infoArgs
k := infoArgs.Keys() 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++ { ar, moreContent, splitString, modK = findAllOptionArgs(splitString, modK, infoArgs, &ar)
for n := currentPos; n <= len(k); n++ {
if n > len(k)+1 || currentPos+1 > len(k) { // If there is more content, lets find it
break if moreContent == true {
} v, ok := infoArgs.Get(modK[0])
v, _ := infoArgs.Get(k[currentPos]) if !ok {
vv := v.(*ArgInfo) return &ar
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
} }
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 */ /* Argument Parsing Helpers */
@ -247,22 +240,254 @@ func createContentString(splitString []string, currentPos int) (string, int) {
return strings.TrimSuffix(str, " "), currentPos return strings.TrimSuffix(str, " "), currentPos
} }
func handleQuotedString(splitString []string, argInfo ArgInfo, currentPos int) (CommandArg, int) { // Finds all the 'option' type args
str := "" func findAllOptionArgs(argString []string, keys []string, infoArgs *orderedmap.OrderedMap, args *Arguments) (Arguments, bool, []string, []string) {
splitString[currentPos] = strings.TrimPrefix(splitString[currentPos], "\"") if len(keys) == 0 || keys == nil {
for i := currentPos; i < len(splitString); i++ { return *args, false, []string{}, []string{}
if !strings.HasSuffix(splitString[i], "\"") { }
str += splitString[i] + " " 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 { } else {
str += strings.TrimSuffix(splitString[i], "\"")
currentPos = i
break break
} }
} }
return CommandArg{ // Remove already found keys and clear the index list
info: argInfo, // We also reset some values that we reuse
Value: str, //if
}, currentPos 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 { func handleArgOption(str string, info ArgInfo) CommandArg {
@ -309,8 +534,12 @@ func checkTypeGuard(str string, typeguard ArgTypeGuards) bool {
return true return true
} }
return false return false
case Message:
if isMatch, _ := TypeGuard["message_url"].MatchString(str); isMatch {
return true
}
return false
} }
return false return false
} }

View File

@ -2,19 +2,18 @@ package framework
import ( import (
"github.com/QPixel/orderedmap" "github.com/QPixel/orderedmap"
"runtime"
"strings" "strings"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
// TODO Clean up this file
// commands.go // commands.go
// This file contains everything required to add core commands to the bot, and parse commands from a message // This file contains everything required to add core commands to the bot, and parse commands from a message
// GroupTypes // GroupTypes
const ( const (
Moderation = "moderation" Moderation = "moderation"
Module = "module"
Utility = "utility" Utility = "utility"
) )
@ -26,8 +25,8 @@ type CommandInfo struct {
Description string // A short description of what the command does Description string // A short description of what the command does
Group string // The group this command belongs to Group string // The group this command belongs to
ParentID string // The ID of the parent command ParentID string // The ID of the parent command
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
IsTyping bool // Whether or not the command will show a typing thing when ran. IsTyping bool // Whether the command will show a typing thing when ran.
IsParent bool // If the command is the parent of a subcommand tree IsParent bool // If the command is the parent of a subcommand tree
IsChild bool // If the command is the child IsChild bool // If the command is the child
Trigger string // The string that will trigger the command Trigger string // The string that will trigger the command
@ -46,7 +45,7 @@ type Context struct {
// BotFunction // BotFunction
// This type defines the functions that are called when commands are triggered // 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) type BotFunction func(ctx *Context)
// Command // Command
@ -56,6 +55,8 @@ type Command struct {
Function BotFunction Function BotFunction
} }
// ChildCommand
// Defines how child commands are stored
type ChildCommand map[string]map[string]Command type ChildCommand map[string]map[string]Command
// CustomCommand // CustomCommand
@ -63,16 +64,16 @@ type ChildCommand map[string]map[string]Command
type CustomCommand struct { type CustomCommand struct {
Content string // The content of the custom command. Custom commands are just special strings after all 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 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 // 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 // This is private so that other commands cannot modify it
var commands = make(map[string]Command) var commands = make(map[string]Command)
// childCommands // childCommands
// All of the registered childcommands (subcmdgrps) // All the registered ChildCommands (SubCmdGrps)
// This is private so other commands cannot modify it // This is private so other commands cannot modify it
var childCommands = make(ChildCommand) var childCommands = make(ChildCommand)
@ -81,7 +82,7 @@ var childCommands = make(ChildCommand)
var commandAliases = make(map[string]string) var commandAliases = make(map[string]string)
// slashCommands // 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 // This is also private so other commands cannot modify it
var slashCommands = make(map[string]discordgo.ApplicationCommand) var slashCommands = make(map[string]discordgo.ApplicationCommand)
@ -128,8 +129,16 @@ func AddChildCommand(info *CommandInfo, function BotFunction) {
// Adds a slash command to the bot // Adds a slash command to the bot
// Allows for separation between normal commands and slash commands // Allows for separation between normal commands and slash commands
func AddSlashCommand(info *CommandInfo) { func AddSlashCommand(info *CommandInfo) {
s := createSlashCommandStruct(info) if !info.IsParent || !info.IsChild {
slashCommands[strings.ToLower(info.Trigger)] = *s 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 // 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 // Ignore messages sent by the bot
if message.Author.ID == session.State.User.ID { if message.Author.ID == session.State.User.ID {
return return
@ -250,6 +253,7 @@ func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate
if command.Info.IsTyping && g.Info.ResponseChannelId == "" { if command.Info.IsTyping && g.Info.ResponseChannelId == "" {
_ = Session.ChannelTyping(message.ChannelID) _ = Session.ChannelTyping(message.ChannelID)
} }
defer handleCommandError(g.ID, channel.ID, message.Author.ID)
if command.Info.IsParent { if command.Info.IsParent {
handleChildCommand(*argString, command, message.Message, g) handleChildCommand(*argString, command, message.Message, g)
return return
@ -312,3 +316,18 @@ func handleChildCommand(argString string, command Command, message *discordgo.Me
}) })
return 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
}

View File

@ -20,5 +20,11 @@ var (
"channel": regexp2.MustCompile("<((#?\\d+))>", 0), "channel": regexp2.MustCompile("<((#?\\d+))>", 0),
"id": regexp2.MustCompile("^[0-9]{18}$", 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),
}
) )

View File

@ -17,7 +17,6 @@ import (
type GuildInfo struct { type GuildInfo struct {
AddedDate int64 `json:"addedDate"` // The date the bot was added to the server AddedDate int64 `json:"addedDate"` // The date the bot was added to the server
Prefix string `json:"prefix"` // The bot prefix 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 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 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 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 // 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 // Return a pointer to the guild object and pass that around instead, to avoid information desync
func getGuild(guildId string) *Guild { 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 { if guild, ok := Guilds[guildId]; ok {
return guild return guild
} else { } else {
@ -67,7 +90,6 @@ func getGuild(guildId string) *Guild {
ID: guildId, ID: guildId,
Info: GuildInfo{ Info: GuildInfo{
AddedDate: time.Now().Unix(), AddedDate: time.Now().Unix(),
GuildLanguage: "en",
Prefix: "!", Prefix: "!",
DeletePolicy: false, DeletePolicy: false,
ResponseChannelId: "", ResponseChannelId: "",
@ -255,13 +277,6 @@ func (g *Guild) SetPrefix(newPrefix string) {
g.save() g.save()
} }
// SetLang
// Set the prefix, then save the guild data
func (g *Guild) SetLang(lang string) {
g.Info.GuildLanguage = lang
g.save()
}
// IsMod // IsMod
// Check if a given ID is a moderator or not // Check if a given ID is a moderator or not
func (g *Guild) IsMod(checkId string) bool { func (g *Guild) IsMod(checkId string) bool {

View File

@ -1,20 +1,16 @@
package framework package framework
import ( import (
"fmt"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"runtime"
) )
// // -- Types and Structs --
//// TODO clean up this file and move interaction specific functions here
// // slashCommandTypes
//import ( // A map of *short hand* slash commands types to their discordgo counterparts
// "github.com/bwmarrin/discordgo" // TODO move this over to interaction.go
// "strings"
//)
//
//// 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{ var slashCommandTypes = map[ArgTypeGuards]discordgo.ApplicationCommandOptionType{
Int: discordgo.ApplicationCommandOptionInteger, Int: discordgo.ApplicationCommandOptionInteger,
String: discordgo.ApplicationCommandOptionString, String: discordgo.ApplicationCommandOptionString,
@ -26,6 +22,8 @@ var slashCommandTypes = map[ArgTypeGuards]discordgo.ApplicationCommandOptionType
//SubCmdGrp: discordgo.ApplicationCommandOptionSubCommandGroup, //SubCmdGrp: discordgo.ApplicationCommandOptionSubCommandGroup,
} }
//var componentHandlers
// //
// getSlashCommandStruct // getSlashCommandStruct
// Creates a slash command struct // Creates a slash command struct
@ -66,6 +64,29 @@ func createSlashCommandStruct(info *CommandInfo) (st *discordgo.ApplicationComma
return 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 -- // -- Interaction Handlers --
// handleInteraction // handleInteraction
@ -84,6 +105,39 @@ func handleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
// handleInteractionCommand // handleInteractionCommand
// Handles a slash command // Handles a slash command
func handleInteractionCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { 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) g := getGuild(i.GuildID)
if g.Info.DeletePolicy { 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) { 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 // Check if the command is public, or if the current user is a bot moderator
// Bot admins supercede both checks // Bot admins supercede both checks
defer handleSlashCommandError(*i.Interaction)
command.Function(&Context{ command.Function(&Context{
Guild: g, Guild: g,
Cmd: command.Info, 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
}

View File

@ -1,6 +1,7 @@
package framework package framework
import ( import (
"fmt"
"time" "time"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
@ -24,6 +25,7 @@ type Response struct {
Success bool Success bool
Loading bool Loading bool
Ephemeral bool Ephemeral bool
Reply bool
Embed *discordgo.MessageEmbed Embed *discordgo.MessageEmbed
ResponseComponents *ResponseComponents ResponseComponents *ResponseComponents
} }
@ -71,6 +73,7 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response
}, },
Loading: ctx.Cmd.IsTyping, Loading: ctx.Cmd.IsTyping,
Ephemeral: ephemeral, Ephemeral: ephemeral,
Reply: ephemeral,
} }
if messageComponents { if messageComponents {
r.ResponseComponents.Components = CreateComponentFields() r.ResponseComponents.Components = CreateComponentFields()
@ -90,8 +93,74 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, 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 return r
} }
@ -317,7 +386,7 @@ func (r *Response) Send(success bool, title string, description string) {
Embed: r.Embed, Embed: r.Embed,
Components: r.ResponseComponents.Components, Components: r.ResponseComponents.Components,
}) })
if err != nil { if err != nil && r.Reply {
// Reply to user if no output channel // Reply to user if no output channel
_, err = ReplyToUser(r.Ctx.Message.ChannelID, &discordgo.MessageSend{ _, err = ReplyToUser(r.Ctx.Message.ChannelID, &discordgo.MessageSend{
Embed: r.Embed, Embed: r.Embed,
@ -334,6 +403,12 @@ func (r *Response) Send(success bool, title string, description string) {
if err != nil { if err != nil {
SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Message.ChannelID, r.Ctx.Message.Author.ID, "Ultimately failed to send bot response", err) 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,
})
} }
} }

53
util.go
View File

@ -3,12 +3,12 @@ package framework
import ( import (
"errors" "errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/dlclark/regexp2" "regexp"
"strconv" "strconv"
"strings" "strings"
) )
// language.go // util.go
// This file contains utility functions, simplifying redundant tasks // This file contains utility functions, simplifying redundant tasks
// RemoveItem // RemoveItem
@ -23,45 +23,43 @@ func RemoveItem(slice []string, delete string) []string {
return newSlice 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 // EnsureNumbers
// Given a string, ensure it contains only numbers // Given a string, ensure it contains only numbers
// This is useful for stripping letters and formatting characters from user/role pings // This is useful for stripping letters and formatting characters from user/role pings
func EnsureNumbers(in string) string { func EnsureNumbers(in string) string {
reg, err := regexp2.Compile("[^0-9]+", 0) reg, err := regexp.Compile("[^0-9]+")
if err != nil { if err != nil {
log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err)
return "" return ""
} }
if ok, _ := reg.MatchString(in); ok {
str, err := reg.Replace(in, "", -1, -1) return reg.ReplaceAllString(in, "")
if err != nil {
log.Errorf("Unable to replace text in EnsureNumbers")
return ""
}
return str
}
return in
} }
// EnsureLetters // EnsureLetters
// Given a string, ensure it contains only letters // Given a string, ensure it contains only letters
// This is useful for stripping numbers from mute durations, and possibly other things // This is useful for stripping numbers from mute durations, and possibly other things
func EnsureLetters(in string) string { func EnsureLetters(in string) string {
reg, err := regexp2.Compile("[^a-zA-Z]+", 0) reg, err := regexp.Compile("[^a-zA-Z]+")
if err != nil { if err != nil {
log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err)
return "" return ""
} }
if ok, _ := reg.MatchString(in); ok { return reg.ReplaceAllString(in, "")
str, err := reg.Replace(in, "", -1, -1)
if err != nil {
log.Errorf("Unable to replace text in EnsureLetters")
return ""
}
return str
}
return in
} }
// CleanId // CleanId
@ -140,15 +138,8 @@ func ExtractCommand(guild *GuildInfo, message string) (*string, *string) {
if content == "" { if content == "" {
return nil, nil return nil, nil
} }
// Attempt to pull the trigger out of the command content by splitting on spaces trigger := strings.ToLower(strings.Fields(content)[0])
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
fullArgs := strings.SplitN(content, trigger, 2)[1] 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 return &trigger, &fullArgs
} else { } else {
return nil, nil return nil, nil
@ -190,7 +181,7 @@ func SendErrorReport(guildId string, channelId string, userId string, title stri
log.Errorf("[REPORT] %s (%s)", title, err) log.Errorf("[REPORT] %s (%s)", title, err)
// Iterate through all the admins // Iterate through all the admins
for admin, _ := range botAdmins { for admin := range botAdmins {
// Get the channel ID of the user to DM // Get the channel ID of the user to DM
dmChannel, dmCreateErr := Session.UserChannelCreate(admin) dmChannel, dmCreateErr := Session.UserChannelCreate(admin)