package framework import ( "github.com/QPixel/orderedmap" "github.com/bwmarrin/discordgo" "runtime" "runtime/debug" "strings" "time" ) // commands.go // This file contains everything required to add core commands to the bot, and parse commands from a message // Group // Defines different "groups" of commands for ordering in a help command type Group string var ( Moderation Group = "moderation" Utility Group = "utility" ) // CommandInfo // The definition of a command's info. This is everything about the command, besides the function it will run type CommandInfo struct { Aliases []string // Aliases for the normal trigger Arguments *orderedmap.OrderedMap // Arguments for the command Description string // A short description of what the command does Group Group // The group this command belongs to ParentID string // The ID of the parent command 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 } // Context // This is a context of a single command invocation // This gives the command function access to all the information it might need type Context struct { Guild *Guild // NOTE: Guild is a pointer, since we want to use the SAME instance of the guild across the program! Cmd CommandInfo Args Arguments Message *discordgo.Message Interaction *discordgo.Interaction } // 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 type BotFunction func(ctx *Context) // Command // The definition of a command, which is that command's information, along with the function it will run type Command struct { Info CommandInfo Function BotFunction } // ChildCommand // Defines how child commands are stored type ChildCommand map[string]map[string]Command // 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 the registered ChildCommands (SubCmdGrps) // This is private so other commands cannot modify it var childCommands = make(ChildCommand) // Command Aliases // A map of aliases to command triggers var commandAliases = make(map[string]string) // slashCommands // 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) // commandsGC var commandsGC = 0 // AddCommand // Add a command to the bot func AddCommand(info *CommandInfo, function BotFunction) { // Add Trigger to the alias info.Aliases = append(info.Aliases, info.Trigger) // Build a Command object for this command command := Command{ Info: *info, Function: function, } // adds a alias to a map; command aliases are case-sensitive for _, alias := range info.Aliases { if _, ok := commandAliases[alias]; ok { log.Errorf("Alias was already registered %s for command %s", alias, info.Trigger) continue } alias = strings.ToLower(alias) commandAliases[alias] = info.Trigger } // Add the command to the map; command triggers are case-insensitive commands[strings.ToLower(info.Trigger)] = command } // AddChildCommand // Adds a child command to the bot. func AddChildCommand(info *CommandInfo, function BotFunction) { // Build a Command object for this command command := Command{ Info: *info, Function: function, } parentID := strings.ToLower(info.ParentID) if childCommands[parentID] == nil { childCommands[parentID] = make(map[string]Command) } // Add the command to the map; command triggers are case-insensitive childCommands[parentID][command.Info.Trigger] = command } // AddSlashCommand // Adds a slash command to the bot // Allows for separation between normal commands and slash commands func AddSlashCommand(info *CommandInfo) { 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 // Defaults to adding Global slash commands // Currently hard coded to guild commands for testing func AddSlashCommands(guildId string, c chan string) { for _, v := range slashCommands { _, err := Session.ApplicationCommandCreate(Session.State.User.ID, guildId, &v) if err != nil { c <- "Unable to register slash commands :/" log.Errorf("Cannot create '%v' command: %v", v.Name, err) log.Errorf("%v", v.Options) return } } c <- "Finished registering slash commands" return } // GetCommands // Provide a way to read commands without making it possible to modify their functions func GetCommands() map[string]CommandInfo { list := make(map[string]CommandInfo) for x, y := range commands { list[x] = y.Info } return list } // commandHandler // This handler will be added to a *discordgo.Session, and will scan an incoming messages for commands to run func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate) { // Try getting an object for the current channel, with a fallback in case session.state is not ready or is nil channel, err := session.State.Channel(message.ChannelID) if err != nil { if channel, err = session.Channel(message.ChannelID); err != nil { return } } // Ignore messages sent by the bot if message.Author.ID == session.State.User.ID { return } g := getGuild(message.GuildID) trigger, argString := ExtractCommand(&g.Info, message.Content) if trigger == nil { return } // Only do further checks if the user is not a bot admin if !IsAdmin(message.Author.ID) { // Ignore the command if it is globally disabled if g.IsGloballyDisabled(*trigger) { return } // Ignore the command if this channel has blocked the command if g.CommandIsDisabledInChannel(*trigger, message.ChannelID) { return } // Ignore any message if the user is banned from using the bot if !g.MemberOrRoleIsWhitelisted(message.Author.ID) || g.MemberOrRoleIsIgnored(message.Author.ID) { return } // Ignore the message if this channel is not whitelisted, or if it is ignored if !g.ChannelIsWhitelisted(message.ChannelID) || g.ChannelIsIgnored(message.ChannelID) { return } } //Get the command to run // Error Checking command, ok := commands[commandAliases[*trigger]] if !ok { log.Errorf("Command was not found") return } // Check if the command is public, or if the current user is a bot moderator // Bot admins supercede both checks if IsAdmin(message.Author.ID) || command.Info.Public || g.IsMod(message.Author.ID) { // Run the command with the necessary context if command.Info.IsTyping && g.Info.ResponseChannelId == "" { _ = Session.ChannelTyping(message.ChannelID) } // The command is valid, so now we need to delete the invoking message if that is configured if g.Info.DeletePolicy { err := Session.ChannelMessageDelete(message.ChannelID, message.ID) if err != nil { SendErrorReport(message.GuildID, message.ChannelID, message.Author.ID, "Failed to delete message: "+message.ID, err) } } defer handleCommandError(g.ID, channel.ID, message.Author.ID) if command.Info.IsParent { handleChildCommand(*argString, command, message.Message, g) return } command.Function(&Context{ Guild: g, Cmd: command.Info, Args: *ParseArguments(*argString, command.Info.Arguments), Message: message.Message, }) // Makes sure that variables ran in ParseArguments are gone. if commandsGC == 25 && commandsGC > 25 { debug.FreeOSMemory() commandsGC = 0 } else { commandsGC++ } return } } // -- Helper Methods func handleChildCommand(argString string, command Command, message *discordgo.Message, g *Guild) { split := strings.SplitN(argString, " ", 2) childCmd, ok := childCommands[command.Info.Trigger][split[0]] if !ok { command.Function(&Context{ Guild: g, Cmd: command.Info, Args: nil, Message: message, }) return } if len(split) < 2 { childCmd.Function(&Context{ Guild: g, Cmd: childCmd.Info, Args: *ParseArguments("", childCmd.Info.Arguments), Message: message, }) return } childCmd.Function(&Context{ Guild: g, Cmd: childCmd.Info, Args: *ParseArguments(split[1], childCmd.Info.Arguments), Message: message, }) 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) } time.Sleep(5 * time.Second) _ = Session.ChannelMessageDelete(cId, message.ID) return } return }