move files from uberbot repo
This commit is contained in:
parent
efec0de08b
commit
43216b2819
|
|
@ -0,0 +1,6 @@
|
|||
uberbot
|
||||
.env
|
||||
guilds/*.json
|
||||
.vscode
|
||||
.idea
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "discordgo"]
|
||||
path = discordgo
|
||||
url = git@github.com:QPixel/discordgo
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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{}
|
||||
)
|
||||
|
|
@ -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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit db86e88f35b68b8707e4c89cf88004b25d52e1de
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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=
|
||||
Loading…
Reference in New Issue