You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

333 lines
11 KiB

package main
import (
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/deckarep/golang-set"
"log"
"regexp"
"strings"
"time"
)
type CommandType int
// These are used to specify Command.CommandType when registering new commands
const (
CommandTypePrefix CommandType = 0
CommandTypeFullMatch CommandType = 1
CommandTypeRegex CommandType = 2
CommandTypeContains CommandType = 3
)
/*
This struct represents a command object.
The options should be self-explanatory, but they are also explained in the readme.
A struct can be initialized by passing any number of its attributes as parameters.
Everything not set will be set to the go-usual defaults ("" for string, 0 for int, false for bool, nil for the rest)
Any command that has a Trigger is valid (but useless if nothing else is specified)
*/
type Command struct {
Trigger string // must be specified
Output string // no output if unspecified
OutputEmbed *discordgo.MessageEmbed // no embed output if unspecified
Type CommandType // defaults to Prefix
Cooldown int // defaults to 0 (no cooldown)
OutputIsReply bool
RequiresMention bool
DeleteInput bool
DMOnly bool
AdminOnly bool
IgnoreCase bool
// for custom commands that go beyond prints and deletions
Function func(*discordgo.Session, *discordgo.MessageCreate)
AllowedChannels mapset.Set // allowed everywhere if blank
UsersOnCooldown mapset.Set // don’t set this manually (it’s overwritten anyway)
}
// Performs basic input validation on a given command and adds it to the global command array
func registerCommand(command Command) {
if command.Trigger == "" {
fmt.Println("Cannot register a command with no trigger. Skipping.")
return
}
if command.IgnoreCase {
command.Trigger = strings.ToLower(command.Trigger)
}
command.UsersOnCooldown = mapset.NewSet()
if command.AllowedChannels == nil {
command.AllowedChannels = mapset.NewSet()
}
commands = append(commands, &command)
}
/*
Any message that the bot can read is evaluated here.
The message is matched against each of the command triggers depending on the respective match type.
If one of the commands matches, execute that command and return.
Only one command can be executed per message. Earlier defined commands take precedence.
This is a deliberate choice (for now).
*/
func evaluateMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID {
// Properly log embeds
if len(m.Embeds) > 0 {
log.Printf("<Self> %s", m.Embeds[0].Description)
} else {
log.Printf("<Self> %s", m.Content)
}
return
}
for _, command := range commands {
content := m.Content
if command.IgnoreCase {
content = strings.ToLower(content)
}
if command.RequiresMention {
command.Trigger = fmt.Sprintf(command.Trigger, s.State.User.ID)
}
switch command.Type {
case CommandTypePrefix:
if strings.HasPrefix(content, command.Trigger) {
executeCommand(s, m, command)
return
}
case CommandTypeFullMatch:
if content == command.Trigger {
executeCommand(s, m, command)
return
}
case CommandTypeRegex:
match, _ := regexp.MatchString(command.Trigger, content)
if match {
executeCommand(s, m, command)
return
}
case CommandTypeContains:
if strings.Contains(content, command.Trigger) {
executeCommand(s, m, command)
return
}
}
}
}
/*
Executes the given command on the given message and session.
Sets command cooldowns if necessary and also clears them again.
*/
func executeCommand(session *discordgo.Session, message *discordgo.MessageCreate, command *Command) {
if commandAllowed(session, message, command) {
log.Printf("Executed command %s triggered by user %s", command.Trigger, userToString(message.Author))
if command.Cooldown > 0 && !isDM(session, message) && !isAdmin(message.Author) {
command.UsersOnCooldown.Add(message.Author.ID)
go removeCooldown(command, message.Author.ID)
}
if command.Function == nil {
// simple reply
if command.OutputEmbed == nil {
messageContent := generateReply(message, command)
session.ChannelMessageSend(message.ChannelID, messageContent)
} else {
session.ChannelMessageSendEmbed(message.ChannelID, command.OutputEmbed)
}
if command.DeleteInput {
deleteAndSendViaDM(session, message)
}
} else {
// execute custom function
command.Function(session, message)
}
} else {
log.Printf("Denied command %s to user %s.", command.Trigger, userToString(message.Author))
}
}
/*
* Check if a user has the permission to trigger a given command.
* To be honest, this whole logic is a mess, but I don’t know a better way to handle it.
*/
func commandAllowed(session *discordgo.Session, message *discordgo.MessageCreate, command *Command) bool {
// no restrictions for admins
if isAdmin(message.Author) {
return true
}
// blacklist admin commands for everyone else
if command.AdminOnly {
return false
}
// cooldowns are irrelevant in DMs
if !isDM(session, message) && command.UsersOnCooldown.Contains(message.Author.ID) {
return false
}
// the command is not limited to DMs or we are inside a DM chat
if command.DMOnly && !isDM(session, message) {
return false
}
// no allowed channels = all channels are allowed.
// DMs are whitelisted by default
if command.AllowedChannels.Cardinality() != 0 &&
!command.AllowedChannels.Contains(message.ChannelID) &&
isDM(session, message) {
return false
}
return true
}
func removeCooldown(command *Command, uid string) {
time.Sleep(time.Duration(command.Cooldown) * time.Second)
if command.UsersOnCooldown.Contains(uid) {
command.UsersOnCooldown.Remove(uid)
}
}
func deleteAndSendViaDM(s *discordgo.Session, message *discordgo.MessageCreate) {
s.ChannelMessageDelete(message.ChannelID, message.ID)
dm := getDMChannelFromMessage(s, message)
s.ChannelMessageSend(dm.ID, "Deine Nachricht wurde gelöscht, weil sie ein verbotenes Wort enthielt. Falls du sie editieren und erneut abschicken willst, hier die Nachricht:")
s.ChannelMessageSend(dm.ID, message.Content)
}
func generateReply(message *discordgo.MessageCreate, command *Command) string {
output := command.Output
if command.OutputIsReply {
output = fmt.Sprintf(output, message.Author.ID)
}
return output
}
/*
* I’m beginning to doubt my own self-imposed limitations of
* only allowing func(session, message) to be attached to commands,
* but refactoring that might be more effort than it’s worth.
* Hence, small wrappers around the redirect function.
*/
func modComplain(s *discordgo.Session, m *discordgo.MessageCreate) {
success := redirectMessage(s, m, config.ModChannel, true)
dm, _ := s.UserChannelCreate(m.Author.ID)
if success {
s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
} else {
s.ChannelMessageSend(dm.ID, "Could not send message. Please tell kageru about this.")
}
}
func selphyComplain(s *discordgo.Session, m *discordgo.MessageCreate) {
dm_target, _ := s.UserChannelCreate("190958368301645824")
success := redirectMessage(s, m, dm_target.ID, true)
dm, _ := s.UserChannelCreate(m.Author.ID)
if success {
s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
} else {
s.ChannelMessageSend(dm.ID, "Could not send message. Please tell kageru about this.")
}
}
func redirectMessage(s *discordgo.Session, m *discordgo.MessageCreate, target string, isEmbed bool) bool {
var err error
if isEmbed {
embed := &discordgo.MessageEmbed{
// Embed are anonymized by default for now. Fits the use case.
Author: &discordgo.MessageEmbedAuthor{},
Color: 0xbb0000,
Description: m.Content,
}
_, err = s.ChannelMessageSendEmbed(target, embed)
} else {
_, err = s.ChannelMessageSend(target, messageToString(m.Message))
}
if err != nil {
log.Printf("Could not redirect message", err)
return false
}
return true
}
func echoMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
s.ChannelMessageSend(m.ChannelID, m.Content)
}
func giveAgeRole(s *discordgo.Session, m *discordgo.MessageCreate) {
Member, err := s.GuildMember(config.ServerID, m.Author.ID)
if err != nil {
log.Printf("User could not be retrieved for role assignment\n%s", err)
return
}
dm, err := s.UserChannelCreate(Member.User.ID)
if err != nil {
log.Printf("Could not reply to user\n%s", err)
return
}
required := mapset.NewSetWith("416184227672096780", "416184208470310922", "416184150404628480", "416184132473847810", "440996904948465664")
for command, role := range config.RoleCommands {
if m.Content == command {
// Found the command that was triggered
// This is a restriction imposed by my own wrapper,
// but working around it is not actually necessary for performance and makes the code uglier in other places.
for _, newRole := range config.RoleCommands {
// check if the user has a twitch sub or any of the patreon roles
isAllowed := false
for _, curRole := range Member.Roles {
if required.Contains(curRole) {
isAllowed = true
}
}
if !isAllowed {
s.ChannelMessageSend(dm.ID, "Du kannst dir keine Rolle zuweisen, da weder Patreon noch Twitch mit deinem Account verlinkt ist oder du Selphy auf keiner dieser Plattformen unterstützt. Bei Problemen wende dich bitte an die `!mods`.")
log.Printf("Denied role %s to %s. User is neither patron nor sub.", roleName(s.State, newRole), userToString(m.Author))
return
}
for _, curRole := range Member.Roles {
// If the user already has one of the available roles, tell them and exit
if newRole == curRole {
if curRole == role {
// User is trying to get the role they already have
s.ChannelMessageSend(dm.ID, "Baka, die Rolle hast du doch schon.")
log.Printf("Denied Role %s to %s. User already has %s", roleName(s.State, curRole), userToString(m.Author), roleName(s.State, curRole))
} else {
s.ChannelMessageSend(dm.ID, "Baka, du kannst nur eine der Rollen haben.")
log.Printf("Denied Role %s to %s. User already has %s", roleName(s.State, curRole), userToString(m.Author), roleName(s.State, curRole))
}
return
}
}
}
s.GuildMemberRoleAdd(config.ServerID, m.Author.ID, role)
s.ChannelMessageSend(dm.ID, "Haaai, Ryoukai desu~")
log.Printf("Giving Role %s to %s", roleName(s.State, role), userToString(m.Author))
}
}
}
func getHelpEmbed() *discordgo.MessageEmbed {
commandList := "Im Folgenden findest du eine automatisch generierte Liste aller Commands. Um herauszufinden, was sie tun, probiere sie aus oder lies den Source Code (siehe unten).\n```- !complain\n- !scomplain\n"
for _, command := range commands {
if command.Type != CommandTypeRegex && !command.AdminOnly && !command.DMOnly {
commandList += "- " + command.Trigger + "\n"
}
}
commandList += "```"
embed := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{},
Color: 0xffb90f,
Description: "__Hilfe__",
Fields: []*discordgo.MessageEmbedField{
&discordgo.MessageEmbedField{
Name: "__Commands__",
Value: commandList,
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "__Bugs__",
Value: fmt.Sprintf("Bei Fragen zum Bot, Vorschlägen, Bugs etc. wende dich bitte an <@%s> oder öffne eine Issue auf https://git.kageru.moe/kageru/discord-selphybot.", config.Admins[0]),
Inline: true,
},
},
Thumbnail: &discordgo.MessageEmbedThumbnail{
URL: "https://static-cdn.jtvnw.net/emoticons/v1/1068185/3.0",
},
}
return embed
}