diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f03c4b4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[{Makefile,go.mod,go.sum,*.go,.gitmodules}] +indent_style = tab +indent_size = 4 \ No newline at end of file diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f869ba3..fb4c350 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,24 +2,20 @@ name: Go on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: - build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - - name: Set up Go - uses: actions/setup-go@v2 - with: - go-version: 1.17 + - uses: actions/checkout@v2 - - name: Build - run: go build -v ./... + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.21 - - name: Test - run: go test -v ./... + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore index 2881224..80b4be2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # ignore IDE related files .idea .vscode + +.DS_Store \ No newline at end of file diff --git a/arguments.go b/arguments.go index c358d2a..4b352e4 100644 --- a/arguments.go +++ b/arguments.go @@ -3,11 +3,12 @@ package framework import ( "errors" "fmt" + "strconv" + "strings" + "github.com/QPixel/orderedmap" "github.com/bwmarrin/discordgo" "github.com/dlclark/regexp2" - "strconv" - "strings" ) // Arguments.go @@ -49,16 +50,18 @@ var ( ) // ArgInfo -// Describes a CommandInfo argument +// Describes the argument that a command will receive type ArgInfo struct { + Name string Match ArgTypes TypeGuard ArgTypeGuards Description string Required bool Flag bool DefaultOption string - Choices []string + Choices []*discordgo.ApplicationCommandOptionChoice Regex *regexp2.Regexp + AutoComplete bool } // CommandArg @@ -72,171 +75,6 @@ type CommandArg struct { // Type of the arguments field in the command ctx type Arguments map[string]CommandArg -// -- Command Configuration -- - -// CreateCommandInfo -// Creates a pointer to a CommandInfo -func CreateCommandInfo(trigger string, description string, public bool, group Group) *CommandInfo { - cI := &CommandInfo{ - Aliases: nil, - Arguments: orderedmap.New(), - Description: description, - Group: group, - Public: public, - IsTyping: false, - Trigger: trigger, - } - return cI -} - -// CreateRawCmdInfo -// Creates a pointer to a CommandInfo -func CreateRawCmdInfo(cI *CommandInfo) *CommandInfo { - cI.Arguments = orderedmap.New() - return cI -} - -// SetParent -// Sets the parent properties -func (cI *CommandInfo) SetParent(isParent bool, parentID string) { - if !isParent { - cI.IsChild = true - } - cI.IsParent = isParent - cI.ParentID = parentID -} - -//AddCmdAlias -// Adds a list of strings as aliases for the command -func (cI *CommandInfo) AddCmdAlias(aliases []string) *CommandInfo { - if len(aliases) < 1 { - return cI - } - cI.Aliases = aliases - return cI -} - -// AddArg -// Adds an arg to the CommandInfo -func (cI *CommandInfo) AddArg(argument string, typeGuard ArgTypeGuards, match ArgTypes, description string, required bool, defaultOption string) *CommandInfo { - cI.Arguments.Set(argument, &ArgInfo{ - TypeGuard: typeGuard, - Description: description, - Required: required, - Match: match, - DefaultOption: defaultOption, - Choices: nil, - 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, - Flag: true, - Match: match, - TypeGuard: typeGuard, - DefaultOption: defaultOption, - Regex: regex, - }) - return cI -} - -// AddChoices -// Adds SubCmd choices -func (cI *CommandInfo) AddChoices(arg string, choices []string) *CommandInfo { - v, ok := cI.Arguments.Get(arg) - if ok { - vv := v.(*ArgInfo) - vv.Choices = choices - cI.Arguments.Set(arg, vv) - } else { - log.Errorf("Unable to get argument %s in AddChoices", arg) - return cI - } - return cI -} - -func (cI *CommandInfo) SetTyping(isTyping bool) *CommandInfo { - cI.IsTyping = isTyping - 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 -// 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" - - // 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 - - 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 - } -} - /* Argument Parsing Helpers */ func createContentString(splitString []string, currentPos int) (string, int) { diff --git a/commands.go b/commands.go index 2954ddf..9536dbe 100644 --- a/commands.go +++ b/commands.go @@ -1,12 +1,16 @@ package framework import ( - "github.com/QPixel/orderedmap" - "github.com/bwmarrin/discordgo" + "fmt" "runtime" "runtime/debug" "strings" "time" + + "github.com/QPixel/orderedmap" + "github.com/bwmarrin/discordgo" + "github.com/dlclark/regexp2" + "github.com/ubergeek77/tinylog" ) // commands.go @@ -17,23 +21,36 @@ import ( type Group string var ( - Moderation Group = "moderation" - Utility Group = "utility" + Moderation Group = "moderation" + Utility Group = "utility" + UserContext Group = "context" + MessageContext Group = "message" +) + +type CommandType string + +var ( + ChatCommand CommandType = "CHAT" + UserCommand CommandType = "USER" + MessageCommand CommandType = "MESSAGE" ) // 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 + Type CommandType // The type of command + 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 + Name string // The name of the command + IntegrationTypes []discordgo.ApplicationIntegrationType + InstallationContexts []discordgo.InteractionContextType } // Context @@ -45,116 +62,371 @@ type Context struct { Args Arguments Message *discordgo.Message Interaction *discordgo.Interaction + Log *tinylog.Logger } +var botContextLoggerColor = tinylog.NewColor("38;5;13") + // 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 +// The definition of a command, which is that command's information, along with the functions it will run +// Handlers is a map of strings to BotFunctions, so that different handlers can be used for different situations type Command struct { - Info CommandInfo - Function BotFunction + Info *CommandInfo + Handlers map[string]BotFunction + ApplicationCommand *discordgo.ApplicationCommand } -// ChildCommand -// Defines how child commands are stored -type ChildCommand map[string]map[string]Command - // commands -// All the registered core commands (not custom commands) +// All commands that are registered with the bot are stored here // 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) +var commands = make(map[string]*Command) // 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) +// component handlers +var componentHandlers = make(map[string]BotFunction) // commandsGC var commandsGC = 0 +// -- Command Configuration -- + +// CreateCommandInfo +// Creates a pointer to a CommandInfo +func CreateCommandInfo(name string, description string, public bool, group Group, command_type ...CommandType) *CommandInfo { + if len(command_type) < 1 { + command_type = append(command_type, ChatCommand) + } + cI := &CommandInfo{ + Type: command_type[0], + Aliases: make([]string, 0), + Arguments: orderedmap.New(), + Description: description, + Group: group, + Public: public, + IsTyping: false, + Name: name, + IsParent: true, + IsChild: false, + IntegrationTypes: []discordgo.ApplicationIntegrationType{ + discordgo.ApplicationIntegrationGuildInstall, + }, + InstallationContexts: []discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, + } + cI.Aliases = append(cI.Aliases, name) + return cI +} + +// Sets the parent properties +func (cI *CommandInfo) SetParent(isParent bool, parentID string) *CommandInfo { + if !isParent { + cI.IsChild = true + } + cI.IsParent = isParent + cI.ParentID = parentID + return cI +} + +// AddCmdAlias +// Adds a list of strings as aliases for the command +func (cI *CommandInfo) AddCmdAlias(aliases []string) *CommandInfo { + if len(aliases) < 1 { + return cI + } + cI.Aliases = aliases + return cI +} + +// AddArg +// Adds an arg to the CommandInfo +func (cI *CommandInfo) AddArg(argument string, typeGuard ArgTypeGuards, match ArgTypes, description string, required bool) *CommandInfo { + cI.Arguments.Set(argument, &ArgInfo{ + TypeGuard: typeGuard, + Description: description, + Required: required, + Match: match, + DefaultOption: "", + Choices: make([]*discordgo.ApplicationCommandOptionChoice, 0), + Regex: nil, + AutoComplete: false, + }) + 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.Name, flag) + } + cI.Arguments.Set(flag, &ArgInfo{ + Description: description, + Required: required, + Flag: true, + Match: match, + TypeGuard: typeGuard, + DefaultOption: defaultOption, + Regex: regex, + }) + return cI +} + +// AddChoice +// Adds an argument choice +func (cI *CommandInfo) AddChoice(arg string, choice string) *CommandInfo { + v, ok := cI.Arguments.Get(arg) + if ok { + vv := v.(*ArgInfo) + vv.Choices = append(vv.Choices, &discordgo.ApplicationCommandOptionChoice{ + Name: choice, + Value: choice, + }) + cI.Arguments.Set(arg, vv) + } else { + log.Errorf("Unable to get argument %s in AddChoice", arg) + return cI + } + return cI +} + +// AddChoices +// Adds SubCmd choices +func (cI *CommandInfo) AddChoices(arg string, choices []string) *CommandInfo { + v, ok := cI.Arguments.Get(arg) + if ok { + vv := v.(*ArgInfo) + optionChoice := make([]*discordgo.ApplicationCommandOptionChoice, 0) + for _, v := range choices { + optionChoice = append(optionChoice, &discordgo.ApplicationCommandOptionChoice{ + Name: v, + Value: v, + }) + } + vv.Choices = append(vv.Choices, optionChoice...) + cI.Arguments.Set(arg, vv) + } else { + log.Errorf("Unable to get argument %s in AddChoices", arg) + return cI + } + return cI +} + +// AddChoices +// Adds SubCmd choices +func (cI *CommandInfo) AddChoicesManual(arg string, choices []*discordgo.ApplicationCommandOptionChoice) *CommandInfo { + v, ok := cI.Arguments.Get(arg) + if ok { + vv := v.(*ArgInfo) + vv.Choices = append(vv.Choices, choices...) + cI.Arguments.Set(arg, vv) + } else { + log.Errorf("Unable to get argument %s in AddChoices", arg) + return cI + } + return cI +} + +func (cI *CommandInfo) SetTyping(isTyping bool) *CommandInfo { + cI.IsTyping = isTyping + return cI +} + +func (cI *CommandInfo) SetAutocomplete(arg string, autocomplete bool) *CommandInfo { + v, ok := cI.Arguments.Get(arg) + if ok { + vv := v.(*ArgInfo) + vv.AutoComplete = autocomplete + cI.Arguments.Set(arg, vv) + } else { + log.Errorf("Unable to get argument %s in SetAutocomplete", arg) + return cI + } + return cI +} + +func (cI *CommandInfo) SetIntegrationType(integrationType ...discordgo.ApplicationIntegrationType) *CommandInfo { + cI.IntegrationTypes = integrationType + return cI +} + +func (cI *CommandInfo) SetInstallationContext(installationContext ...discordgo.InteractionContextType) *CommandInfo { + cI.InstallationContexts = installationContext + return cI +} + +// -- Argument Parser -- + +// ParseArguments +// 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" + + // 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 + + 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 + } +} + // 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) + switch info.Type { + case ChatCommand: + AddChatCommand(info, function) + case UserCommand, MessageCommand: + AddContextCommand(info, function) + } +} + +// AddChatCommand +// Add a chat command to the bot +func AddChatCommand(info *CommandInfo, function BotFunction) { // Build a Command object for this command + appCommand := createApplicationChatCommand(info) command := Command{ - Info: *info, - Function: function, + Info: info, + Handlers: make(map[string]BotFunction), + ApplicationCommand: appCommand, } + + command.Handlers["default"] = 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) + log.Errorf("Alias was already registered %s for command %s", alias, info.Name) continue } alias = strings.ToLower(alias) - commandAliases[alias] = info.Trigger + commandAliases[alias] = info.Name + } + + if _, ok := commands[strings.ToLower(info.Name)]; ok { + log.Errorf("Command was already registered %s", info.Name) + return + } // Add the command to the map; command triggers are case-insensitive - commands[strings.ToLower(info.Trigger)] = command + commands[strings.ToLower(info.Name)] = &command } -// AddChildCommand -// Adds a child command to the bot. -func AddChildCommand(info *CommandInfo, function BotFunction) { +// AddContextCommand +// Add a context command to the bot +func AddContextCommand(info *CommandInfo, function BotFunction) { + appCommand := createApplicationContextCommand(info) // 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) + Info: info, + Handlers: make(map[string]BotFunction), + ApplicationCommand: appCommand, } + + command.Handlers["default"] = function + // Add the command to the map; command triggers are case-insensitive - childCommands[parentID][command.Info.Trigger] = command + if _, ok := commands[strings.ToLower(info.Name)]; ok { + log.Errorf("Command was already registered %s", info.Name) + return + } + + commands[strings.ToLower(info.Name)] = &command +} + +// AddCommandHandler +// Adds a command handler to the bot +func AddCommandHandler(info *CommandInfo, function BotFunction, handler string) { + if _, ok := commands[strings.ToLower(info.Name)]; !ok { + log.Errorf("Command was not found") + return + } + commands[strings.ToLower(info.Name)].Handlers[handler] = function } -// 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 +// AddAutoCompleteHandler +// Adds an autocomplete handler to the bot +func AddAutoCompleteHandler(info *CommandInfo, function BotFunction, handler string) { + if _, ok := commands[strings.ToLower(info.Name)]; !ok { + log.Errorf("Command was not found") return } - if info.IsParent { - s := createSlashSubCmdStruct(info, childCommands[info.Trigger]) - slashCommands[strings.ToLower(info.Trigger)] = *s + commands[info.Name].Handlers[fmt.Sprintf("ac:%s", strings.ToLower(handler))] = function +} + +// AddComponentHandler +// Adds a component handler to the bot +func AddComponentHandler(handler string, function BotFunction) { + if _, ok := componentHandlers[handler]; ok { + log.Errorf("Component handler was already registered %s", handler) return } + componentHandlers[handler] = function } -// AddSlashCommands +// RegisterSlashCommands // 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) +func RegisterSlashCommands(guildId string, c chan string) { + for _, v := range commands { + _, err := Session.ApplicationCommandCreate(Session.State.User.ID, guildId, v.ApplicationCommand) if err != nil { c <- "Unable to register slash commands :/" - log.Errorf("Cannot create '%v' command: %v", v.Name, err) - log.Errorf("%v", v.Options) + log.Errorf("Cannot create '%v' command: %v", v.Info, err) + log.Errorf("%v", v.ApplicationCommand) return } } c <- "Finished registering slash commands" - return } // GetCommands @@ -162,11 +434,25 @@ func AddSlashCommands(guildId string, c chan string) { func GetCommands() map[string]CommandInfo { list := make(map[string]CommandInfo) for x, y := range commands { - list[x] = y.Info + list[x] = *y.Info } return list } +// SendAutocompleteChoices +// Sends the choices to the user +func (ctx *Context) SendAutocompleteChoices(choices []*discordgo.ApplicationCommandOptionChoice) { + err := Session.InteractionRespond(ctx.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{ + Choices: choices, + }, + }) + if err != nil { + log.Errorf("Error sending autocomplete choices %s", err) + } +} + // 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) { @@ -236,12 +522,12 @@ func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate defer handleCommandError(g.ID, channel.ID, message.Author.ID) if command.Info.IsParent { - handleChildCommand(*argString, command, message.Message, g) + // handleChildCommand(*argString, command, message.Message, g) return } - command.Function(&Context{ + command.Handlers["default"](&Context{ Guild: g, - Cmd: command.Info, + Cmd: *command.Info, Args: *ParseArguments(*argString, command.Info.Arguments), Message: message.Message, }) @@ -257,38 +543,6 @@ func commandHandler(session *discordgo.Session, message *discordgo.MessageCreate } -// -- 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) @@ -302,5 +556,4 @@ func handleCommandError(gID string, cId string, uId string) { _ = Session.ChannelMessageDelete(cId, message.ID) return } - return } diff --git a/core.go b/core.go index 7fceb8e..7462fa5 100644 --- a/core.go +++ b/core.go @@ -1,13 +1,15 @@ package framework import ( - "github.com/bwmarrin/discordgo" - tlog "github.com/ubergeek77/tinylog" "os" "os/signal" - "strconv" "strings" "syscall" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/qpixel/framework/workers" + tlog "github.com/ubergeek77/tinylog" ) // core.go @@ -19,7 +21,7 @@ var MessageState = 500 // log // The logger for the core bot -var log = tlog.NewTaggedLogger("BotCore", tlog.NewColor("38;5;111")) +var log = tlog.NewTaggedLogger("Framework", tlog.NewColor("38;5;111")) // dlog // The logger for discordgo @@ -53,26 +55,22 @@ var ColorSuccess = 0x55F485 // The color to use for response embeds reporting failure var ColorFailure = 0xF45555 -// BotPresence -// Presence data to send when the bot is logging in -var botPresence discordgo.GatewayStatusUpdate - // initProvider // Stores and allows for the calling of the chosen GuildProvider var initProvider func() GuildProvider +// debugMode +// A boolean that tells the bot to log debug messages +var debugMode = false + +// workerManager +// The worker manager for the bot +var WorkerManager *workers.WorkerManager + // SetInitProvider // Sets the init provider func SetInitProvider(provider func() GuildProvider) { initProvider = provider - return -} - -// SetPresence -// Sets the gateway field for bot presence -func SetPresence(presence discordgo.GatewayStatusUpdate) { - botPresence = presence - return } // AddAdmin @@ -109,8 +107,14 @@ func IsCommand(trigger string) bool { return false } -// Start the bot. -func Start() { +// SetDebugMode +// Set the log level to debug +func SetDebugMode() { + debugMode = true +} + +// Run the bot. +func Run() { discordgo.Logger = dgoLog // Load all the guilds @@ -125,6 +129,8 @@ func Start() { log.Fatalf("You have not specified a Discord bot token!") } + WorkerManager = workers.InitializeManager(time.UTC) + // Use the token to create a new session var err error Session, err = discordgo.New("Bot " + botToken) @@ -132,22 +138,24 @@ func Start() { if err != nil { log.Fatalf("Failed to create Discord session: %s", err) } + if debugMode { + Session.LogLevel = discordgo.LogInformational + Session.Debug = true + log.LogLevel = tlog.DebugLevel + } else { + Session.LogLevel = discordgo.LogWarning + log.LogLevel = tlog.WarningLevel + } + + if os.Getenv("LOG_LEVEL") != "" && os.Getenv("LOG_LEVEL") == "DEBUG" { + Session.LogLevel = discordgo.LogDebug + } + // Setup State specific variables Session.State.MaxMessageCount = MessageState - Session.LogLevel = discordgo.LogWarning Session.SyncEvents = false Session.Identify.Intents = discordgo.IntentsAllWithoutPrivileged | discordgo.IntentMessageContent - // Set the bots status - Session.Identify.Presence = botPresence - - // Open the session - log.Info("Connecting to Discord...") - err = Session.Open() - if err != nil { - log.Fatalf("Failed to connect to Discord: %s", err) - } - // Add the commandHandler to the list of user-defined handlers AddDGOHandler(commandHandler) @@ -157,11 +165,17 @@ func Start() { // Add the handlers to the session addDGoHandlers() + // Open the session + log.Info("Connecting to Discord...") + err = Session.Open() + if err != nil { + log.Fatalf("Failed to connect to Discord: %s", err) + } + // Log that the login succeeded log.Infof("Bot logged in as \"" + Session.State.Ready.User.Username + "#" + Session.State.Ready.User.Discriminator + "\"") // Start workers - startWorkers() // Print information about the current bot admins numAdmins := 0 @@ -181,7 +195,7 @@ func Start() { //Register slash commands slashChannel := make(chan string) log.Info("Registering slash commands") - go AddSlashCommands(botTestingId, slashChannel) + go RegisterSlashCommands(botTestingId, slashChannel) // Bot ready log.Info("Initialization complete! The bot is now ready.") @@ -193,31 +207,22 @@ func Start() { // Set up a sigterm channel, so we can detect when the application receives a TERM signal sigChannel := make(chan os.Signal, 1) - signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL, os.Interrupt, os.Kill) + signal.Notify(sigChannel, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, syscall.SIGKILL, syscall.SIGQUIT, syscall.SIGABRT) // Keep this thread blocked forever, until a TERM signal is received <-sigChannel log.Info("Received TERM signal, terminating gracefully.") - // Set the global loop variable to false so all background loops terminate - continueLoop = false - // Make a second sig channel that will respond to user term signal immediately sigInstant := make(chan os.Signal, 1) - signal.Notify(sigInstant, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) + signal.Notify(sigInstant, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, syscall.SIGKILL, syscall.SIGQUIT, syscall.SIGABRT) // Make a goroutine that will wait for all background workers to be unlocked go func() { log.Info("Waiting for workers to exit... (interrupt to kill immediately; not recommended!!!)") - for i, lock := range workerLock { - // Try locking the worker mutex. This will block if the mutex is already locked - // If we are able to lock it, then it means the worker has stopped. - lock.Lock() - log.Info("Stopped worker " + strconv.Itoa(i)) - lock.Unlock() - } - + // Stop all workers + WorkerManager.StopWorkers() log.Info("All routines exited gracefully.") // Send our own signal to the instant sig channel @@ -228,11 +233,38 @@ func Start() { <-sigInstant log.Info("Closing the Discord session...") - closeErr := Session.Close() + closeErr := Session.CloseWithCode(1000) if closeErr != nil { log.Errorf("An error occurred when closing the Discord session: %s", err) return } log.Info("Session closed.") + +} + +func DeleteSlashCommands() { + var err error + Session, err = discordgo.New("Bot " + botToken) + if err != nil { + log.Fatalf("Failed to create Discord session: %s", err) + } + if debugMode { + Session.LogLevel = discordgo.LogInformational + Session.Debug = true + log.LogLevel = tlog.DebugLevel + } else { + Session.LogLevel = discordgo.LogWarning + log.LogLevel = tlog.WarningLevel + } + err = Session.Open() + if err != nil { + log.Fatalf("Failed to connect to Discord: %s", err) + } + + log.Infof("%#v", Session) + RemoveAllSlashCommands() + + log.Info("Slash commands deleted.") + Session.Close() } diff --git a/go.mod b/go.mod index 6c8580d..63ec4ca 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,28 @@ module github.com/qpixel/framework -go 1.18 +go 1.21 + +replace github.com/bwmarrin/discordgo => ../discordgo require ( github.com/QPixel/orderedmap v0.2.0 - github.com/bwmarrin/discordgo v0.26.1 - github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 + github.com/bwmarrin/discordgo v0.27.2-0.20240315152229-33ee38cbf271 + github.com/dlclark/regexp2 v1.11.0 github.com/ubergeek77/tinylog v1.0.0 - golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 + gitlab.com/tozd/go/errors v0.8.1 + golang.org/x/sys v0.18.0 +) + +require ( + github.com/google/uuid v1.4.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + go.uber.org/atomic v1.9.0 // indirect ) require ( - github.com/gorilla/websocket v1.4.2 // indirect - golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + github.com/go-co-op/gocron v1.37.0 + github.com/gorilla/websocket v1.5.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 7421b8f..edd7111 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,70 @@ github.com/QPixel/orderedmap v0.2.0 h1:qGTSj7i1YP7dhhUmOZ5/p2OX3NGHtJW/FLT2eDCHJak= github.com/QPixel/orderedmap v0.2.0/go.mod h1:4cAVROPCVsOwbmwg3hDwZcfiAqYVr3FsOHAVy9ntErE= -github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE= -github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 h1:Izz0+t1Z5nI16/II7vuEo/nHjodOg0p7+OiDpjX5t1E= -github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= +github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/bwmarrin/discordgo v0.27.2-0.20240315152229-33ee38cbf271 h1:BuDtVy29wfi7XFRYUP2c65EJuMWWNCWhkiP+jYIU4eY= +github.com/bwmarrin/discordgo v0.27.2-0.20240315152229-33ee38cbf271/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/go-co-op/gocron v1.37.0 h1:ZYDJGtQ4OMhTLKOKMIch+/CY70Brbb1dGdooLEhh7b0= +github.com/go-co-op/gocron v1.37.0/go.mod h1:3L/n6BkO7ABj+TrfSVXLRzsP26zmikL4ISkLQ0O8iNY= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/ubergeek77/tinylog v1.0.0 h1:gsq98mbig3LDWhsizOe2tid12wHUz/mrkDlmgJ0MZG4= github.com/ubergeek77/tinylog v1.0.0/go.mod h1:NzUi4PkRG2hACL4cGgmW7db6EaKjAeqrqlVQnJdw78Q= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +gitlab.com/tozd/go/errors v0.8.1 h1:RfylffRAsl3PbDdHNUBEkTleTCiL/RIT+Ef8p0HRNCI= +gitlab.com/tozd/go/errors v0.8.1/go.mod h1:PvIdUMLpPwxr+KEBxghQaCMydHXGYdJQn/PhdMqYREY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/guilds.go b/guilds.go index 100fda6..3ee0866 100644 --- a/guilds.go +++ b/guilds.go @@ -28,7 +28,7 @@ type GuildInfo struct { WhitelistIds []string `json:"whitelist_ids"` } -//GuildProvider +// GuildProvider // Type that holds functions that can be easily modified to support a wide range // of storage types type GuildProvider struct { @@ -141,10 +141,7 @@ func (g *Guild) GetMember(userId string) (*discordgo.Member, error) { // Determine whether or not a given userId is a member in this guild func (g *Guild) IsMember(userId string) bool { _, err := g.GetMember(userId) - if err != nil { - return false - } - return true + return err == nil } // GetRole @@ -175,10 +172,7 @@ func (g *Guild) GetRole(roleId string) (*discordgo.Role, error) { // Determine whether or not a given roleId is a valid role in this guild func (g *Guild) IsRole(roleId string) bool { _, err := g.GetRole(roleId) - if err != nil { - return false - } - return true + return err == nil } // HasRole @@ -230,10 +224,7 @@ func (g *Guild) GetChannel(channelId string) (*discordgo.Channel, error) { // Determine whether or not a given channelId is a valid channel in this guild func (g *Guild) IsChannel(channelId string) bool { _, err := g.GetChannel(channelId) - if err != nil { - return false - } - return true + return err == nil } // MemberOrRoleInList @@ -609,7 +600,7 @@ func (g *Guild) RemoveChannelFromIgnored(channelId string) error { // Check if a given command is globally disabled func (g *Guild) IsGloballyDisabled(trigger string) bool { for _, disabled := range g.Info.GlobalDisabledCommands { - if strings.ToLower(disabled) == strings.ToLower(trigger) { + if strings.EqualFold(disabled, trigger) { return true } } @@ -950,10 +941,10 @@ func (g *Guild) GetMap(key string) (map[string]interface{}, error) { } // GetCommandUsage -//// Compile the usage information for a single command, so it can be printed out +// // Compile the usage information for a single command, so it can be printed out func (g *Guild) GetCommandUsage(cmd CommandInfo) string { // Get the trigger for the command, and add the prefix to it - trigger := g.Info.Prefix + cmd.Trigger + trigger := g.Info.Prefix + cmd.Name // If there are no usage examples, we only need to print the trigger, wrapped in code formatting if len(cmd.Arguments.Keys()) == 0 { diff --git a/handlers.go b/handlers.go index 5a3e4e4..c62e1dd 100644 --- a/handlers.go +++ b/handlers.go @@ -3,7 +3,7 @@ package framework // handlers.go // Everything required for commands to pass their own handlers to discordgo and the framework itself. -// handlers +// dGOhandlers // This list stores all the handlers that can be added to the bot // It's basically a passthroughs for discordgo.AddHandler, but having a list // allows them to be collected ahead of time and then added all at once diff --git a/interaction.go b/interaction.go index ca98d94..b053b36 100644 --- a/interaction.go +++ b/interaction.go @@ -1,44 +1,50 @@ package framework import ( + "fmt" "runtime" + "strings" "github.com/bwmarrin/discordgo" + "github.com/ubergeek77/tinylog" + errors "gitlab.com/tozd/go/errors" ) // -- 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, - Channel: discordgo.ApplicationCommandOptionChannel, - User: discordgo.ApplicationCommandOptionUser, - Role: discordgo.ApplicationCommandOptionRole, - Boolean: discordgo.ApplicationCommandOptionBoolean, - //SubCmd: discordgo.ApplicationCommandOptionSubCommand, - //SubCmdGrp: discordgo.ApplicationCommandOptionSubCommandGroup, + Int: discordgo.ApplicationCommandOptionInteger, + String: discordgo.ApplicationCommandOptionString, + Channel: discordgo.ApplicationCommandOptionChannel, + User: discordgo.ApplicationCommandOptionUser, + Role: discordgo.ApplicationCommandOptionRole, + Boolean: discordgo.ApplicationCommandOptionBoolean, + SubCmd: discordgo.ApplicationCommandOptionSubCommand, + SubCmdGrp: discordgo.ApplicationCommandOptionSubCommandGroup, } var genericError = "error executing command" -// getSlashCommandStruct -// Creates a slash command struct -// todo work on sub command stuff -func createSlashCommandStruct(info *CommandInfo) (st *discordgo.ApplicationCommand) { +func createApplicationChatCommand(info *CommandInfo) (st *discordgo.ApplicationCommand) { if info.Arguments == nil || len(info.Arguments.Keys()) < 1 { st = &discordgo.ApplicationCommand{ - Name: info.Trigger, - Description: info.Description, + Name: info.Name, + Description: info.Description, + Type: discordgo.ChatApplicationCommand, + IntegrationTypes: &info.IntegrationTypes, + Contexts: &info.InstallationContexts, } return } st = &discordgo.ApplicationCommand{ - Name: info.Trigger, - Description: info.Description, - Options: make([]*discordgo.ApplicationCommandOption, len(info.Arguments.Keys())), + Name: info.Name, + Description: info.Description, + Options: make([]*discordgo.ApplicationCommandOption, len(info.Arguments.Keys())), + Type: discordgo.ChatApplicationCommand, + IntegrationTypes: &info.IntegrationTypes, + Contexts: &info.InstallationContexts, } for i, k := range info.Arguments.Keys() { v, _ := info.Arguments.Get(k) @@ -50,46 +56,36 @@ func createSlashCommandStruct(info *CommandInfo) (st *discordgo.ApplicationComma sType = slashCommandTypes["String"] } optionStruct := discordgo.ApplicationCommandOption{ - Type: sType, - Name: k, - Description: vv.Description, - Required: vv.Required, + Type: sType, + Name: k, + Description: vv.Description, + Required: vv.Required, + Autocomplete: vv.AutoComplete, } - if vv.Choices != nil { - optionStruct.Choices = make([]*discordgo.ApplicationCommandOptionChoice, len(vv.Choices)) - for i, k := range vv.Choices { - optionStruct.Choices[i] = &discordgo.ApplicationCommandOptionChoice{ - Name: k, - Value: k, - } - } + if len(vv.Choices) > 0 { + optionStruct.Choices = vv.Choices } st.Options[i] = &optionStruct } 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)), +func createApplicationContextCommand(info *CommandInfo) (st *discordgo.ApplicationCommand) { + var context_type discordgo.ApplicationCommandType + switch info.Type { + case UserCommand: + context_type = discordgo.UserApplicationCommand + case MessageCommand: + context_type = discordgo.MessageApplicationCommand } - 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++ - } + st = &discordgo.ApplicationCommand{ + Name: info.Name, + Type: context_type, + IntegrationTypes: &info.IntegrationTypes, + Contexts: &info.InstallationContexts, } - return st + return } // -- Interaction Handlers -- @@ -99,81 +95,212 @@ func createSlashSubCmdStruct(info *CommandInfo, childCmds map[string]Command) (s func handleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { switch i.Type { case discordgo.InteractionApplicationCommand: - handleInteractionCommand(s, i) - break + handleApplicationCommand(s, i) case discordgo.InteractionMessageComponent: handleMessageComponents(s, i) + case discordgo.InteractionApplicationCommandAutocomplete: + handleAutoComplete(i) + } +} + +// handleApplicationCommand +// Handles a ApplicationCommand +func handleApplicationCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.ApplicationCommandData().CommandType { + case discordgo.ChatApplicationCommand: + handleChatApplicationCommand(s, i) + case discordgo.UserApplicationCommand, discordgo.MessageApplicationCommand: + handleApplicationContextCommand(i) } - return } -// handleInteractionCommand +// handleChatApplicationCommand // Handles a slash command -func handleInteractionCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { +func handleChatApplicationCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Let's check if this is a user command, if so lets handle it separately + if i.Interaction.Member == nil && i.Interaction.GuildID == "" { + handleUserApplicationChatCommand(s, i) + return + } + g := getGuild(i.GuildID) trigger := i.ApplicationCommandData().Name - if !IsAdmin(i.Member.User.ID) { - // Ignore the command if it is globally disabled - if g.IsGloballyDisabled(trigger) { - ErrorResponse(i.Interaction, "Command is globally disabled", trigger) - return - } - // Ignore the command if this channel has blocked the command - if g.CommandIsDisabledInChannel(trigger, i.ChannelID) { - ErrorResponse(i.Interaction, "Command is disabled in this channel!", trigger) - return - } + log.Debugf("Handling command %s", trigger) - // Ignore any message if the user is banned from using the bot - if !g.MemberOrRoleIsWhitelisted(i.Member.User.ID) || g.MemberOrRoleIsIgnored(i.Member.User.ID) { - return - } + command := commands[trigger] - // Ignore the message if this channel is not whitelisted, or if it is ignored - if !g.ChannelIsWhitelisted(i.ChannelID) || g.ChannelIsIgnored(i.ChannelID) { - return - } + log.Debugf("Command %s found %#v", trigger, command) + + defer handleSlashCommandError(*i.Interaction) + command.Handlers["default"](&Context{ + Guild: g, + Cmd: *command.Info, + Args: *ParseInteractionArgs(i.ApplicationCommandData().Options), + Interaction: i.Interaction, + Message: &discordgo.Message{ + Member: i.Member, + Author: i.Member.User, + ChannelID: i.ChannelID, + GuildID: i.GuildID, + Content: "", + }, + Log: MakeModuleLogger(command.Info.Name), + }) +} + +// handleApplicationContextCommand +// Handles a context menu command +func handleApplicationContextCommand(i *discordgo.InteractionCreate) { + switch i.ApplicationCommandData().CommandType { + case discordgo.UserApplicationCommand: + handleUserContextCommand(i) + case discordgo.MessageApplicationCommand: + handleMessageContextCommand(i) } +} +// handleUserContextCommand +// Handles a user context command +func handleUserContextCommand(i *discordgo.InteractionCreate) { + trigger := i.ApplicationCommandData().Name + log.Debugf("Handling command %s", trigger) + command := commands[trigger] + log.Debugf("Command %s found %#v", trigger, command) + + defer handleSlashCommandError(*i.Interaction) + command.Handlers["default"](&Context{ + Guild: getGuild(i.GuildID), + Cmd: *command.Info, + Interaction: i.Interaction, + Message: &discordgo.Message{ + Author: i.User, + ChannelID: i.ChannelID, + Content: "", + }, + Log: MakeModuleLogger(command.Info.Name), + }) +} + +// handleMessageContextCommand +// Handles a message context command +func handleMessageContextCommand(i *discordgo.InteractionCreate) { + commandData := i.ApplicationCommandData() + trigger := commandData.Name + message := commandData.Resolved.Messages[commandData.TargetID] + + log.Debugf("Handling command %s", trigger) command := commands[trigger] - 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, - Args: *ParseInteractionArgs(i.ApplicationCommandData().Options), - Interaction: i.Interaction, - Message: &discordgo.Message{ - Member: i.Member, - Author: i.Member.User, - ChannelID: i.ChannelID, - GuildID: i.GuildID, - Content: "", + log.Debugf("Command %s found %#v", trigger, command) + + defer handleSlashCommandError(*i.Interaction) + command.Handlers["default"](&Context{ + Guild: getGuild(i.GuildID), + Cmd: *command.Info, + Interaction: i.Interaction, + Message: message, + Log: MakeModuleLogger(command.Info.Name), + }) +} + +// handleUserApplicationChatCommand +// Handles a user application slash command +func handleUserApplicationChatCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + trigger := i.ApplicationCommandData().Name + log.Debugf("Handling user command %s", trigger) + command := commands[trigger] + log.Debugf("Command %s found %#v", trigger, command) + + defer handleSlashCommandError(*i.Interaction) + command.Handlers["default"](&Context{ + Guild: nil, + Cmd: *command.Info, + Args: *ParseInteractionArgs(i.ApplicationCommandData().Options), + Interaction: i.Interaction, + Message: &discordgo.Message{ + Member: &discordgo.Member{ + User: i.User, }, - }) - return - } + Author: i.User, + ChannelID: i.ChannelID, + Content: "", + }, + Log: MakeModuleLogger(command.Info.Name), + }) + } func handleMessageComponents(s *discordgo.Session, i *discordgo.InteractionCreate) { - content := "Currently testing customid " + i.MessageComponentData().CustomID - i.Message.Embeds[0].Description = content - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - // Buttons also may update the message which they was attached to. - // Or may just acknowledge (InteractionResponseDeferredMessageUpdate) that the event was received and not update the message. - // To update it later you need to use interaction response edit endpoint. - Type: discordgo.InteractionResponseUpdateMessage, - Data: &discordgo.InteractionResponseData{ - TTS: false, - Embeds: i.Message.Embeds, - }, + componentName := i.MessageComponentData().CustomID + if _, ok := componentHandlers[componentName]; !ok { + log.Errorf("No component found for %s", componentName) + return + } + message := &discordgo.Message{ + Content: "", + } + + if i.Message != nil { + message = i.Message + } + if i.Member != nil { + message.Member = i.Member + } + if i.GuildID != "" { + message.GuildID = i.GuildID + } + if i.ChannelID != "" { + message.ChannelID = i.ChannelID + } + if i.User != nil { + message.Author = i.User + } + if i.Member != nil { + message.Member = i.Member + } + + defer handleSlashCommandError(*i.Interaction) + componentHandlers[componentName](&Context{ + Guild: getGuild(i.GuildID), + Cmd: CommandInfo{}, + Args: map[string]CommandArg{}, + Interaction: i.Interaction, + Message: message, + Log: tinylog.NewTaggedLogger(fmt.Sprintf("Component: %s", componentName), botContextLoggerColor), }) - return +} + +func handleAutoComplete(i *discordgo.InteractionCreate) { + commandName := i.ApplicationCommandData().Name + for _, option := range i.ApplicationCommandData().Options { + if option.Focused { + command := commands[strings.ToLower(commandName)] + if command == nil { + log.Errorf("No command found for autocomplete %s", commandName) + return + } + + // All AutoComplete handlers are prefixed with "ac:" + handler := command.Handlers[fmt.Sprintf("ac:%s", strings.ToLower(option.Name))] + + if handler == nil { + log.Errorf("No handler found for autocomplete %s", commandName) + return + } + + defer handleAutoCompleteError(*i.Interaction, "Error executing autocomplete") + + handler(&Context{ + Guild: getGuild(i.GuildID), + Cmd: *command.Info, + Args: *ParseInteractionArgs(i.ApplicationCommandData().Options), + Interaction: i.Interaction, + Log: tinylog.NewTaggedLogger(fmt.Sprintf("Handler: %s", commandName), botContextLoggerColor), + }) + } + } + } // -- Slash Argument Parsing Helpers -- @@ -203,7 +330,7 @@ func ParseInteractionArgsR(options []*discordgo.ApplicationCommandInteractionDat Value: v.StringValue(), } if v.Options != nil { - ParseInteractionArgsR(v.Options, *&args) + ParseInteractionArgsR(v.Options, args) } } } @@ -227,11 +354,29 @@ func RemoveGuildSlashCommands(guildID string) { } } +// RemoveAllSlashCommands +// Removes all slash commands. +func RemoveAllSlashCommands() { + commands, err := Session.ApplicationCommands(Session.State.User.ID, "") + if err != nil { + log.Errorf("Error getting all slash commands %s", err) + return + } + for _, k := range commands { + err = Session.ApplicationCommandDelete(Session.State.User.ID, "", k.ID) + if err != nil { + log.Errorf("error deleting slash command %s %s %s", k.Name, k.ID, err) + continue + } + } +} + func handleSlashCommandError(i discordgo.Interaction) { if r := recover(); r != nil { - log.Warningf("Recovering from panic: %s", r) + e := errors.WithStack(r.(error)) + log.Warningf("Recovering from panic: %s", e) log.Warningf("Sending Error report to admins") - SendErrorReport(i.GuildID, i.ChannelID, i.Member.User.ID, "Error!", r.(runtime.Error)) + SendErrorReport(i.GuildID, i.ChannelID, i.Member.User.ID, "Error!", e) message, err := Session.InteractionResponseEdit(&i, &discordgo.WebhookEdit{ Content: &genericError, }) @@ -246,7 +391,13 @@ func handleSlashCommandError(i discordgo.Interaction) { log.Errorf("err sending message %s", err) } Session.ChannelMessageDelete(i.ChannelID, message.ID) - return } - return +} + +func handleAutoCompleteError(i discordgo.Interaction, message string) { + 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)) + } } diff --git a/response.go b/response.go index 941afb6..6150f25 100644 --- a/response.go +++ b/response.go @@ -1,6 +1,7 @@ package framework import ( + "reflect" "time" "github.com/bwmarrin/discordgo" @@ -13,7 +14,7 @@ import ( // Stores the components for response // allows for functions to add data type ResponseComponents struct { - Components []discordgo.MessageComponent + Components []discordgo.ActionsRow SelectMenuOptions []discordgo.SelectMenuOption } @@ -27,6 +28,7 @@ type Response struct { Reply bool Embed *discordgo.MessageEmbed ResponseComponents *ResponseComponents + Content string } // CreateField @@ -50,11 +52,61 @@ func CreateEmbed(color int, title string, description string, fields []*discordg } } -// CreateComponentFields -// Returns a slice of a Message Component, containing a singular ActionsRow -func CreateComponentFields() []discordgo.MessageComponent { - return []discordgo.MessageComponent{ - discordgo.ActionsRow{}, +func (c *ResponseComponents) FindButton(customID string) (*discordgo.Button, bool) { + log.Debugf("%#v", c.Components) + for _, row := range c.Components { + for _, component := range row.Components { + log.Debugf("%#v", component) + if component.Type() == discordgo.ButtonComponent { + ctype := reflect.TypeOf(component).Kind() + if ctype == reflect.Ptr { + if component.(*discordgo.Button).CustomID == customID { + return component.(*discordgo.Button), true + } + } else { + if component.(discordgo.Button).CustomID == customID { + return component.(*discordgo.Button), true + } + } + + } + } + } + return nil, false +} + +func (c *ResponseComponents) FindDropDown(customID string) (*discordgo.SelectMenu, bool) { + for _, row := range c.Components { + for _, component := range row.Components { + if component.(discordgo.SelectMenu).CustomID == customID { + return component.(*discordgo.SelectMenu), true + } + } + } + return nil, false +} + +func (c *ResponseComponents) SetButton(customID string, button discordgo.Button, row ...int) { + if len(row) == 0 { + row = append(row, 0) + } + c.Components[row[0]].Components = append(c.Components[row[0]].Components, &button) +} + +func (c *ResponseComponents) ReplaceButton(customID string, button discordgo.Button) { + for i, row := range c.Components { + for j, component := range row.Components { + ctype := reflect.TypeOf(component).Kind() + if ctype == reflect.Ptr { + if component.(*discordgo.Button).CustomID == customID { + c.Components[i].Components[j] = &button + } + } else { + if component.(discordgo.Button).CustomID == customID { + c.Components[i].Components[j] = &button + } + } + } } } @@ -62,10 +114,17 @@ func CreateComponentFields() []discordgo.MessageComponent { // Create a response object for a guild, which starts off as an empty Embed which will have fields added to it // The response starts with some "auditing" information // The embed will be finalized in .Send() -func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response { +func NewResponse(ctx *Context, messageComponents bool, ephemeral bool, use_embed ...bool) *Response { + var embed *discordgo.MessageEmbed + if len(use_embed) == 0 { + use_embed = append(use_embed, true) + } + if use_embed[0] { + embed = CreateEmbed(0, "", "", nil) + } r := &Response{ Ctx: ctx, - Embed: CreateEmbed(0, "", "", nil), + Embed: embed, ResponseComponents: &ResponseComponents{ Components: nil, SelectMenuOptions: nil, @@ -75,7 +134,7 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response Reply: ephemeral, } if messageComponents { - r.ResponseComponents.Components = CreateComponentFields() + r.ResponseComponents.Components = MakeActionRow() r.ResponseComponents.SelectMenuOptions = []discordgo.SelectMenuOption{} } if r.Loading && ctx.Interaction != nil { @@ -97,31 +156,105 @@ func NewResponse(ctx *Context, messageComponents bool, ephemeral bool) *Response return r } +// ReconstructResponse +// Reconstruct a response object from a given context. Only for interactions +func ReconstructResponse(ctx *Context) *Response { + if ctx.Interaction == nil { + log.Errorf("Tried to reconstruct response from context without interaction") + return nil + } + if ctx.Interaction.Message == nil { + log.Errorf("Tried to reconstruct response from context without interaction message") + return nil + } + if len(ctx.Interaction.Message.Embeds) == 0 { + log.Errorf("Tried to reconstruct response from context without embeds") + return nil + } + log.Debugf("Reconstructing response from context %#v", ctx.Interaction) + r := &Response{ + Ctx: ctx, + Embed: ctx.Interaction.Message.Embeds[0], + ResponseComponents: &ResponseComponents{ + Components: ConvertMessageComponent(ctx.Interaction.Message.Components), + }, + Loading: ctx.Cmd.IsTyping, + Ephemeral: ctx.Interaction.Message.Flags == 1<<6, + Reply: false, + } + return r +} + +// ConvertComponent +// Properly Type Asserts a MessageComponent to any of the possible types, and returns it +// func ConvertComponent[K discordgo.MessageComponent](component discordgo.MessageComponent) (K, bool) {} + +// ConvertMessageComponent +// Converts the components on the message struct to an array of ActionsRow +func ConvertMessageComponent(components []discordgo.MessageComponent) []discordgo.ActionsRow { + var rows []discordgo.ActionsRow + log.Debugf("Converting message components: %#v", components) + for _, component := range components { + if row, ok := component.(discordgo.ActionsRow); ok { + rows = append(rows, row) + } else if row, ok := component.(*discordgo.ActionsRow); ok { + rows = append(rows, *row) + } + } + return rows +} + +// MakeActionRow +// Returns a slice of a Message Component, containing a singular ActionsRow +func MakeActionRow() []discordgo.ActionsRow { + return make([]discordgo.ActionsRow, 1) +} + +// SerializeActionRow +// Converts a slice of ActionsRow to a slice of Message Components +func SerializeActionRow(row []discordgo.ActionsRow) *[]discordgo.MessageComponent { + var components []discordgo.MessageComponent + for _, r := range row { + components = append(components, r) + } + return &components +} + +// ConvertToMessageComponent +// Converts a slice of Message Components to a slice of Message Components +func ConvertToMessageComponent[T []discordgo.MessageComponent](component T) *[]discordgo.MessageComponent { + if c, ok := (any(component).([]discordgo.MessageComponent)); ok { + return &c + } + return nil +} + // -- Fields -- // AppendField // Create a new basic field and append it to an existing Response -func (r *Response) AppendField(name string, value string, inline bool) { +func (r *Response) AppendField(name string, value string, inline bool) *Response { r.Embed.Fields = append(r.Embed.Fields, CreateField(name, value, inline)) + return r } // PrependField // Create a new basic field and prepend it to an existing Response -func (r *Response) PrependField(name string, value string, inline bool) { +func (r *Response) PrependField(name string, value string, inline bool) *Response { fields := []*discordgo.MessageEmbedField{CreateField(name, value, inline)} r.Embed.Fields = append(fields, r.Embed.Fields...) + return r } // AppendUsage // Add the command usage to the response. Intended for syntax error responses -func (r *Response) AppendUsage() { +func (r *Response) AppendUsage() *Response { if r.Ctx.Cmd.Description == "" { r.AppendField("Command description:", "no description", false) - return + return r } r.AppendField("Command description:", r.Ctx.Cmd.Description, false) - //r.AppendField("Command usage:", r.Ctx.Guild.GetCommandUsage(r.Ctx.Cmd), false) - + return r } // -- Message Components -- @@ -131,7 +264,7 @@ func CreateButton(label string, style discordgo.ButtonStyle, customID string, ur Label: label, Style: style, Disabled: disabled, - Emoji: discordgo.ComponentEmoji{}, + Emoji: nil, URL: url, CustomID: customID, } @@ -149,17 +282,22 @@ func CreateDropDown(customID string, placeholder string, options []discordgo.Sel // AppendButton // Appends a button -func (r *Response) AppendButton(label string, style discordgo.ButtonStyle, url string, customID string, rowID int) { - row := r.ResponseComponents.Components[rowID].(discordgo.ActionsRow) - row.Components = append(row.Components, CreateButton(label, style, customID, url, false)) - r.ResponseComponents.Components[rowID] = row +func (r *Response) AppendButton(label string, style discordgo.ButtonStyle, url string, customID string, rowID ...int) { + if len(rowID) == 0 { + rowID = append(rowID, 0) + } + if r.ResponseComponents.Components == nil { + r.ResponseComponents.Components = MakeActionRow() + } + button := CreateButton(label, style, customID, url, false) + r.ResponseComponents.SetButton(customID, *button, rowID...) } -//AppendDropDown +// AppendDropDown // Adds a DropDown component func (r *Response) AppendDropDown(customID string, placeholder string, noNewRow bool) { if noNewRow { - row := r.ResponseComponents.Components[0].(discordgo.ActionsRow) + row := r.ResponseComponents.Components[0] row.Components = append(row.Components, CreateDropDown(customID, placeholder, r.ResponseComponents.SelectMenuOptions)) r.ResponseComponents.Components[0] = row } else { @@ -176,26 +314,50 @@ func (r *Response) AppendDropDown(customID string, placeholder string, noNewRow } } +// PrependContent +// Prepend content to the response +func (r *Response) PrependContent(content string) *Response { + r.Content = content + r.Content + return r +} + // Send // Send a compiled response -func (r *Response) Send(success bool, title string, description string) { +func (r *Response) Send(success bool, title, description string) { + r.SendComplex(SendComplex{ + Success: success, + Title: title, + Description: description, + }) +} + +type SendComplex struct { + Success bool + Title string + Description string +} + +// SendComplex +// SendComplex a compiled response +func (r *Response) SendComplex(options SendComplex) { // Determine what color to use based on the success state var color int - if success { + if options.Success { color = ColorSuccess } else { // On failure, also append the command usage r.AppendUsage() color = ColorFailure } - - // Fill out the main embed - r.Embed.Title = title - r.Embed.Description = description - r.Embed.Color = color + if r.Embed != nil { + // Fill out the main embed + r.Embed.Title = options.Title + r.Embed.Description = options.Description + r.Embed.Color = color + } // If guild is nil, this is intended to be sent to Bot Admins - if r.Ctx.Guild == nil { + if r.Ctx.Guild == nil && r.Ctx.Interaction == nil { for admin := range botAdmins { dmChannel, dmCreateErr := Session.UserChannelCreate(admin) if dmCreateErr != nil { @@ -206,7 +368,7 @@ func (r *Response) Send(success bool, title string, description string) { } _, dmSendErr := Session.ChannelMessageSendComplex(dmChannel.ID, &discordgo.MessageSend{ Embed: r.Embed, - Components: r.ResponseComponents.Components, + Components: *SerializeActionRow(r.ResponseComponents.Components), }) if dmSendErr != nil { // Since error reports also use DMs, sending this as an error report would be redundant @@ -221,109 +383,23 @@ func (r *Response) Send(success bool, title string, description string) { // If this is a interaction (slash command) // Run it as a interaction response and then return early if r.Ctx.Interaction != nil { - // Some commands take a while to load - // Slash commands expect a response in 3 seconds or the interaction gets invalidated - if r.Loading { - // Check to see if the command is ephemeral (only shown to the user) - if r.Ephemeral { - _, err := Session.InteractionResponseEdit(r.Ctx.Interaction, &discordgo.WebhookEdit{ - Components: &r.ResponseComponents.Components, - Embeds: &[]*discordgo.MessageEmbed{ - r.Embed, - }, - }) - // Just in case the interaction gets removed. - if err != nil { - if err != nil { - SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send interaction messages", err) - } - if r.Ctx.Guild.Info.ResponseChannelId != "" { - _, err = Session.ChannelMessageSendEmbed(r.Ctx.Guild.Info.ResponseChannelId, r.Embed) - - } else { - _, err = Session.ChannelMessageSendEmbed(r.Ctx.Message.ChannelID, r.Embed) - } - - if err != nil { - SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send message", err) - } - } - } else { - _, err := Session.InteractionResponseEdit(r.Ctx.Interaction, &discordgo.WebhookEdit{ - Content: ToPtr[string](""), - Embeds: &[]*discordgo.MessageEmbed{ - r.Embed, - }, - Components: &r.ResponseComponents.Components, - }) - // Just in case the interaction gets removed. - if err != nil { - _, err := Session.ChannelMessageSendEmbed(r.Ctx.Guild.Info.ResponseChannelId, r.Embed) - if err != nil { - _, err = Session.ChannelMessageSendEmbed(r.Ctx.Message.ChannelID, r.Embed) - if err != nil { - } - } - } - } - r.Loading = false - return - } - // Check to see if the command is ephemeral (only shown to the user) - if r.Ephemeral { - Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ - // Ephemeral is type 64 don't ask why - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: 1 << 6, - Embeds: []*discordgo.MessageEmbed{ - r.Embed, - }, - Components: r.ResponseComponents.Components, - }, - }) - return - } - - // Default response for interaction - err := Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Embeds: []*discordgo.MessageEmbed{ - r.Embed, - }, - Components: r.ResponseComponents.Components, - }, - }) - if err != nil { - if err != nil { - SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send interaction messages", err) - } - if r.Ctx.Guild.Info.ResponseChannelId != "" { - _, err = Session.ChannelMessageSendEmbed(r.Ctx.Guild.Info.ResponseChannelId, r.Embed) - - } else { - _, err = Session.ChannelMessageSendEmbed(r.Ctx.Message.ChannelID, r.Embed) - } - - if err != nil { - SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send message", err) - } - } + r.SendInteraction() return } + // Try sending the response in the configured output channel // If that fails, try sending the response in the current channel // If THAT fails, send an error report _, err := Session.ChannelMessageSendComplex(r.Ctx.Guild.Info.ResponseChannelId, &discordgo.MessageSend{ Embed: r.Embed, - Components: r.ResponseComponents.Components, + Components: *SerializeActionRow(r.ResponseComponents.Components), + Content: r.Content, }) if err != nil && r.Reply { // Reply to user if no output channel _, err = ReplyToUser(r.Ctx.Message.ChannelID, &discordgo.MessageSend{ Embed: r.Embed, - Components: r.ResponseComponents.Components, + Components: *SerializeActionRow(r.ResponseComponents.Components), Reference: &discordgo.MessageReference{ MessageID: r.Ctx.Message.ID, ChannelID: r.Ctx.Message.ChannelID, @@ -332,6 +408,7 @@ func (r *Response) Send(success bool, title string, description string) { AllowedMentions: &discordgo.MessageAllowedMentions{ Parse: []discordgo.AllowedMentionType{}, }, + Content: r.Content, }) if err != nil { SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Message.ChannelID, r.Ctx.Message.Author.ID, "Ultimately failed to send bot response", err) @@ -340,8 +417,150 @@ func (r *Response) Send(success bool, title string, description string) { // 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, + Components: *SerializeActionRow(r.ResponseComponents.Components), + Content: r.Content, + }) + } +} + +// SendInteraction +// Send a response to an interaction +func (r *Response) SendInteraction() { + var embeds []*discordgo.MessageEmbed + + if r.Embed != nil { + embeds = []*discordgo.MessageEmbed{r.Embed} + } + + // Some commands take a while to load + // Slash commands expect a response in 3 seconds or the interaction gets invalidated + if r.Loading { + var err error + // Check to see if the command is ephemeral (only shown to the user) + components := SerializeActionRow(r.ResponseComponents.Components) + webhookEdit := &discordgo.WebhookEdit{ + Content: ToPtr(r.Content), + Components: components, + } + + if embeds != nil { + webhookEdit.Embeds = &embeds + } + + if r.Ephemeral { + _, err = Session.InteractionResponseEdit(r.Ctx.Interaction, webhookEdit) + } else { + _, err = Session.InteractionResponseEdit(r.Ctx.Interaction, webhookEdit) + } + + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send interaction messages", err) + } + + r.Loading = false + return + } + // Check to see if the command is ephemeral (only shown to the user) + if r.Ephemeral { + responseData := &discordgo.InteractionResponseData{ + Flags: 1 << 6, + Components: *SerializeActionRow(r.ResponseComponents.Components), + Content: r.Content, + } + + if embeds != nil { + responseData.Embeds = embeds + } + + err := Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: responseData, }) + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send interaction messages", err) + } + return + } + + // Default response for interaction + err := Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: embeds, + Components: *SerializeActionRow(r.ResponseComponents.Components), + Content: r.Content, + }, + }) + + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send interaction messages", err) + if r.Ctx.Guild.Info.ResponseChannelId != "" { + _, err = Session.ChannelMessageSendEmbed(r.Ctx.Guild.Info.ResponseChannelId, r.Embed) + + } else { + _, err = Session.ChannelMessageSendEmbed(r.Ctx.Message.ChannelID, r.Embed) + } + + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Interaction.ChannelID, r.Ctx.Message.Author.ID, "Unable to send message", err) + } + } +} + +// -- Response Editing -- + +// EditButtonDisabled +// Edit a button to be disabled +func (r *Response) EditButtonDisabled(buttonID string) { + r.EditButtonComplex(buttonID, "", 0, "", true) +} + +// EditButtonComplex +// Edit a button +func (r *Response) EditButtonComplex(buttonID string, label string, style discordgo.ButtonStyle, url string, disabled bool) { + button, ok := r.ResponseComponents.FindButton(buttonID) + if !ok { + log.Errorf("Could not find button with ID %s", buttonID) + return + } + + if label != "" { + button.Label = label + } + if style != 0 { + button.Style = style + } + if url != "" { + button.URL = url + } + + if disabled { + button.Disabled = true + } + r.ResponseComponents.ReplaceButton(buttonID, *button) +} + +// Edit +// Edit a response +func (r *Response) Edit() { + // Stupid fix for https://arc.net/l/quote/rawqeoye + // Follow up messages have to be ephemeral, even if they are just edits... + if r.Ctx.Message.GuildID == "" { + log.Errorf("Cannot edit a message in a DM (blame Discord)") + return + } + + component := SerializeActionRow(r.ResponseComponents.Components) + + log.Debugf("Editing response with components: %#v", component) + _, err := Session.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: r.Ctx.Interaction.Message.ChannelID, + ID: r.Ctx.Interaction.Message.ID, + Embed: r.Embed, + Components: component, + }) + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Message.ChannelID, r.Ctx.Message.Author.ID, "Failed to edit message", err) } } diff --git a/util.go b/util.go index 6be4658..2b3aeb2 100644 --- a/util.go +++ b/util.go @@ -10,6 +10,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/dlclark/regexp2" + "github.com/ubergeek77/tinylog" ) // util.go @@ -305,49 +306,53 @@ func createDisplayDurationString(content string) (str string) { break } str += prefixChar + "Second" - break case "m": if multiplier > 1 { str += prefixChar + fmt.Sprintf("%d Minutes", multiplier) - break } str += prefixChar + fmt.Sprintf("%d Minute", multiplier) - break case "h": if multiplier > 1 { str += prefixChar + fmt.Sprintf("%d Hours", multiplier) break } str += prefixChar + fmt.Sprintf("%d Hours", multiplier) - break case "d": if multiplier > 1 { str += prefixChar + fmt.Sprintf("%d Days", multiplier) break } str += prefixChar + fmt.Sprintf("%d Day", multiplier) - break case "w": if multiplier > 1 { str += prefixChar + fmt.Sprintf("%d Weeks", multiplier) break } str += prefixChar + fmt.Sprintf("%d Week", multiplier) - break case "y": if multiplier > 1 { str += prefixChar + fmt.Sprintf("%d Years", multiplier) break } str += prefixChar + fmt.Sprintf("%d Year", multiplier) - break - default: - break } } return } +// CreateOptionChoiceFromSlice +// Given a slice of strings, create a slice of ApplicationCommandOptionChoice objects +func CreateOptionChoiceFromSlice(slice []string) []*discordgo.ApplicationCommandOptionChoice { + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0) + for _, v := range slice { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: v, + Value: v, + }) + } + return choices +} + func FindAllString(re *regexp2.Regexp, s string) []string { var matches []string m, _ := re.FindStringMatch(s) @@ -381,8 +386,19 @@ func dgoLog(msgL, caller int, format string, a ...interface{}) { } } +// MakeModuleLogger +// Makes a logger for a module +func MakeModuleLogger(module string) *tinylog.Logger { + cfg := tinylog.NewConfig() + if debugMode { + cfg.LogLevel = tinylog.DebugLevel + } + cfg.LogPrefix = tinylog.GenerateTag(fmt.Sprintf("Module: %s", module), botContextLoggerColor, cfg) + return tinylog.NewLogger(cfg) +} + // ToPtr // quick func to turn anything into a pointer func ToPtr[T any](v T) *T { return &v -} \ No newline at end of file +} diff --git a/workers.go b/workers.go deleted file mode 100644 index 614a31a..0000000 --- a/workers.go +++ /dev/null @@ -1,55 +0,0 @@ -package framework - -import ( - "sync" - "time" -) - -// workers.go -// This file contains everything for adding and managing workers - -// workerLock -// A map that stores mutexes for the background workers -// These will be used to determine when the workers have exited gracefully -// If a worker is still locked, then it has not exited -var workerLock = make(map[int]*sync.Mutex) - -// workers -// The list of workers that are to be pre-registered before the bot starts, then all executed in the background -var workers []func() - -// continueLoop -// This boolean will be changed to false when the bot is trying to shut down -// All the background workers are looping on this being true, meaning they will stop when it is false -var continueLoop = true - -// AddWorker -// Given a function that is passed through, append it to the list of worker functions -func AddWorker(worker func()) { - workers = append(workers, worker) -} - -// startWorkers -// Go through the list of workers than have been added to the list, and execute them all in the background -func startWorkers() { - // Iterate over all the workers - for i, worker := range workers { - // Create a mutex for this worker - workerLock[i] = &sync.Mutex{} - - // Start a goroutine for this worker, which starts it in the background - go func(worker func(), i int) { - // Lock the worker; this will be used in graceful termination - workerLock[i].Lock() - - // Run the worker once per second, forever, until a TERM signal breaks this loop - for continueLoop { - worker() - time.Sleep(time.Second) - } - - // The loop has stopped. Unlock the worker - workerLock[i].Unlock() - }(worker, i) - } -} diff --git a/workers/workers.go b/workers/workers.go new file mode 100644 index 0000000..ab06960 --- /dev/null +++ b/workers/workers.go @@ -0,0 +1,91 @@ +package workers + +// workers.go +// This package contains the necessary code to schedule reoccurring events +// Workers can manipulate different parts of the bot and are responsible for +// Mutes, TempBans, Presence updates, and other required things +// Commands can also register workers with the manager + +// todo clean up the documentation + +// WORKERS RUN MULTIPLE TIMES WHILE THE BOT IS RUNNING +// JOBS ARE THE ACTUAL GOCRON VERSION OF the WORKER + +import ( + "time" + + "github.com/go-co-op/gocron" + tlog "github.com/ubergeek77/tinylog" +) + +var wlog = tlog.NewTaggedLogger("WorkerManager", tlog.NewColor("38;5;111")) + +// WorkerManager is an easy way to manage workers. +type WorkerManager struct { + Scheduler *gocron.Scheduler + Workers map[string]Worker + Jobs []*gocron.Job + IsRunning bool +} + +// Worker +// Describes a worker. +type Worker struct { + Duration string + WorkerFunc func() +} + +func InitializeManager(loc *time.Location) *WorkerManager { + wrk := &WorkerManager{ + Scheduler: gocron.NewScheduler(loc), + Workers: make(map[string]Worker), + IsRunning: false, + } + wrk.Scheduler.TagsUnique() + return wrk +} + +// Start +// Will start all the workers via the scheduler. +func (m *WorkerManager) Start() { + m.Scheduler.StartAsync() + m.IsRunning = true +} + +// StopWorkers +// Will stop all the workers via the scheduler. +func (m *WorkerManager) StopWorkers() { + m.Scheduler.StopBlockingChan() + m.IsRunning = false +} + +// AddWorker +// Adds a worker to the internal worker map. +func (m *WorkerManager) AddWorker(tag string, worker Worker) { + m.Workers[tag] = worker +} + +// AddWorkers +// registers all the workers to the scheduler. +func (m *WorkerManager) AddWorkers() { + for tag, worker := range m.Workers { + job, err := m.Scheduler.Cron(worker.Duration).Tag(tag).Do(worker.WorkerFunc) + if err != nil { + wlog.Errorf("Unable to register worker %s", tag) + wlog.Fatal(err.Error()) + } + m.Jobs = append(m.Jobs, job) + } +} + +// RemoveWorker +// Removes a worker from the scheduler. +func (m *WorkerManager) RemoveWorker() { + +} + +// AddWorkerOnce +// Easy way to add a single job to the scheduler. +func (m *WorkerManager) AddWorkerOnce() { + +}