diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16b8f2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +uberbot +.env +guilds/*.json +.vscode +.idea +.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3a79cfe --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "discordgo"] + path = discordgo + url = git@github.com:QPixel/discordgo diff --git a/core/arguments.go b/core/arguments.go new file mode 100644 index 0000000..01c2510 --- /dev/null +++ b/core/arguments.go @@ -0,0 +1,523 @@ +package core + +import ( + "errors" + "github.com/QPixel/orderedmap" + "github.com/bwmarrin/discordgo" + "strconv" + "strings" +) + +// Arguments.go +// File for all argument based functions which includes: parsing, creating, and more +// Woo + +// pixel wrote this + +// -- TypeDefs -- + +// ArgTypes +// A way to get type safety in AddArg +type ArgTypes string + +var ( + ArgOption ArgTypes = "option" + ArgContent ArgTypes = "content" + ArgFlag ArgTypes = "flag" +) + +// ArgTypeGuards +// A way to get type safety in AddArg +type ArgTypeGuards string + +var ( + Int ArgTypeGuards = "int" + String ArgTypeGuards = "string" + Channel ArgTypeGuards = "channel" + User ArgTypeGuards = "user" + Role ArgTypeGuards = "role" + Boolean ArgTypeGuards = "bool" + SubCmd ArgTypeGuards = "subcmd" + SubCmdGrp ArgTypeGuards = "subcmdgrp" + ArrString ArgTypeGuards = "arrString" +) + +// ArgInfo +// Describes a CommandInfo argument +type ArgInfo struct { + Match ArgTypes + TypeGuard ArgTypeGuards + Description string + Required bool + Flag bool + DefaultOption string + Choices []string + Regex string +} + +// CommandArg +// Describes what a cmd ctx will receive +type CommandArg struct { + info ArgInfo + Value interface{} +} + +// Arguments +// 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 string) *CommandInfo { + cI := &CommandInfo{ + Aliases: nil, + Arguments: orderedmap.New(), + Description: description, + Group: group, + Public: public, + IsTyping: false, + Trigger: trigger, + } + 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: "", + }) + return cI +} + +// AddFlagArg +// Adds a flag arg, which is a special type of argument +func (cI *CommandInfo) AddFlagArg(flag string, typeGuard ArgTypeGuards, match ArgTypes, description string, required bool, defaultOption string) *CommandInfo { + cI.Arguments.Set(flag, &ArgInfo{ + Description: description, + Required: required, + Flag: true, + Match: match, + TypeGuard: typeGuard, + DefaultOption: defaultOption, + 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 +} + +// -- Argument Parser -- + +// ParseArguments +// Parses the arguments into a pointer to an Arguments struct +func ParseArguments(args string, infoArgs *orderedmap.OrderedMap) *Arguments { + ar := make(Arguments) + if args == "" || len(infoArgs.Keys()) < 1 { + return &ar + } + // Split string on spaces to get every "phrase" + splitString := strings.Split(args, " ") + + // Current Position in the infoArgs map + currentPos := 0 + + // Keys of infoArgs + k := infoArgs.Keys() + + for i := 0; i < len(splitString); i++ { + for n := currentPos; n <= len(k); n++ { + if n > len(k)+1 || currentPos+1 > len(k) { + break + } + v, _ := infoArgs.Get(k[currentPos]) + vv := v.(*ArgInfo) + switch vv.Match { + case ArgOption: + // Lets first check the typeguard to see if the str matches the arg + if checkTypeGuard(splitString[i], vv.TypeGuard) { + // todo abstract this into handleArgOption + // Handle quoted ArgOptions separately + if strings.Contains(splitString[i], "\"") { + st := CommandArg{} + st, i = handleQuotedString(splitString, *vv, i) + ar[k[currentPos]] = st + currentPos++ + break + } + // Handle ArgOption + ar[k[currentPos]] = handleArgOption(splitString[i], *vv) + currentPos++ + break + } + if n+1 > len(splitString) { + break + } + // If the TypeGuard does not match check to see if the Arg is required or not + if vv.Required { + // Set the CommandArg to the default option, which is usually "" + ar[k[currentPos]] = CommandArg{ + info: *vv, + Value: vv.DefaultOption, + } + currentPos++ + break + } else { + // If it's not required, we set the CommandArg to "" + ar[k[currentPos]] = CommandArg{ + info: *vv, + Value: "", + } + currentPos++ + break + } + case ArgContent: + // Takes the splitString and currentPos to find how many more elements in the slice + // need to join together + contentString := "" + contentString, i = createContentString(splitString, i) + ar[k[currentPos]] = CommandArg{ + info: *vv, + Value: contentString, + } + break + default: + break + } + continue + } + } + return &ar +} + +/* Argument Parsing Helpers */ + +func createContentString(splitString []string, currentPos int) (string, int) { + str := "" + for i := currentPos; i < len(splitString); i++ { + str += splitString[i] + " " + currentPos = i + } + return strings.TrimSuffix(str, " "), currentPos +} + +func handleQuotedString(splitString []string, argInfo ArgInfo, currentPos int) (CommandArg, int) { + str := "" + splitString[currentPos] = strings.TrimPrefix(splitString[currentPos], "\"") + for i := currentPos; i < len(splitString); i++ { + if !strings.HasSuffix(splitString[i], "\"") { + str += splitString[i] + " " + } else { + str += strings.TrimSuffix(splitString[i], "\"") + currentPos = i + break + } + } + return CommandArg{ + info: argInfo, + Value: str, + }, currentPos +} + +func handleArgOption(str string, info ArgInfo) CommandArg { + return CommandArg{ + info: info, + Value: str, + } +} + +func checkTypeGuard(str string, typeguard ArgTypeGuards) bool { + switch typeguard { + case String: + return true + case Int: + if _, err := strconv.Atoi(str); err == nil { + return true + } + return false + case Boolean: + if _, err := strconv.ParseBool(str); err == nil { + return true + } + case Channel: + if isMatch, _ := MentionStringRegexes["channel"].MatchString(str); isMatch { + return true + } else if isMatch, _ := MentionStringRegexes["id"].MatchString(str); isMatch { + return true + } + case Role: + if isMatch, _ := MentionStringRegexes["role"].MatchString(str); isMatch { + return true + } else if isMatch, _ := MentionStringRegexes["id"].MatchString(str); isMatch { + return true + } + case User: + if isMatch, _ := MentionStringRegexes["user"].MatchString(str); isMatch { + return true + } else if isMatch, _ := MentionStringRegexes["id"].MatchString(str); isMatch { + return true + } + return false + case ArrString: + if isMatch, _ := TypeGuard["arrString"].MatchString(str); isMatch { + return true + } + return false + } + + return false +} + +/* Argument Casting s*/ + +// StringValue +// Returns the string value of the arg +func (ag CommandArg) StringValue() string { + if ag.Value == nil { + return "" + } + if v, ok := ag.Value.(string); ok { + return v + } else if v := strconv.FormatFloat(ag.Value.(float64), 'f', 2, 64); v != "" { + return v + } else if v = strconv.FormatBool(ag.Value.(bool)); v != "" { + return v + } + return "" +} + +// Int64Value +// Returns the int64 value of the arg +func (ag CommandArg) Int64Value() int64 { + if ag.Value == nil { + return 0 + } + if v, ok := ag.Value.(float64); ok { + return int64(v) + } else if v, err := strconv.ParseInt(ag.StringValue(), 10, 64); err == nil { + return v + } + return 0 +} + +// IntValue +// Returns the int value of the arg +func (ag CommandArg) IntValue() int { + if ag.Value == nil { + return 0 + } + if v, ok := ag.Value.(float64); ok { + return int(v) + } else if v, err := strconv.Atoi(ag.StringValue()); err == nil { + return v + } + return 0 +} + +// FloatValue +// Returns the int value of the arg +func (ag CommandArg) FloatValue() float64 { + if ag.Value == nil { + return 0.0 + } + if v, ok := ag.Value.(float64); ok { + return v + } else if v, err := strconv.ParseFloat(ag.StringValue(), 64); err == nil { + return v + } + return 0.0 +} + +// BoolValue +// Returns the int value of the arg +func (ag CommandArg) BoolValue() bool { + if ag.Value == nil { + return false + } + stringValue := ag.StringValue() + if v, err := strconv.ParseBool(stringValue); err == nil { + return v + } + return false +} + +// ChannelValue is a utility function for casting value to a channel struct +// Returns a channel struct, partial channel struct, or a nil value +func (ag CommandArg) ChannelValue(s *discordgo.Session) (*discordgo.Channel, error) { + chanID := ag.StringValue() + if chanID == "" { + return &discordgo.Channel{ID: chanID}, errors.New("no channel id") + } + + if s == nil { + return &discordgo.Channel{ID: chanID}, errors.New("no session") + } + cleanedId := CleanId(chanID) + + if cleanedId == "" { + return &discordgo.Channel{ID: chanID}, errors.New("not an id") + } + ch, err := s.State.Channel(cleanedId) + + if err != nil { + ch, err = s.Channel(cleanedId) + if err != nil { + return &discordgo.Channel{ID: chanID}, errors.New("could not find channel") + } + } + return ch, nil +} + +// MemberValue is a utility function for casting value to a member struct +// Returns a user struct, partial user struct, or a nil value +func (ag CommandArg) MemberValue(s *discordgo.Session, g string) (*discordgo.Member, error) { + userID := ag.StringValue() + if userID == "" { + return &discordgo.Member{ + GuildID: g, + User: &discordgo.User{ + ID: userID, + }, + }, errors.New("no userid") + } + cleanedId := CleanId(userID) + if cleanedId == "" { + return &discordgo.Member{ + GuildID: g, + User: &discordgo.User{ + ID: userID, + }, + }, errors.New("invalid userid") + } + if s == nil { + return &discordgo.Member{ + GuildID: g, + User: &discordgo.User{ + ID: cleanedId, + }, + }, errors.New("session is nil") + } + u, err := s.State.Member(g, cleanedId) + + if err != nil { + u, err = s.GuildMember(g, cleanedId) + if err != nil { + return &discordgo.Member{ + GuildID: g, + User: &discordgo.User{ + ID: userID, + }, + }, errors.New("cant find user") + } + } + return u, nil +} + +// UserValue is a utility function for casting value to a member struct +// Returns a user struct, partial user struct, or a nil value +func (ag CommandArg) UserValue(s *discordgo.Session) (*discordgo.User, error) { + userID := ag.StringValue() + if userID == "" { + return &discordgo.User{ + ID: userID, + }, errors.New("no userid") + } + cleanedId := CleanId(userID) + if cleanedId == "" { + return &discordgo.User{ + ID: userID, + }, errors.New("invalid userid") + } + if s == nil { + return &discordgo.User{ + ID: userID, + }, errors.New("session is nil") + } + u, err := s.User(cleanedId) + + if err != nil { + return &discordgo.User{ + + ID: cleanedId, + }, errors.New("cant find user") + } + return u, nil +} + +// RoleValue is a utility function for casting value to a user struct +// Returns a user struct, partial user struct, or a nil value +func (ag CommandArg) RoleValue(s *discordgo.Session, gID string) (*discordgo.Role, error) { + roleID := ag.StringValue() + if roleID == "" { + return nil, errors.New("unable to find roleid") + } + cleanedId := CleanId(roleID) + if cleanedId == "" { + return &discordgo.Role{ + ID: roleID, + }, errors.New("invalid roleid") + } + if s == nil || gID == "" { + return &discordgo.Role{ID: cleanedId}, errors.New("no session (and/or) guild id") + } + r, err := s.State.Role(cleanedId, gID) + + if err != nil { + roles, err := s.GuildRoles(gID) + if err == nil { + for _, r = range roles { + if r.ID == cleanedId { + return r, nil + } + } + } + return &discordgo.Role{ID: roleID}, errors.New("could not find role") + } + return r, nil +} diff --git a/core/commands.go b/core/commands.go new file mode 100644 index 0000000..46d31c8 --- /dev/null +++ b/core/commands.go @@ -0,0 +1,314 @@ +package core + +import ( + "github.com/QPixel/orderedmap" + "strings" + + "github.com/bwmarrin/discordgo" +) + +// TODO Clean up this file +// commands.go +// This file contains everything required to add core commands to the bot, and parse commands from a message + +// GroupTypes +const ( + Moderation = "moderation" + Module = "module" + Utility = "utility" +) + +// 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 string // The group this command belongs to + ParentID string // The ID of the parent command + Public bool // Whether or not non-admins and non-mods can use this command + IsTyping bool // Whether or not the command will show a typing thing when ran. + 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 +} + +type ChildCommand map[string]map[string]Command + +// CustomCommand +// A type that defines a custom command +type CustomCommand struct { + Content string // The content of the custom command. Custom commands are just special strings after all + InvokeCount int64 // How many times the command has been invoked; int64 for easier use with json + Public bool // Whether or not non-admins and non-mods can use this command +} + +// commands +// All of the registered core commands (not custom commands) +// This is private so that other commands cannot modify it +var commands = make(map[string]Command) + +// childCommands +// All of the registered childcommands (subcmdgrps) +// 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 of 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) + +// 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) { + s := createSlashCommandStruct(info) + slashCommands[strings.ToLower(info.Trigger)] = *s +} + +// 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("%s", 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 +} + +// customCommandHandler +// Given a custom command, interpret and run it +func customCommandHandler(command CustomCommand, args []string, message *discordgo.Message) { + //TODO +} + +// 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 + } + } + + // If we are in DMs, ignore the message + // In the future, this can be used to handle special DM-only commands + if channel.Type == discordgo.ChannelTypeDM { + return + } + + // Ignore messages sent by the bot + if message.Author.ID == session.State.User.ID { + return + } + + g := getGuild(message.GuildID) + + trigger, argString := ExtractCommand(&g.Info, message.Content) + if trigger == nil { + return + } + isCustom := false + if _, ok := commands[commandAliases[*trigger]]; !ok { + if !g.IsCustomCommand(*trigger) { + return + } else { + isCustom = true + } + } + // 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 trigger + if g.TriggerIsDisabledInChannel(*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 + } + } + + // 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) + } + } + if !isCustom { + //Get the command to run + // Error Checking + command, ok := commands[commandAliases[*trigger]] + if !ok { + log.Errorf("Command was not found") + if IsAdmin(message.Author.ID) { + Session.MessageReactionAdd(message.ChannelID, message.ID, "<:redtick:861413502991073281>") + Session.ChannelMessageSendReply(message.ChannelID, "<:redtick:861413502991073281> Error! Command not found!", message.MessageReference) + } + 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) + } + 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, + }) + return + } + } +} + +// -- Helper Methods +func handleChildCommand(argString string, command Command, message *discordgo.Message, g *Guild) { + split := strings.SplitN(argString, " ", 2) + // First lets see if this subcmd even exists + v, ok := command.Info.Arguments.Get("subcmdgrp") + // the command doesn't even have a subcmdgrp arg, return + if !ok { + return + } + + choices := v.(*ArgInfo).Choices + subCmdExist := false + for _, choice := range choices { + if split[0] != choice { + continue + } else { + subCmdExist = true + break + } + } + if !subCmdExist { + command.Function(&Context{ + Guild: g, + Cmd: command.Info, + Args: nil, + Message: message, + }) + return + } + childCmd, ok := childCommands[command.Info.Trigger][split[0]] + if !ok || len(split) < 2 { + command.Function(&Context{ + Guild: g, + Cmd: command.Info, + Args: nil, + Message: message, + }) + return + } + childCmd.Function(&Context{ + Guild: g, + Cmd: childCmd.Info, + Args: *ParseArguments(split[1], childCmd.Info.Arguments), + Message: message, + }) + return +} diff --git a/core/consts.go b/core/consts.go new file mode 100644 index 0000000..18383f5 --- /dev/null +++ b/core/consts.go @@ -0,0 +1,24 @@ +package core + +import "github.com/dlclark/regexp2" + +type regex map[string]*regexp2.Regexp + +var ( + TimeRegexes = regex{ + "seconds": regexp2.MustCompile("^[0-9]+s$", 0), + "minutes": regexp2.MustCompile("^[0-9]+m$", 0), + "hours": regexp2.MustCompile("^[0-9]+h$", 0), + "days": regexp2.MustCompile("^[0-9]+d$", 0), + "weeks": regexp2.MustCompile("^[0-9]+w$", 0), + "years": regexp2.MustCompile("^[0-9]+y$", 0), + } + MentionStringRegexes = regex{ + "all": regexp2.MustCompile("<((@!?\\d+)|(#?\\d+)|(@&?\\d+))>", 0), + "role": regexp2.MustCompile("<((@&?\\d+))>", 0), + "user": regexp2.MustCompile("<((@!?\\d+))>", 0), + "channel": regexp2.MustCompile("<((#?\\d+))>", 0), + "id": regexp2.MustCompile("^[0-9]{18}$", 0), + } + TypeGuard = regex{} +) diff --git a/core/core.go b/core/core.go new file mode 100644 index 0000000..8e64932 --- /dev/null +++ b/core/core.go @@ -0,0 +1,201 @@ +package core + +import ( + "github.com/bwmarrin/discordgo" + tlog "github.com/ubergeek77/tinylog" + "os" + "os/signal" + + "strconv" + "strings" + "syscall" +) + +// core.go +// This file contains the main code responsible for driving core bot functionality + +// messageState +// Tells discordgo the amount of mesesages to cache +var messageState = 20 + +// log +// The logger for the core bot +var log = tlog.NewTaggedLogger("BotCore", tlog.NewColor("38;5;111")) + +// Session +// The Discord session, made public so commands can use it +var Session *discordgo.Session + +// BotAdmins +// A list of user IDs that are designated as "Bot Administrators" +// These don't get saved to .json, and must be added programmatically +// They receive some privileges higher than guild moderators +// This is a boolean map, because checking its values is dead simple this way +var botAdmins = make(map[string]bool) + +// BotToken +// A string of the current bot token, usually set by the main method +// Similar to BotAdmins, this isn't saved to .json and is added programmatically +var botToken = "" + +// ColorSuccess +// The color to use for response embeds reporting success +var ColorSuccess = 0x55F485 + +// ColorFailure +// The color to use for response embeds reporting failure +var ColorFailure = 0xF45555 + +// AddAdmin +// A function that allows admins to be added, but not removed +func AddAdmin(userId string) { + botAdmins[userId] = true +} + +// SetToken +// A function that allows a single token to be added, but not removed +func SetToken(token string) { + botToken = token +} + +// IsAdmin +// Allow commands to check if a user is an admin or not +// Since botAdmins is a boolean map, if they are not in the map, false is the default +func IsAdmin(userId string) bool { + return botAdmins[userId] +} + +// IsCommand +// Check if a given string is a command registered to the core bot +func IsCommand(trigger string) bool { + if _, ok := commands[strings.ToLower(trigger)]; ok { + return true + } + return false +} + +// Start uberbot! +func Start() { + // Load all the guilds + loadGuilds() + + // We need a token + if botToken == "" { + log.Fatalf("You have not specified a Discord bot token!") + } + + // Use the token to create a new session + var err error + Session, err = discordgo.New("Bot " + botToken) + + if err != nil { + log.Fatalf("Failed to create Discord session: %s", err) + } + // Setup State specific variables + Session.State.MaxMessageCount = messageState + Session.Identify.Intents = discordgo.MakeIntent(discordgo.IntentsAll) + + // 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 + AddHandler(commandHandler) + + // Add the slash command handler to the list of user-defined handlers + AddHandler(handleInteraction) + // Add the handlers to the session + addHandlers() + + // 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 + for userId := range botAdmins { + if user, err := GetUser(userId); err == nil { + numAdmins += 1 + log.Infof("Added bot admin: %s#%s", user.Username, user.Discriminator) + } else { + log.Errorf("Unable to lookup bot admin user ID: " + userId) + } + } + + if numAdmins == 0 { + log.Warning("You have not added any bot admins! Only moderators will be able to run commands, and permissions cannot be changed!") + } + // Register slash commands + slashChannel := make(chan string) + log.Info("Registering slash commands") + go AddSlashCommands("833901685054242846", slashChannel) + + // Bot ready + log.Info("Initialization complete! The bot is now ready.") + // Set the bots status + var timeSinceIdle = 91879201 + Session.UpdateStatusComplex(discordgo.UpdateStatusData{ + Activities: []*discordgo.Activity{ + { + Name: "Mega Man Battle Network", + Type: 0, + }, + }, + Status: "dnd", + AFK: true, + IdleSince: &timeSinceIdle, + }) + // Info about slash commands + log.Info(<-slashChannel) + // -- GRACEFUL TERMINATION -- // + + // 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) + + // 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) + + // 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() + } + + log.Info("All routines exited gracefully.") + + // Send our own signal to the instant sig channel + sigInstant <- syscall.SIGTERM + }() + + // Keep the thread blocked until the above goroutine finishes closing all workers, or until another TERM is received + <-sigInstant + + log.Info("Closing the Discord session...") + closeErr := Session.Close() + if closeErr != nil { + log.Errorf("An error occurred when closing the Discord session: %s", err) + return + } + + log.Info("Session closed.") +} diff --git a/core/fs.go b/core/fs.go new file mode 100644 index 0000000..7cdb4e6 --- /dev/null +++ b/core/fs.go @@ -0,0 +1,170 @@ +package core + +import ( + "encoding/json" + "io/ioutil" + "os" + "path" + "strings" + "sync" + "syscall" +) + +// fs.go +// This file contains functions that pertain to interacting with the filesystem, including mutex locking of files + +// GuildsDir +// The directory to use for reading and writing guild .json files. Defaults to ./guilds +var GuildsDir = "./guilds" + +// saveLock +// A map that stores mutexes for each guild, which will be locked every time that guild's data is written +// This ensures files are written to synchronously, avoiding file race conditions +var saveLock = make(map[string]*sync.Mutex) + +// loadGuilds +// Load all known guilds from the filesystem, from inside GuildsDir +func loadGuilds() { + // Check if the configured guild directory exists, and create it if otherwise + if _, existErr := os.Stat(GuildsDir); os.IsNotExist(existErr) { + mkErr := os.MkdirAll(GuildsDir, 0755) + if mkErr != nil { + log.Fatalf("Failed to create guild directory: %s", mkErr) + } + log.Warningf("There are no Guilds to load; data for new Guilds will be saved to: %s", GuildsDir) + + // There are no guilds to load, so we can return early + return + } + + // Get a list of files in the directory + files, rdErr := ioutil.ReadDir(GuildsDir) + if rdErr != nil { + log.Fatalf("Failed to read guild directory: %s", rdErr) + } + + // Iterate over each file + for _, file := range files { + // Ignore directories + if file.IsDir() { + continue + } + + // Get the file name, convert to lowercase so ".JSON" is also valid + fName := strings.ToLower(file.Name()) + + // File name must end in .json + if !strings.HasSuffix(fName, ".json") { + continue + } + + // Split ".json" from the string name, and check that the remaining characters: + // - Add up to at least 17 characters (it must be a Discord snowflake) + // - Are all numbers + guildId := strings.Split(fName, ".json")[0] + if len(guildId) < 17 || guildId != EnsureNumbers(guildId) { + continue + } + + // Even though we are reading files, we need to make sure we can write to this file later + fPath := path.Join(GuildsDir, fName) + err := syscall.Access(fPath, syscall.O_RDWR) + if err != nil { + log.Errorf("File \"%s\" is not writable; guild %s WILL NOT be loaded! (%s)", fPath, guildId, err) + continue + } + + // Try reading the file + jsonBytes, err := ioutil.ReadFile(fPath) + if err != nil { + log.Errorf("Failed to read \"%s\"; guild %s WILL NOT be loaded! (%s)", fPath, guildId, err) + continue + } + + // Unmarshal the json + var gInfo GuildInfo + err = json.Unmarshal(jsonBytes, &gInfo) + if err != nil { + log.Errorf("Failed to unmarshal \"%s\"; guild %s WILL NOT be loaded! (%s)", fPath, guildId, err) + continue + } + + // Add the loaded guild to the map + Guilds[guildId] = &Guild{ + ID: guildId, + Info: gInfo, + } + } + + if len(Guilds) == 0 { + log.Warningf("There are no guilds to load; data for new guilds will be saved to \"%s\"", GuildsDir) + return + } + + // :) + plural := "" + if len(Guilds) != 1 { + plural = "s" + } + + log.Infof("Loaded %d guild%s", len(Guilds), plural) +} + +// save +// Save a given guild object to .json +func (g *Guild) save() { + // See if a mutex exists for this guild, and create if not + if _, ok := saveLock[g.ID]; !ok { + saveLock[g.ID] = &sync.Mutex{} + } + + // Unlock writing when done + defer saveLock[g.ID].Unlock() + + // Mark this guild as locked before saving + saveLock[g.ID].Lock() + + // Create the output directory if it doesn't exist + // This is a fatal error, since no other guilds would be savable if this fails + if _, err := os.Stat(GuildsDir); os.IsNotExist(err) { + mkErr := os.Mkdir(GuildsDir, 0755) + if mkErr != nil { + log.Fatalf("Failed to create guild output directory: %s", mkErr) + } + } + + // Convert the guild object to text + jsonBytes, err := json.MarshalIndent(g.Info, "", " ") + if err != nil { + log.Fatalf("Failed marshalling JSON data for guild %s: %s", g.ID, err) + } + + // Write the contents to a file + outPath := path.Join(GuildsDir, g.ID+".json") + err = ioutil.WriteFile(outPath, jsonBytes, 0644) + if err != nil { + log.Fatalf("Write failed to %s: %s", outPath, err) + } +} + +// ReadDefaults +// TODO: WRITE DOCUMENTATION FOR THIS LMAO +func ReadDefaults(filePath string) (result []string) { + fPath := path.Clean(filePath) + if _, existErr := os.Stat(fPath); os.IsNotExist(existErr) { + log.Errorf("Failed to find \"%s\"; File WILL NOT be loaded! (%s)", fPath, existErr) + return + } + + jsonBytes, err := ioutil.ReadFile(fPath) + if err != nil { + log.Errorf("Failed to read \"%s\"; File WILL NOT be loaded! (%s)", fPath, err) + return + } + + err = json.Unmarshal(jsonBytes, &result) + if err != nil { + log.Errorf("Failed to unmarshal \"%s\"; File WILL NOT be loaded! (%s)", fPath, err) + } + return +} diff --git a/core/guilds.go b/core/guilds.go new file mode 100644 index 0000000..e73d3c2 --- /dev/null +++ b/core/guilds.go @@ -0,0 +1,1206 @@ +package core + +import ( + "errors" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" +) + +// guilds.go +// This file contains the structure of a guild, and all of the functions used to store and retrieve guild information + +// GuildInfo +// This is all of the settings and data that needs to be stored about a single guild +type GuildInfo struct { + AddedDate int64 `json:"addedDate"` // The date the bot was added to the server + Prefix string `json:"prefix"` // The bot prefix + ModeratorIds []string `json:"moderatorIds"` // The list of user/role IDs allowed to run mod-only commands + WhitelistIds []string `json:"whitelistIds"` // List of user/role Ids that a user MUST have one of in order to run any commands, including public ones + IgnoredIds []string `json:"ignoredIds"` // List of user/role IDs that can never run commands, even public ones + WhitelistedChannels []string `json:"whitelistedChannels"` // List of channel IDs of whitelisted channels. If this list is non-empty, then only channels in this list can be used to invoke commands (unless the invoker is a bot moderator) + IgnoredChannels []string `json:"ignoredChannels"` // A list of channel IDs where commands will always be ignored, unless the user is a bot admin + BannedWordDetector bool `json:"banned_word_detector"` // Whether or not to detect banned words + GuildBannedWords []string `json:"guild_banned_words"` // List of banned words and phrases in this guild. Can use a command to update list. + BannedWordDetectorRoles []string `json:"banned_word_detector_roles"` // List of roles that the bot will not ignore + BannedWordDetectorChannels []string `json:"banned_word_detector_channels"` // List of channels that the bot will detect + GlobalDisabledTriggers []string `json:"globalDisabledTriggers"` // List of BotCommand triggers that can't be used anywhere in this guild + ChannelDisabledTriggers map[string][]string `json:"channelDisabledTriggers"` // List of channel IDs and the list of triggers that can't be used in it + CustomCommands map[string]CustomCommand `json:"customCommands"` // The list of triggers and their corresponding outputs for custom commands + DeletePolicy bool `json:"deletePolicy"` // Whether or not to delete BotCommand messages after a user sends them + ResponseChannelId string `json:"responseChannelId"` // The channelID of the channel to use for responses by default + MuteRoleId string `json:"muteRoleId"` // The role ID of the Mute role + MutedUsers map[string]int64 `json:"mutedUsers"` // The list of muted users, and the Unix timestamp of when their mute expired + Storage map[string]interface{} `json:"storage"` // Generic storage available to store anything not specific to the core bot +} + +// Guild +// The definition of a guild, which is simply its ID and Info +type Guild struct { + ID string + Info GuildInfo +} + +// Guilds +// A map that stores the data for all known guilds +// We store pointers to the guilds, so that only one guild object is maintained across all contexts +// Otherwise, there will be information desync +var Guilds = make(map[string]*Guild) + +// muteLock +// A map to store mutexes for handling mutes for a server synchronously +var muteLock = make(map[string]*sync.Mutex) + +// getGuild +// Return a Guild object corresponding to the given guildId +// If the guild doesn't exist, initialize a new guild and save it before returning +// Return a pointer to the guild object and pass that around instead, to avoid information desync +func getGuild(guildId string) *Guild { + if guild, ok := Guilds[guildId]; ok { + return guild + } else { + // Create a new guild with default values + newGuild := Guild{ + ID: guildId, + 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{}), + }, + } + // Add the new guild to the map of guilds + Guilds[guildId] = &newGuild + + // Save the guild to .json + // A failed save is fatal, so we can count on this being successful + newGuild.save() + + // Log that a new guild was detected + log.Infof("New guild detected: %s", guildId) + + return &newGuild + } +} + +// GetMember +// Convenience function to get a member in this guild +// This function handles cleaning of the string so you don't have to +func (g *Guild) GetMember(userId string) (*discordgo.Member, error) { + cleanedId := CleanId(userId) + if cleanedId == "" { + return nil, errors.New("invalid user ID") + } + return Session.GuildMember(g.ID, cleanedId) +} + +// IsMember +// 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 +} + +// GetRole +// Convenience function to get a single role in this guild +// This function handles cleaning of the string so you don't have to +func (g *Guild) GetRole(roleId string) (*discordgo.Role, error) { + cleanedId := CleanId(roleId) + if cleanedId == "" { + return nil, errors.New("invalid role ID") + } + + roles, err := Session.GuildRoles(g.ID) + + if err != nil { + return nil, err + } + + for _, role := range roles { + if role.ID == cleanedId { + return role, nil + } + } + + return nil, errors.New("role not found") +} + +// IsRole +// 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 +} + +// HasRole +// Determine if a given user ID has a certain role in this guild +func (g *Guild) HasRole(userId string, roleId string) bool { + member, err := g.GetMember(userId) + if err != nil { + return false + } + + role, err := g.GetRole(roleId) + if err != nil { + return false + } + + for _, r := range member.Roles { + if r == role.ID { + return true + } + } + + return false +} + +// GetChannel +// Retrieve a single channel belonging to this guild +// This function handles cleaning of the string so you don't have to +func (g *Guild) GetChannel(channelId string) (*discordgo.Channel, error) { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return nil, errors.New("invalid channel ID") + } + + channels, err := Session.GuildChannels(g.ID) + if err != nil { + return nil, err + } + + for _, channel := range channels { + if channel.ID == cleanedId { + return channel, nil + } + } + + return nil, errors.New("channel not found") +} + +// IsChannel +// 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 +} + +// MemberOrRoleInList +// This is a higher-level function specifically for the Moderator, Ignored, and Whitelist checks +// Check if a given ID - member or role - exists in a given list, while automatically checking member roles if necessary +func (g *Guild) MemberOrRoleInList(checkId string, list []string) bool { + // Check if the ID represents a member + member, err := g.GetMember(checkId) + if err == nil { + // This is a member, check if their ID is found in the list directly, OR if a role they have is found in the list + for _, id := range list { + if member.User.ID == id { + return true + } + for _, role := range member.Roles { + if role == id { + return true + } + } + } + + // The member is not in the list, neither by ID nor by any roles they have + return false + } + + // Check if the ID represents a role + role, err := g.GetRole(checkId) + log.Infof("Role %s", role) + if err == nil { + // This is a role; check if this role is in the list + for _, mod := range list { + if role.ID == mod { + return true + } + } + } + + // All checks failed, they are not in the list + return false +} + +// SetPrefix +// Set the prefix, then save the guild data +func (g *Guild) SetPrefix(newPrefix string) { + g.Info.Prefix = newPrefix + g.save() +} + +// IsMod +// Check if a given ID is a moderator or not +func (g *Guild) IsMod(checkId string) bool { + return g.MemberOrRoleInList(checkId, g.Info.ModeratorIds) +} + +// AddMod +// Add a user or role ID as a moderator to the bot +func (g *Guild) AddMod(addId string) error { + // Add the ID if it is a member + member, err := g.GetMember(addId) + if err == nil { + if g.IsMod(member.User.ID) { + return errors.New("member is already a bot moderator in this guild; nothing to add") + } + g.Info.ModeratorIds = append(g.Info.ModeratorIds, member.User.ID) + g.save() + return nil + } + + // Add the ID if it is a role + role, err := g.GetRole(addId) + if err == nil { + if g.IsMod(role.ID) { + return errors.New("role is already a bot moderator in this guild; nothing to add") + } + g.Info.ModeratorIds = append(g.Info.ModeratorIds, role.ID) + g.save() + return nil + } + + return errors.New("failed to locate member or role") +} + +// RemoveMod +// Remove a user or role ID from the list of bot moderators +func (g *Guild) RemoveMod(remId string) error { + cleanedId := CleanId(remId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + if !g.IsMod(cleanedId) { + return errors.New("id is not a bot moderator in this guild; nothing to remove") + } + + g.Info.ModeratorIds = RemoveItem(g.Info.ModeratorIds, cleanedId) + g.save() + return nil +} + +// MemberOrRoleIsWhitelisted +// Check if a given user or role is whitelisted +// If the whitelist is empty, return true +func (g *Guild) MemberOrRoleIsWhitelisted(checkId string) bool { + // Check if the whitelist is empty. If it is, return true immediately + if len(g.Info.WhitelistIds) == 0 { + return true + } + + return g.MemberOrRoleInList(checkId, g.Info.WhitelistIds) +} + +// AddMemberOrRoleToWhitelist +// Add a member OR role ID to the list of whitelisted ids +func (g *Guild) AddMemberOrRoleToWhitelist(addId string) error { + // Make sure the id is a member or a role + if !g.IsMember(addId) && !g.IsRole(addId) { + return errors.New("provided ID is neither a member or a role") + } + + cleanedId := CleanId(addId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + if g.MemberOrRoleIsWhitelisted(cleanedId) { + return errors.New("id is already whitelisted in this guild; nothing to add") + } + + g.Info.WhitelistIds = append(g.Info.WhitelistIds, cleanedId) + g.save() + + // If this ID is ignored, remove it from the ignore list, as these are mutually exclusive + if g.MemberOrRoleIsIgnored(cleanedId) { + err := g.RemoveMemberOrRoleFromIgnored(cleanedId) + if err != nil { + return err + } + } + + return nil +} + +// RemoveMemberOrRoleFromWhitelist +// Remove a given ID from the list of whitelisted IDs +func (g *Guild) RemoveMemberOrRoleFromWhitelist(remId string) error { + cleanedId := CleanId(remId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + if !g.MemberOrRoleIsWhitelisted(cleanedId) { + return errors.New("id is not whitelisted in this guild; nothing to remove") + } + + g.Info.WhitelistIds = RemoveItem(g.Info.WhitelistIds, cleanedId) + g.save() + return nil +} + +// MemberOrRoleIsIgnored +// Determine if a given user or role ID is on the ignored list, OR if they have a role on the ignored list +// On error, treat as if they are on this list +func (g *Guild) MemberOrRoleIsIgnored(checkId string) bool { + // Check if the ignore list is empty. If it is, return false immediately + if len(g.Info.IgnoredIds) == 0 { + return false + } + + return g.MemberOrRoleInList(checkId, g.Info.IgnoredIds) +} + +// AddMemberOrRoleToIgnored +// Add a user OR role ID to the list of ignored IDs +func (g *Guild) AddMemberOrRoleToIgnored(addId string) error { + // Make sure the id is a member or a role + if !g.IsMember(addId) && !g.IsRole(addId) { + return errors.New("provided ID is neither a member or a role") + } + + cleanedId := CleanId(addId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + if g.MemberOrRoleIsIgnored(cleanedId) { + return errors.New("id is already ignored in this guild; nothing to add") + } + + g.Info.IgnoredIds = append(g.Info.IgnoredIds, cleanedId) + g.save() + + // If this ID is whitelisted, remove it from the whitelist, as these are mutually exclusive + if g.MemberOrRoleIsWhitelisted(cleanedId) { + err := g.RemoveMemberOrRoleFromWhitelist(cleanedId) + if err != nil { + return err + } + } + + return nil +} + +// RemoveMemberOrRoleFromIgnored +// Remove a given ID from the list of ignored IDs +func (g *Guild) RemoveMemberOrRoleFromIgnored(remId string) error { + cleanedId := CleanId(remId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + if !g.MemberOrRoleIsIgnored(cleanedId) { + return errors.New("id is not ignored in this guild; nothing to remove") + } + + g.Info.IgnoredIds = RemoveItem(g.Info.IgnoredIds, cleanedId) + g.save() + return nil +} + +// ChannelIsWhitelisted +// Determine if a channel ID is whitelisted. Return true if the whitelist is empty +func (g *Guild) ChannelIsWhitelisted(channelId string) bool { + if len(g.Info.WhitelistedChannels) == 0 { + return true + } + + // Make sure it is a channel + channel, err := g.GetChannel(channelId) + if err != nil { + return false + } + + for _, whitelisted := range g.Info.WhitelistedChannels { + if channel.ID == whitelisted { + return true + } + } + + return false +} + +// AddChannelToWhitelist +// Add a channel to the list of channels that are whitelisted (where commands can be run) +func (g *Guild) AddChannelToWhitelist(channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + // Make sure it is a channel + channel, err := g.GetChannel(cleanedId) + if err != nil { + return err + } + + // Make sure it's not already in the whitelist + if g.ChannelIsWhitelisted(channel.ID) { + return errors.New("channel is already whitelisted") + } + + // Add the ID to the whitelist + g.Info.WhitelistedChannels = append(g.Info.WhitelistedChannels, channel.ID) + g.save() + + // If this channel is ignored, remove it from the ignore list, as these are mutually exclusive + if g.ChannelIsIgnored(channel.ID) { + err := g.RemoveChannelFromIgnored(channel.ID) + if err != nil { + return err + } + } + + return nil +} + +// RemoveChannelFromWhitelist +// Remove a channel from the list of channels that are whitelisted (where commands can be run) +func (g *Guild) RemoveChannelFromWhitelist(channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + // Make check if it's even on the channel whitelist + if !g.ChannelIsWhitelisted(cleanedId) { + return errors.New("channel is already whitelisted") + } + + // Remove the ID from the whitelist + g.Info.WhitelistedChannels = RemoveItem(g.Info.WhitelistedChannels, cleanedId) + g.save() + + return nil +} + +// ChannelIsIgnored +// Determine if a channel ID is ignored. Return false if the ignore list is empty +func (g *Guild) ChannelIsIgnored(channelId string) bool { + if len(g.Info.IgnoredChannels) == 0 { + return false + } + + // Make sure it is a channel + channel, err := g.GetChannel(channelId) + if err != nil { + return true + } + + for _, ignored := range g.Info.IgnoredChannels { + if channel.ID == ignored { + return true + } + } + + return false +} + +// AddChannelToIgnored +// Add a channel to the list of channels that are ignored (where commands can't be run) +func (g *Guild) AddChannelToIgnored(channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + // Make sure it is a channel + channel, err := g.GetChannel(cleanedId) + if err != nil { + return err + } + + // Make sure it's not already in the ignored list + if g.ChannelIsIgnored(channel.ID) { + return errors.New("channel is already ignored") + } + + // Add the ID to the ignored list + g.Info.IgnoredChannels = append(g.Info.IgnoredChannels, channel.ID) + g.save() + + // If this channel is whitelisted, remove it from the whitelist, as these are mutually exclusive + if g.ChannelIsWhitelisted(channel.ID) { + err := g.RemoveChannelFromWhitelist(channel.ID) + if err != nil { + return err + } + } + + return nil +} + +// RemoveChannelFromIgnored +// Remove a channel from the list of channels that are ignored (where commands can't be run) +func (g *Guild) RemoveChannelFromIgnored(channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided ID is invalid") + } + + // Make check if it's even on the ignored channel list + if !g.ChannelIsIgnored(cleanedId) { + return errors.New("channel is not ignored") + } + + // Remove the ID from the ignore list + g.Info.IgnoredChannels = RemoveItem(g.Info.IgnoredChannels, cleanedId) + g.save() + + return nil +} + +// IsGloballyDisabled +// Check if a given trigger is globally disabled +func (g *Guild) IsGloballyDisabled(trigger string) bool { + for _, disabled := range g.Info.GlobalDisabledTriggers { + if strings.ToLower(disabled) == strings.ToLower(trigger) { + return true + } + } + + return false +} + +// EnableTriggerGlobally +// Remove a trigger from the list of *globally disabled* triggers +func (g *Guild) EnableTriggerGlobally(trigger string) error { + if !g.IsGloballyDisabled(trigger) { + return errors.New("trigger is not disabled; nothing to enable") + } + + g.Info.GlobalDisabledTriggers = RemoveItem(g.Info.GlobalDisabledTriggers, trigger) + g.save() + return nil +} + +// DisableTriggerGlobally +// Add a trigger to the list of *globally disabled* triggers +func (g *Guild) DisableTriggerGlobally(trigger string) error { + if g.IsGloballyDisabled(trigger) { + return errors.New("trigger is not enabled; nothing to disable") + } + + g.Info.GlobalDisabledTriggers = append(g.Info.GlobalDisabledTriggers, trigger) + g.save() + return nil +} + +// TriggerIsDisabledInChannel +// Check if a given trigger is disabled in the given channel +func (g *Guild) TriggerIsDisabledInChannel(trigger string, channelId string) bool { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return true + } + + if !g.IsChannel(cleanedId) { + return true + } + + // Iterate over every channel ID (the map key) and their internal list of disabled triggers + for channel, triggers := range g.Info.ChannelDisabledTriggers { + + // If the channel matches our current channel, continue + if channel == cleanedId { + + // For every disabled trigger in the list... + for _, disabled := range triggers { + + // If the current trigger matches a disabled one, return true + if disabled == trigger { + return true + } + } + } + } + + return false +} + +// EnableTriggerInChannel +// Given a trigger and channel ID, remove that trigger from that channel's list of blocked triggers +func (g *Guild) EnableTriggerInChannel(trigger string, channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided channel ID is invalid") + } + + if !g.TriggerIsDisabledInChannel(trigger, cleanedId) { + return errors.New("that trigger is not disabled in this channel; nothing to enable") + } + + // Remove the trigger from THIS channel's list + g.Info.ChannelDisabledTriggers[cleanedId] = RemoveItem(g.Info.ChannelDisabledTriggers[cleanedId], trigger) + + // If there are no more items, delete the entire channel list, otherwise it will appear as null in the json + if len(g.Info.ChannelDisabledTriggers[cleanedId]) == 0 { + delete(g.Info.ChannelDisabledTriggers, cleanedId) + } + + g.save() + return nil +} + +// DisableTriggerInChannel +// Given a trigger and channel ID, add that trigger to that channel's list of blocked triggers +func (g *Guild) DisableTriggerInChannel(trigger string, channelId string) error { + cleanedId := CleanId(channelId) + if cleanedId == "" { + return errors.New("provided channel ID is invalid") + } + + if g.TriggerIsDisabledInChannel(trigger, cleanedId) { + return errors.New("that trigger is already disabled in this channel; nothing to disable") + } + + g.Info.ChannelDisabledTriggers[cleanedId] = append(g.Info.ChannelDisabledTriggers[cleanedId], trigger) + g.save() + return nil +} + +// IsCustomCommand +// Check if a given trigger is a custom command in this guild +func (g *Guild) IsCustomCommand(trigger string) bool { + if _, ok := g.Info.CustomCommands[strings.ToLower(trigger)]; ok { + return true + } + return false +} + +// AddCustomCommand +// Add a custom command to this guild +func (g *Guild) AddCustomCommand(trigger string, content string, public bool) error { + if g.IsCustomCommand(trigger) { + return errors.New("the provided trigger is already a custom command") + } + + if _, ok := commands[trigger]; ok { + return errors.New("custom command would have overridden a core command") + } + + g.Info.CustomCommands[trigger] = CustomCommand{ + Content: content, + InvokeCount: 0, + Public: public, + } + g.save() + return nil +} + +// RemoveCustomCommand +// Remove a custom command from this guild +func (g *Guild) RemoveCustomCommand(trigger string) error { + if !g.IsCustomCommand(trigger) { + return errors.New("the provided trigger is not a custom command") + } + delete(g.Info.CustomCommands, trigger) + g.save() + return nil +} + +// SetDeletePolicy +// Set the delete policy, then save the guild data +func (g *Guild) SetDeletePolicy(policy bool) { + g.Info.DeletePolicy = policy + g.save() +} + +// SetResponseChannel +// Check that the channel exists, set the response channel, then save the guild data +func (g *Guild) SetResponseChannel(channelId string) error { + // If channelId is blank, + if channelId == "" { + g.Info.ResponseChannelId = channelId + g.save() + return nil + } + // Try grabbing the channel first (we don't use IsChannel since we need the real ID) + channel, err := g.GetChannel(channelId) + if err != nil { + return err + } + g.Info.ResponseChannelId = channel.ID + g.save() + return nil +} + +// SetMuteRole +// Set the role ID to use for issuing mutes, then save the guild data +func (g *Guild) SetMuteRole(roleId string) error { + // Try grabbing the role first (we don't use IsRole since we need the real ID) + role, err := g.GetRole(roleId) + if err != nil { + return err + } + g.Info.MuteRoleId = role.ID + g.save() + return nil +} + +// HasMuteRecord +// Check if a member with a given ID has a mute record +// To check if they are actually muted, use g.HasRole +func (g *Guild) HasMuteRecord(userId string) bool { + // Check if the member exists + member, err := g.GetMember(userId) + if err != nil { + return false + } + + // Check if the member is in the list of mutes + if _, ok := g.Info.MutedUsers[member.User.ID]; ok { + return true + } + + return false +} + +// Mute +// Mute a user for the specified duration, apply the mute role, and write a mute record to the guild info +func (g *Guild) Mute(userId string, duration int64) error { + // Make sure the mute role exists + muteRole, err := g.GetRole(g.Info.MuteRoleId) + if err != nil { + return err + } + + // Make sure the member exists + member, err := g.GetMember(userId) + if err != nil { + return err + } + + // Create a mute mutex for this guild if it does not exist + if _, ok := muteLock[g.ID]; !ok { + muteLock[g.ID] = &sync.Mutex{} + } + + // Lock this guild's mute activity so there is no desync + defer muteLock[g.ID].Unlock() + muteLock[g.ID].Lock() + + // Try muting the member + err = Session.GuildMemberRoleAdd(g.ID, member.User.ID, muteRole.ID) + if err != nil { + return err + } + + // If the duration is not 0 (indefinite mute), add the current time to the duration + if duration != 0 { + duration += time.Now().Unix() + } + + // Record this mute record + g.Info.MutedUsers[member.User.ID] = duration + g.save() + + return nil +} + +// UnMute +// Unmute a user; expiry checks will not be done here, this is a direct unmute +func (g *Guild) UnMute(userId string) error { + // Make sure the mute role exists + muteRole, err := g.GetRole(g.Info.MuteRoleId) + if err != nil { + return err + } + + // Make sure the member exists + member, err := g.GetMember(userId) + if err != nil { + return err + } + + // Create a mute mutex for this guild if it does not exist + if _, ok := muteLock[g.ID]; !ok { + muteLock[g.ID] = &sync.Mutex{} + } + + // Lock this guild's mute activity so there is no desync + defer muteLock[g.ID].Unlock() + muteLock[g.ID].Lock() + + // Delete the mute record if it exists + delete(g.Info.MutedUsers, member.User.ID) + g.save() + + // Try unmuting the user + err = Session.GuildMemberRoleRemove(g.ID, member.User.ID, muteRole.ID) + if err != nil { + return err + } + + return nil +} + +// Kick +// Kick a member +func (g *Guild) Kick(userId string, reason string) error { + // Make sure the member exists + member, err := g.GetMember(userId) + if err != nil { + return err + } + + // Kick the member + if reason != "" { + return Session.GuildMemberDeleteWithReason(g.ID, member.User.ID, reason) + } else { + return Session.GuildMemberDelete(g.ID, member.User.ID) + } +} + +// Ban +// Ban a user, who may not be a member +func (g *Guild) Ban(userId string, reason string, deleteDays int) error { + // Make sure the USER exists, because they may not be a member + user, err := GetUser(userId) + if err != nil { + return err + } + + // Ban the member + if reason != "" { + return Session.GuildBanCreateWithReason(g.ID, user.ID, reason, deleteDays) + } else { + return Session.GuildBanCreate(g.ID, user.ID, deleteDays) + } +} + +// PurgeChannel +// Purge the last N messages in a given channel, regardless of user +func (g *Guild) PurgeChannel(channelId string, deleteCount int) (int, error) { + // Make sure the channel exists + channel, err := g.GetChannel(channelId) + if err != nil { + return 0, err + } + + // Get the group of messages to delete + deleteGroup, err := Session.ChannelMessages(channel.ID, deleteCount, "", "", "") + if err != nil { + return 0, err + } + + // Convert the messages to IDs + // For some reason, discordgo has decided to not allow message objects in the delete function... + var messageIds []string + for _, message := range deleteGroup { + messageIds = append(messageIds, message.ID) + } + + // Delete the messages + return len(messageIds), Session.ChannelMessagesBulkDelete(channel.ID, messageIds) +} + +// PurgeUserInChannel +// Purge a user's messages in a certain channel +// Delete deleteCount messages, searching through a maximum of searchCount messages +func (g *Guild) PurgeUserInChannel(userId string, channelId string, deleteCount int) (int, error) { + // Make sure the channel exists + channel, err := g.GetChannel(channelId) + if err != nil { + return 0, err + } + + // Make sure the user exists + deleteUser, err := GetUser(userId) + if err != nil { + return 0, err + } + + // Start compiling the messages to delete, in batches of 100 + var deleteIds []string + lastId := "" + + // Search a maximum of 300 messages, loop 3 times + for i := 0; i < 3; i++ { + // Break out of the loop if we've got the amount of messages we needed + if deleteCount <= len(deleteIds) { + break + } + + // Get 100 messages from the channel in this iteration + deleteGroup, err := Session.ChannelMessages(channel.ID, 100, lastId, "", "") + if err != nil { + // If we don't have any IDs to delete yet, return an error + // Break early otherwise + if len(deleteIds) == 0 { + return 0, err + } else { + break + } + } + + // If no messages were returned, break + if len(deleteGroup) == 0 { + break + } + + // Set the last ID so we can keep searching up for messages before this + lastId = deleteGroup[len(deleteGroup)-1].ID + + // Go through all the returned messages, and search for messages written by the author we're looking for + for _, message := range deleteGroup { + if deleteCount <= len(deleteIds) { + break + } + if message.Author.ID == deleteUser.ID { + deleteIds = append(deleteIds, message.ID) + } + } + } + + // If we got messages to delete, delete them + if len(deleteIds) != 0 { + return len(deleteIds), Session.ChannelMessagesBulkDelete(channel.ID, deleteIds) + } else { + return 0, nil + } + +} + +// PurgeUser +// PurgeUser a user's messages in any channel +func (g *Guild) PurgeUser(userId string, deleteCount int) (int, error) { + // Get all the channels in the guild + channels, err := Session.GuildChannels(g.ID) + if err != nil { + return 0, err + } + + // Systematically check all channels in the guild for messages to delete + totalDeleted := 0 + for _, channel := range channels { + // Break if we've deleted the amount we wanted to delete + if deleteCount <= totalDeleted { + break + } + + // Don't bother checking user ID, because this function will do it automatically, reducing API calls + numDeleted, err := g.PurgeUserInChannel(userId, channel.ID, deleteCount-totalDeleted) + if err != nil { + return 0, err + } + totalDeleted += numDeleted + } + + return totalDeleted, nil +} + +// StoreString +// Store a string to this guild's arbitrary storage +func (g *Guild) StoreString(key string, value string) { + g.Info.Storage[key] = value + g.save() +} + +// GetString +// Retrieve a string from this guild's arbitrary storage, and error if the cast fails +func (g *Guild) GetString(key string) (string, error) { + res, ok := g.Info.Storage[key].(string) + if !ok { + return "", errors.New("failed to cast the data to type \"string\"") + } + + return res, nil +} + +// StoreInt64 +// Store an int64 to this guild's arbitrary storage +func (g *Guild) StoreInt64(key string, value int64) { + g.Info.Storage[key] = value + g.save() +} + +// GetInt64 +// Retrieve an int64 from this guild's arbitrary storage, and error if the cast fails +func (g *Guild) GetInt64(key string) (int64, error) { + res, ok := g.Info.Storage[key].(int64) + if !ok { + return -1, errors.New("failed to cast the data to type \"int64\"") + } + + return res, nil +} + +// StoreMap +// Store a map to this guild's arbitrary storage +func (g *Guild) StoreMap(key string, value map[string]interface{}) { + g.Info.Storage[key] = value + g.save() +} + +// GetMap +// Get a map from this guild's arbitrary storage, and error if the cast fails +func (g *Guild) GetMap(key string) (map[string]interface{}, error) { + res, ok := g.Info.Storage[key].(map[string]interface{}) + if !ok { + return nil, errors.New("failed to cast the data to type \"map[string]interface{}\"") + } + + return res, nil +} + +// GetCommandUsage +//// 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 + + // If there are no usage examples, we only need to print the trigger, wrapped in code formatting + if len(cmd.Arguments.Keys()) == 0 { + return "```\n" + trigger + "\n```" + } + + // Start building the output + output := "\n\n" + cnt := 0 + + for _, arg := range cmd.Arguments.Keys() { + v, ok := cmd.Arguments.Get(arg) + if !ok { + return "```\n" + trigger + "\n```" + } + argType := v.(*ArgInfo) + output += trigger + " <" + arg + "> (" + argType.Description + ") " + if cnt != len(cmd.Arguments.Keys())-1 { + output += "\n" + } + cnt++ + } + return "```\n" + output + "\n```" +} + +// IsSniperEnabled +// Checks to see if the sniper module is enabled +func (g *Guild) IsSniperEnabled() bool { + return g.Info.BannedWordDetector +} + +// IsSnipeable +// Checks to see if the sniper module can snipe this role +func (g *Guild) IsSnipeable(authorID string) bool { + if Session.State.Ready.User != nil && authorID == Session.State.Ready.User.ID { + return false + } + if g.MemberOrRoleInList(authorID, g.Info.BannedWordDetectorRoles) { + return false + } + return true +} + +// IsSniperChannel +// Checks to see if the channel is in the channel list +func (g *Guild) IsSniperChannel(channelID string) bool { + for _, id := range g.Info.BannedWordDetectorChannels { + if id == channelID { + return true + } + } + return false +} + +// SetSniper +// Sets the state of the sniper +func (g *Guild) SetSniper(value bool) bool { + g.Info.BannedWordDetector = value + g.save() + return value +} + +// BulkAddWords +// Allows you to bulk add words to the banned word detector +func (g *Guild) BulkAddWords(words []string) []string { + g.Info.GuildBannedWords = append(g.Info.GuildBannedWords, words...) + g.save() + return g.Info.GuildBannedWords +} + +// AddWord +// Allows you to add a word to the banned word detector +func (g *Guild) AddWord(word string) []string { + g.Info.GuildBannedWords = append(g.Info.GuildBannedWords, word) + g.save() + return g.Info.GuildBannedWords +} + +// RemoveWord +// Allows you to remove a word from the banned word detector +func (g *Guild) RemoveWord(word string) []string { + g.Info.GuildBannedWords = RemoveItem(g.Info.GuildBannedWords, word) + g.save() + return g.Info.GuildBannedWords +} + +// SetSniperRole +// Allows you to add a role to the sniper +func (g *Guild) SetSniperRole(roleID string) []string { + if g.IsRole(roleID) { + g.Info.BannedWordDetectorRoles = append(g.Info.BannedWordDetectorRoles, roleID) + g.save() + return g.Info.BannedWordDetectorRoles + } + return g.Info.BannedWordDetectorRoles +} + +// SetSniperChannel +// Allows you to add a channel to the sniper +func (g *Guild) SetSniperChannel(channelID string) []string { + if g.IsChannel(channelID) { + g.Info.BannedWordDetectorChannels = append(g.Info.BannedWordDetectorChannels, channelID) + g.save() + return g.Info.BannedWordDetectorChannels + } + return g.Info.BannedWordDetectorChannels +} + +// UnsetSniperRole +// Allows you to remove a role from the sniper +func (g *Guild) UnsetSniperRole(roleID string) []string { + if g.IsRole(roleID) { + g.Info.BannedWordDetectorRoles = RemoveItem(g.Info.BannedWordDetectorRoles, roleID) + g.save() + return g.Info.BannedWordDetectorRoles + } + return g.Info.BannedWordDetectorRoles +} + +// UnsetSniperChannel +// Allows you to remove a channel from the sniper +func (g *Guild) UnsetSniperChannel(channelID string) []string { + if g.IsChannel(channelID) { + g.Info.BannedWordDetectorChannels = RemoveItem(g.Info.BannedWordDetectorChannels, channelID) + g.save() + return g.Info.BannedWordDetectorChannels + } + return g.Info.BannedWordDetectorChannels +} diff --git a/core/handlers.go b/core/handlers.go new file mode 100644 index 0000000..41e2eda --- /dev/null +++ b/core/handlers.go @@ -0,0 +1,29 @@ +package core + +// handlers.go +// Everything required for commands to pass their own handlers to discordgo + +// handlers +// This list stores all of the handlers that can be added to the bot +// It's basically a passthrough for discordgo.AddHandler, but having a list +// allows them to be collected ahead of time and then added all at once +var handlers []interface{} + +// AddHandler +// This provides a way for commands to pass handler functions through to discorgo, +// and have them added properly during bot startup +func AddHandler(handler interface{}) { + handlers = append(handlers, handler) +} + +// addHandlers +// Given all the handlers that have been pre-added to the handlers list, add them to the discordgo session +func addHandlers() { + if len(handlers) == 0 { + return + } + + for _, handler := range handlers { + Session.AddHandler(handler) + } +} diff --git a/core/interaction.go b/core/interaction.go new file mode 100644 index 0000000..8753808 --- /dev/null +++ b/core/interaction.go @@ -0,0 +1,206 @@ +package core + +import ( + "github.com/bwmarrin/discordgo" +) + +// +//// TODO clean up this file and move interaction specific functions here +// +//import ( +// "github.com/bwmarrin/discordgo" +// "strings" +//) +// +//// slashCommandTypes +//// A map of *short hand* slash commands types to their discordgo counterparts +//// TODO move this over to interaction.go +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, +} + +// +// getSlashCommandStruct +// Creates a slash command struct +// todo work on sub command stuff +func createSlashCommandStruct(info *CommandInfo) (st *discordgo.ApplicationCommand) { + if info.Arguments == nil || len(info.Arguments.Keys()) < 1 { + st = &discordgo.ApplicationCommand{ + Name: info.Trigger, + Description: info.Description, + } + return + } + st = &discordgo.ApplicationCommand{ + Name: info.Trigger, + Description: info.Description, + Options: make([]*discordgo.ApplicationCommandOption, len(info.Arguments.Keys())), + } + for i, k := range info.Arguments.Keys() { + v, _ := info.Arguments.Get(k) + vv := v.(*ArgInfo) + optionStruct := discordgo.ApplicationCommandOption{ + Type: slashCommandTypes[vv.TypeGuard], + Name: k, + Description: vv.Description, + Required: vv.Required, + } + 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, + } + } + } + st.Options[i] = &optionStruct + } + return +} + +// -- Interaction Handlers -- + +// handleInteraction +// Handles a slash command interaction. +func handleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + handleInteractionCommand(s, i) + break + case discordgo.InteractionMessageComponent: + handleMessageComponents(s, i) + } + return +} + +// handleInteractionCommand +// Handles a slash command +func handleInteractionCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + g := getGuild(i.GuildID) + + if g.Info.DeletePolicy { + err := Session.ChannelMessageDelete(i.ChannelID, i.ID) + if err != nil { + SendErrorReport(i.GuildID, i.ChannelID, i.Member.User.ID, "Failed to delete message: "+i.ID, err) + } + } + 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 trigger + if g.TriggerIsDisabledInChannel(trigger, i.ChannelID) { + ErrorResponse(i.Interaction, "Command is disabled in this channel!", trigger) + return + } + + // 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 + } + + // Ignore the message if this channel is not whitelisted, or if it is ignored + if !g.ChannelIsWhitelisted(i.ChannelID) || g.ChannelIsIgnored(i.ChannelID) { + return + } + } + + 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 + 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: "", + }, + }) + return + } +} + +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 (InteractionResponseDredeferMessageUpdate) 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, + }, + }) + return +} + +// -- Slash Argument Parsing Helpers -- + +// ParseInteractionArgs +// Parses Interaction args +func ParseInteractionArgs(options []*discordgo.ApplicationCommandInteractionDataOption) *map[string]CommandArg { + var args = make(map[string]CommandArg) + for _, v := range options { + args[v.Name] = CommandArg{ + info: ArgInfo{}, + Value: v.Value, + } + if v.Options != nil { + ParseInteractionArgsR(v.Options, &args) + } + } + return &args +} + +// ParseInteractionArgsR +// Parses interaction args recursively +func ParseInteractionArgsR(options []*discordgo.ApplicationCommandInteractionDataOption, args *map[string]CommandArg) { + for _, v := range options { + (*args)[v.Name] = CommandArg{ + info: ArgInfo{}, + Value: v.StringValue(), + } + if v.Options != nil { + ParseInteractionArgsR(v.Options, *&args) + } + } +} + +// -- :shrug: -- + +// RemoveGuildSlashCommands +// Removes all guild slash commands. +func RemoveGuildSlashCommands(guildID string) { + commands, err := Session.ApplicationCommands(Session.State.User.ID, guildID) + if err != nil { + log.Errorf("Error getting all slash commands %s", err) + return + } + for _, k := range commands { + err = Session.ApplicationCommandDelete(Session.State.User.ID, guildID, k.ID) + if err != nil { + log.Errorf("error deleting slash command %s %s %s", k.Name, k.ID, err) + continue + } + } +} diff --git a/core/response.go b/core/response.go new file mode 100644 index 0000000..1b427bc --- /dev/null +++ b/core/response.go @@ -0,0 +1,446 @@ +package core + +import ( + "fmt" + "time" + + "github.com/bwmarrin/discordgo" +) + +// response.go +// This file contains structures and functions that make it easier to create and send response embeds + +// ResponseComponents +// Stores the components for response +// allows for functions to add data +type ResponseComponents struct { + Components []discordgo.MessageComponent + SelectMenuOptions []discordgo.SelectMenuOption +} + +// Response +// The Response type, can be build and sent to a given guild +type Response struct { + Ctx *Context + Success bool + Loading bool + Ephemeral bool + Embed *discordgo.MessageEmbed + ResponseComponents *ResponseComponents +} + +// CreateField +// Create message field to use for an embed +func CreateField(name string, value string, inline bool) *discordgo.MessageEmbedField { + return &discordgo.MessageEmbedField{ + Name: name, + Value: value, + Inline: inline, + } +} + +// CreateEmbed +// Create an embed +func CreateEmbed(color int, title string, description string, fields []*discordgo.MessageEmbedField) *discordgo.MessageEmbed { + return &discordgo.MessageEmbed{ + Title: title, + Description: description, + Color: color, + Fields: fields, + } +} + +// CreateComponentFields +// Returns a slice of a Message Component, containing a singular ActionsRow +func CreateComponentFields() []discordgo.MessageComponent { + return []discordgo.MessageComponent{ + discordgo.ActionsRow{}, + } +} + +// NewResponse +// 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 { + r := &Response{ + Ctx: ctx, + Embed: CreateEmbed(0, "", "", nil), + ResponseComponents: &ResponseComponents{ + Components: nil, + SelectMenuOptions: nil, + }, + Loading: ctx.Cmd.IsTyping, + Ephemeral: ephemeral, + } + if messageComponents { + r.ResponseComponents.Components = CreateComponentFields() + r.ResponseComponents.SelectMenuOptions = []discordgo.SelectMenuOption{} + } + if r.Loading && ctx.Interaction != nil { + if ephemeral { + _ = Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + // Ephemeral is type 64 don't ask why + Flags: 1 << 6, + }, + }) + } else { + _ = Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ + 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 +} + +// -- Fields -- + +// AppendField +// Create a new basic field and append it to an existing Response +func (r *Response) AppendField(name string, value string, inline bool) { + r.Embed.Fields = append(r.Embed.Fields, CreateField(name, value, inline)) +} + +// PrependField +// Create a new basic field and prepend it to an existing Response +func (r *Response) PrependField(name string, value string, inline bool) { + fields := []*discordgo.MessageEmbedField{CreateField(name, value, inline)} + r.Embed.Fields = append(fields, r.Embed.Fields...) +} + +// AppendUsage +// Add the command usage to the response. Intended for syntax error responses +func (r *Response) AppendUsage() { + if r.Ctx.Cmd.Description == "" { + r.AppendField("Command description:", "no description", false) + return + } + r.AppendField("Command description:", r.Ctx.Cmd.Description, false) + //r.AppendField("Command usage:", r.Ctx.Guild.GetCommandUsage(r.Ctx.Cmd), false) + +} + +// -- Message Components -- + +func CreateButton(label string, style discordgo.ButtonStyle, customID string, url string, disabled bool) *discordgo.Button { + button := &discordgo.Button{ + Label: label, + Style: style, + Disabled: disabled, + Emoji: discordgo.ComponentEmoji{}, + URL: url, + CustomID: customID, + } + return button +} + +func CreateDropDown(customID string, placeholder string, options []discordgo.SelectMenuOption) discordgo.SelectMenu { + dropDown := discordgo.SelectMenu{ + CustomID: customID, + Placeholder: placeholder, + Options: options, + } + return dropDown +} + +// 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 +} + +//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.Components = append(row.Components, CreateDropDown(customID, placeholder, r.ResponseComponents.SelectMenuOptions)) + r.ResponseComponents.Components[0] = row + } else { + actionRow := discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.SelectMenu{ + CustomID: customID, + Placeholder: placeholder, + Options: r.ResponseComponents.SelectMenuOptions, + }, + }, + } + r.ResponseComponents.Components = append(r.ResponseComponents.Components, actionRow) + } +} + +// Send +// Send a compiled response +func (r *Response) Send(success bool, title string, description string) { + // Determine what color to use based on the success state + var color int + if 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 guild is nil, this is intended to be sent to Bot Admins + if r.Ctx.Guild == nil { + for admin := range botAdmins { + dmChannel, dmCreateErr := Session.UserChannelCreate(admin) + if dmCreateErr != nil { + // Since error reports also use DMs, sending this as an error report would be redundant + // Just log the error + log.Errorf("Failed sending Response DM to admin: %s; Response title: %s", admin, r.Embed.Title) + return + } + _, dmSendErr := Session.ChannelMessageSendComplex(dmChannel.ID, &discordgo.MessageSend{ + Embed: r.Embed, + Components: r.ResponseComponents.Components, + }) + if dmSendErr != nil { + // Since error reports also use DMs, sending this as an error report would be redundant + // Just log the error + log.Errorf("Failed sending Response DM to admin: %s; Response title: %s", admin, r.Embed.Title) + return + } + return + } + } + + // 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(Session.State.User.ID, 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(Session.State.User.ID, r.Ctx.Interaction, &discordgo.WebhookEdit{ + Content: "", + 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) + } + } + 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, + }) + if err != nil { + // Reply to user if no output channel + _, err = ReplyToUser(r.Ctx.Message.ChannelID, &discordgo.MessageSend{ + Embed: r.Embed, + Components: r.ResponseComponents.Components, + Reference: &discordgo.MessageReference{ + MessageID: r.Ctx.Message.ID, + ChannelID: r.Ctx.Message.ChannelID, + GuildID: r.Ctx.Guild.ID, + }, + AllowedMentions: &discordgo.MessageAllowedMentions{ + RepliedUser: false, + }, + }) + if err != nil { + SendErrorReport(r.Ctx.Guild.ID, r.Ctx.Message.ChannelID, r.Ctx.Message.Author.ID, "Ultimately failed to send bot response", err) + } + } +} + +func ErrorResponse(i *discordgo.Interaction, errorMsg string, trigger string) { + var errorEmbed = CreateEmbed(0xff3232, "Error", errorMsg, []*discordgo.MessageEmbedField{ + { + Name: "Command Used", + Value: "/" + trigger, + }, + { + Name: "Invoked by:", + Value: i.Member.User.Mention(), + }, + }) + Session.InteractionRespond(i, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + errorEmbed, + }, + }, + }) + + time.AfterFunc(time.Second*5, func() { + time.Sleep(time.Second * 4) + Session.InteractionResponseDelete(Session.State.User.ID, i) + }) +} + +func (r *Response) AcknowledgeInteraction() { + Session.InteractionRespond(r.Ctx.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "<:loadingdots:759625992166965288>", + }, + }) + r.Loading = true +} + +func ReplyToUser(channelID string, messageSend *discordgo.MessageSend) (*discordgo.Message, error) { + return Session.ChannelMessageSendComplex(channelID, messageSend) +} diff --git a/core/util.go b/core/util.go new file mode 100644 index 0000000..881f472 --- /dev/null +++ b/core/util.go @@ -0,0 +1,264 @@ +package core + +import ( + "errors" + "github.com/bwmarrin/discordgo" + "regexp" + "strconv" + "strings" +) + +// util.go +// This file contains utility functions, simplifying redundant tasks + +// RemoveItem +// Remove an item from a slice by value +func RemoveItem(slice []string, delete string) []string { + var newSlice []string + for _, elem := range slice { + if elem != delete { + newSlice = append(newSlice, elem) + } + } + return newSlice +} + +// EnsureNumbers +// Given a string, ensure it contains only numbers +// This is useful for stripping letters and formatting characters from user/role pings +func EnsureNumbers(in string) string { + reg, err := regexp.Compile("[^0-9]+") + if err != nil { + log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) + return "" + } + + return reg.ReplaceAllString(in, "") +} + +// EnsureLetters +// Given a string, ensure it contains only letters +// This is useful for stripping numbers from mute durations, and possibly other things +func EnsureLetters(in string) string { + reg, err := regexp.Compile("[^a-zA-Z]+") + if err != nil { + log.Errorf("An unrecoverable error occurred when compiling a regex expression: %s", err) + return "" + } + + return reg.ReplaceAllString(in, "") +} + +// CleanId +// Given a string, attempt to remove all numbers from it +// Additionally, ensure it is at least 17 characters in length +// This is a way of "cleaning" a Discord ping into a valid snowflake string +func CleanId(in string) string { + out := EnsureNumbers(in) + + // Discord IDs must be, at minimum, 17 characters long + if len(out) < 17 { + return "" + } + + return out +} + +// ExtractCommand +// Given a message, attempt to extract a command trigger and command arguments out of it +// If there is no prefix, try using a bot mention as the prefix +func ExtractCommand(guild *GuildInfo, message string) (*string, *string) { + // Check if the message starts with the bot trigger + if strings.HasPrefix(message, guild.Prefix) { + // Split the message on the prefix, but ensure only 2 fields are returned + // This ensures messages containing multiple instances of the prefix don't split multiple times + split := strings.SplitN(message, guild.Prefix, 2) + + // Get everything after the prefix as the command content + content := split[1] + + // If the content is blank, someone used the prefix without a trigger + if content == "" { + return nil, nil + } + + // Attempt to pull the trigger out of the command content by splitting on spaces + trigger := strings.Fields(content)[0] + + // With the trigger identified, split the command content on the trigger to obtain everything BUT the trigger + // Ensure only 2 fields are returned so it can be split further. Then, get only the second field + fullArgs := strings.SplitN(content, trigger, 2)[1] + fullArgs = strings.TrimPrefix(fullArgs, " ") + // Avoids issues with strings that are case sensitive + trigger = strings.ToLower(trigger) + + return &trigger, &fullArgs + } else { + if strings.Contains(message, "uber") { + // Same process as above prefix method, but split on a bot mention instead + split := strings.SplitN(message, "uber", 2) + content := strings.TrimPrefix(split[1], " ") + // If content is null someone just sent the prefix + if content == "" { + return nil, nil + } + // Attempt to pull the trigger out of the command content by splitting on spaces + trigger := strings.Fields(content)[0] + fullArgs := strings.SplitN(content, trigger, 2)[1] + fullArgs = strings.TrimPrefix(fullArgs, " ") + // Avoids issues with strings that are case sensitive + trigger = strings.ToLower(trigger) + return &trigger, &fullArgs + } + // The bot can only be mentioned with a space + botMention := Session.State.User.Mention() + " " + + // Sanitize Discord's ridiculous formatting + message = strings.Replace(message, "!", "", 1) + + // See if someone is trying to mention the bot + if strings.HasPrefix(message, botMention) { + // Same process as above prefix method, but split on a bot mention instead + split := strings.SplitN(message, botMention, 2) + content := split[1] + // If content is null someone just sent the prefix + if content == "" { + return nil, nil + } + trigger := strings.ToLower(strings.Fields(content)[0]) + fullArgs := strings.SplitN(content, trigger, 2)[1] + return &trigger, &fullArgs + } else { + return nil, nil + } + } +} + +// GetUser +// Given a user ID, get that user's object (global to Discord, not in a guild) +func GetUser(userId string) (*discordgo.User, error) { + cleanedId := CleanId(userId) + if cleanedId == "" { + return nil, errors.New("provided ID is invalid") + } + + return Session.User(cleanedId) +} + +// logErrorReportFailure +// If an error report fails to send, log the failure +func logErrorReportFailure(recipient string, dmErr error, guildId string, channelId string, userId string, errTitle string, origErr error) { + log.Errorf("[REPORT] Failed to DM report to %s: %s", recipient, dmErr) + log.Error("[REPORT] ---------- BEGIN ERROR REPORT ----------") + log.Error("[REPORT] Report title: " + errTitle) + // Can't .Error a nil error + if origErr != nil { + log.Error("[REPORT] Full error: " + origErr.Error()) + } + log.Error("[REPORT] Affected guild: " + guildId) + log.Error("[REPORT] Affected channel: " + channelId) + log.Error("[REPORT] Affected user: " + userId) + log.Error("[REPORT] ----------- END ERROR REPORT -----------") +} + +// SendErrorReport +// Send an error report as a DM to all of the registered bot administrators +func SendErrorReport(guildId string, channelId string, userId string, title string, err error) { + // Log a general error + log.Errorf("[REPORT] %s (%s)", title, err) + + // Iterate through all the admins + for admin, _ := range botAdmins { + + // Get the channel ID of the user to DM + dmChannel, dmCreateErr := Session.UserChannelCreate(admin) + if dmCreateErr != nil { + logErrorReportFailure(admin, dmCreateErr, guildId, channelId, userId, title, err) + continue + } + + // Create a generic embed + reportEmbed := CreateEmbed(ColorFailure, "ERROR REPORT", title, nil) + + // Add fields if they aren't blank + if guildId != "" { + reportEmbed.Fields = append(reportEmbed.Fields, &discordgo.MessageEmbedField{ + Name: "Guild ID:", + Value: guildId, + Inline: false, + }) + } + + if channelId != "" { + reportEmbed.Fields = append(reportEmbed.Fields, &discordgo.MessageEmbedField{ + Name: "Channel ID:", + Value: channelId, + Inline: false, + }) + } + + if userId != "" { + reportEmbed.Fields = append(reportEmbed.Fields, &discordgo.MessageEmbedField{ + Name: "User ID:", + Value: userId, + Inline: false, + }) + } + + if err != nil { + reportEmbed.Fields = append(reportEmbed.Fields, &discordgo.MessageEmbedField{ + Name: "Full error:", + Value: err.Error(), + Inline: false, + }) + } + + _, dmSendErr := Session.ChannelMessageSendEmbed(dmChannel.ID, reportEmbed) + if dmSendErr != nil { + logErrorReportFailure(admin, dmSendErr, guildId, channelId, userId, title, err) + continue + } + } +} + +// ParseTime +// Parses time strings +func ParseTime(content string) (int, string) { + if content == "" { + return 0, "error lol" + } + duration := 0 + displayDuration := "Indefinite" + multiplier := 1 + for k, v := range TimeRegexes { + if isMatch, _ := v.MatchString(content); isMatch { + multiplier, _ = strconv.Atoi(EnsureNumbers(v.String())) + switch k { + case "seconds": + duration = multiplier + duration + displayDuration = "Second" + case "minutes": + duration = multiplier*60 + duration + displayDuration = "Minute" + case "hours": + duration = multiplier*60*60 + duration + displayDuration = "Hour" + case "days": + duration = multiplier*60*60*24 + duration + displayDuration = "Day" + case "weeks": + duration = multiplier*60*60*24*7 + duration + displayDuration = "Week" + case "years": + duration = multiplier*60*60*24*7*365 + duration + displayDuration = "Year" + } + } + } + // Plurals matter! + if multiplier != 1 { + displayDuration += "s" + } + displayDuration = strconv.Itoa(multiplier) + " " + displayDuration + return duration, displayDuration +} diff --git a/core/workers.go b/core/workers.go new file mode 100644 index 0000000..f6ebcfd --- /dev/null +++ b/core/workers.go @@ -0,0 +1,55 @@ +package core + +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/discordgo b/discordgo new file mode 160000 index 0000000..db86e88 --- /dev/null +++ b/discordgo @@ -0,0 +1 @@ +Subproject commit db86e88f35b68b8707e4c89cf88004b25d52e1de diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1d0332a --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/qpixel/framework + +go 1.16 + +replace github.com/bwmarrin/discordgo => ./discordgo + +require ( + github.com/QPixel/orderedmap v0.2.0 + github.com/bwmarrin/discordgo v0.23.3-0.20210410202908-577e7dd4f6cc + github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91 + github.com/ubergeek77/tinylog v1.0.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..887bbef --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/QPixel/orderedmap v0.2.0 h1:qGTSj7i1YP7dhhUmOZ5/p2OX3NGHtJW/FLT2eDCHJak= +github.com/QPixel/orderedmap v0.2.0/go.mod h1:4cAVROPCVsOwbmwg3hDwZcfiAqYVr3FsOHAVy9ntErE= +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/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +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= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +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/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=