move files from uberbot repo

This commit is contained in:
vel 2021-07-13 00:56:30 -07:00
parent efec0de08b
commit 43216b2819
Signed by: velvox
GPG Key ID: 1C8200C1D689CEF5
16 changed files with 3480 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
uberbot
.env
guilds/*.json
.vscode
.idea
.DS_Store

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "discordgo"]
path = discordgo
url = git@github.com:QPixel/discordgo

523
core/arguments.go Normal file
View File

@ -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
}

314
core/commands.go Normal file
View File

@ -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
}

24
core/consts.go Normal file
View File

@ -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{}
)

201
core/core.go Normal file
View File

@ -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.")
}

170
core/fs.go Normal file
View File

@ -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
}

1206
core/guilds.go Normal file

File diff suppressed because it is too large Load Diff

29
core/handlers.go Normal file
View File

@ -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)
}
}

206
core/interaction.go Normal file
View File

@ -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
}
}
}

446
core/response.go Normal file
View File

@ -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)
}

264
core/util.go Normal file
View File

@ -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
}

55
core/workers.go Normal file
View File

@ -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)
}
}

1
discordgo Submodule

@ -0,0 +1 @@
Subproject commit db86e88f35b68b8707e4c89cf88004b25d52e1de

12
go.mod Normal file
View File

@ -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
)

20
go.sum Normal file
View File

@ -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=