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