Compare commits

...

18 Commits

Author SHA1 Message Date
kageru d6383af189 add image to welcome fallback message 2019-06-21 15:14:34 +02:00
kageru 6393950694
fix possible null pointer dereference in role assignment 2019-06-12 23:51:49 +02:00
kageru 3cdb5418ff
better error handling for broken redirects 2019-06-09 14:00:23 +02:00
kageru ffae758587
hopefully fix !vc 2019-06-08 05:31:06 +02:00
kageru 943e9fcaba
add restriction to age roles 2019-06-08 05:30:49 +02:00
kageru a99496377e
Minor changes to the temporary voice channels 2019-03-13 20:44:17 +01:00
kageru c22f81d6cb
added !vc to create temporary voice channels 2019-03-12 22:37:06 +01:00
kageru a4e7ecb92e
Fixed AllowedChannels not being initialized by default
I didn’t even know go could panic like that selphyDerp
2019-03-02 13:27:05 +01:00
kageru 67fb364f4f
bump version to 1.4 and update changelog 2019-03-02 10:37:24 +01:00
kageru 7be862a133
added simple build script 2019-03-02 10:17:45 +01:00
kageru 377bdde44b
put all log statements behind the logged actions
make sure we don’t log them as successful if they failed
2019-03-02 10:15:33 +01:00
kageru 982144f059 Commands can now be limited to a channel or set of Channels (fixes #5)
Also extracted the command permission logic to a separate function
because it was getting ridiculous. It might be a by slower now, but I
really don’t care. Readabiliy should be much better, except now it’s
even more obvious how bad the entire idea behind that logic was/is.
Shikata ga nai, I guess.
2019-01-27 18:26:12 +01:00
kageru 3b672e2f30 Generalized redirect function, some logging (fixed #3, #4)
Now supports embeds as an optional parameter. Already 80% less copy
paste code for the redirects. Can’t get it of it 100% because reasons.
Slight adjustments to logging so I can finally see embeds the bot is
sending.
2019-01-27 17:55:05 +01:00
kageru 9dd7775168 Slight adjustments to comments 2019-01-13 11:37:44 +01:00
kageru 4566ffc230
gofmt all the things 2019-01-10 00:51:50 +01:00
kageru 6e7229d787 deleted messages are now send to their authors via DM
solves an issue where people would lose long messages due to
accidentally using banned words
2018-12-30 16:54:02 +01:00
kageru ebd7f0bb5e
halloween is over. fixed new dango ID 2018-11-10 12:42:11 +01:00
kageru 0b522fde88
added stupid pun for halloween 2018-10-20 11:50:30 +02:00
9 changed files with 582 additions and 384 deletions

View File

@ -1,6 +1,18 @@
# Selphybot Changelog # Selphybot Changelog
Updates are listed in reverse chronological order. Updates are listed in reverse chronological order.
### 1.5 (dev)
- added !vc to create temporary voice channels
### 1.4
- a copy of each deleted message is now send via DM to the author (suggested by CommanderLook)
- seasonal fluff
- finally use gofmt
- better logging of own messages and embeds
- logging always occurs after the action that is being logged
- redirect function is much cleaner now
- commands can be limited to certain channels
### 1.3 ### 1.3
- use global array of pointers to commands to allow easier modification and avoid unnecessary memcpy - use global array of pointers to commands to allow easier modification and avoid unnecessary memcpy
- added feedback to !complain (used to work but was forgotten when refactoring) - added feedback to !complain (used to work but was forgotten when refactoring)

3
build.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/sh
go build -o selphybot *.go

View File

@ -1,250 +1,333 @@
package main package main
import ( import (
"fmt" "fmt"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/deckarep/golang-set" "github.com/deckarep/golang-set"
"strings" "log"
"time" "regexp"
"log" "strings"
"regexp" "time"
) )
type CommandType int type CommandType int
// These are used to specify Command.CommandType when registering new commands // These are used to specify Command.CommandType when registering new commands
const ( const (
CommandTypePrefix CommandType = 0 CommandTypePrefix CommandType = 0
CommandTypeFullMatch CommandType = 1 CommandTypeFullMatch CommandType = 1
CommandTypeRegex CommandType = 2 CommandTypeRegex CommandType = 2
CommandTypeContains CommandType = 3 CommandTypeContains CommandType = 3
) )
/* /*
This struct represents a command object. This struct represents a command object.
The options should be self-explanatory, but they are also explained in the readme. 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. 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) 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) Any command that has a Trigger is valid (but useless if nothing else is specified)
*/ */
type Command struct { type Command struct {
Trigger string // must be specified Trigger string // must be specified
Output string // no output if unspecified Output string // no output if unspecified
OutputEmbed *discordgo.MessageEmbed // no embed output if unspecified OutputEmbed *discordgo.MessageEmbed // no embed output if unspecified
Type CommandType // defaults to Prefix Type CommandType // defaults to Prefix
Cooldown int // defaults to 0 (no cooldown) Cooldown int // defaults to 0 (no cooldown)
OutputIsReply bool OutputIsReply bool
RequiresMention bool RequiresMention bool
DeleteInput bool DeleteInput bool
DMOnly bool DMOnly bool
AdminOnly bool AdminOnly bool
IgnoreCase bool IgnoreCase bool
// for custom commands that go beyond prints and deletions // for custom commands that go beyond prints and deletions
Function func(*discordgo.Session, *discordgo.MessageCreate) 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) 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 // Performs basic input validation on a given command and adds it to the global command array
func registerCommand(command Command) { func registerCommand(command Command) {
if command.Trigger == "" { if command.Trigger == "" {
fmt.Println("Cannot register a command with no trigger. Skipping.") fmt.Println("Cannot register a command with no trigger. Skipping.")
return return
} }
if command.IgnoreCase { if command.IgnoreCase {
command.Trigger = strings.ToLower(command.Trigger) command.Trigger = strings.ToLower(command.Trigger)
} }
command.UsersOnCooldown = mapset.NewSet() command.UsersOnCooldown = mapset.NewSet()
commands = append(commands, &command) if command.AllowedChannels == nil {
command.AllowedChannels = mapset.NewSet()
}
commands = append(commands, &command)
} }
/* /*
Any message that the bot can read is evaluated here. 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. 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. If one of the commands matches, execute that command and return.
Only one command can be executed per message. Earlier defined commands take precedence. Only one command can be executed per message. Earlier defined commands take precedence.
This is a deliberate choice (for now). This is a deliberate choice (for now).
*/ */
func evaluateMessage(s *discordgo.Session, m *discordgo.MessageCreate) { func evaluateMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID { if m.Author.ID == s.State.User.ID {
log.Printf("<Self> %s", m.Content) // Properly log embeds
return if len(m.Embeds) > 0 {
} log.Printf("<Self> %s", m.Embeds[0].Description)
for _, command := range commands { } else {
content := m.Content log.Printf("<Self> %s", m.Content)
if command.IgnoreCase { }
content = strings.ToLower(content) return
} }
if command.RequiresMention { for _, command := range commands {
command.Trigger = fmt.Sprintf(command.Trigger, s.State.User.ID) content := m.Content
} if command.IgnoreCase {
switch command.Type { content = strings.ToLower(content)
case CommandTypePrefix: }
if strings.HasPrefix(content, command.Trigger) { if command.RequiresMention {
executeCommand(s, m, command) command.Trigger = fmt.Sprintf(command.Trigger, s.State.User.ID)
return }
} switch command.Type {
case CommandTypeFullMatch: case CommandTypePrefix:
if content == command.Trigger { if strings.HasPrefix(content, command.Trigger) {
executeCommand(s, m, command) executeCommand(s, m, command)
return return
} }
case CommandTypeRegex: case CommandTypeFullMatch:
match, _ := regexp.MatchString(command.Trigger, content) if content == command.Trigger {
if match { executeCommand(s, m, command)
executeCommand(s, m, command) return
return }
} case CommandTypeRegex:
case CommandTypeContains: match, _ := regexp.MatchString(command.Trigger, content)
if strings.Contains(content, command.Trigger) { if match {
executeCommand(s, m, command) executeCommand(s, m, command)
return return
} }
} case CommandTypeContains:
} if strings.Contains(content, command.Trigger) {
executeCommand(s, m, command)
return
}
}
}
} }
/* /*
Executes the given command on the given message and session. Executes the given command on the given message and session.
Sets command cooldowns if necessary and also clears them again. Sets command cooldowns if necessary and also clears them again.
*/ */
func executeCommand(session *discordgo.Session, message *discordgo.MessageCreate, command *Command) { func executeCommand(session *discordgo.Session, message *discordgo.MessageCreate, command *Command) {
if isAdmin(message.Author) || // no restrictions for admins if commandAllowed(session, message, command) {
(!command.AdminOnly && (isDM(session, message) || !command.UsersOnCooldown.Contains(message.Author.ID)) && log.Printf("Executed command %s triggered by user %s", command.Trigger, userToString(message.Author))
(!command.DMOnly || isDM(session, message))) { 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))
}
}
log.Printf("Executed command %s triggered by user %s", command.Trigger, userToString(message.Author)) /*
if command.Cooldown > 0 && !isDM(session, message) && !isAdmin(message.Author) { * Check if a user has the permission to trigger a given command.
command.UsersOnCooldown.Add(message.Author.ID) * To be honest, this whole logic is a mess, but I dont know a better way to handle it.
go removeCooldown(command, message.Author.ID) */
} func commandAllowed(session *discordgo.Session, message *discordgo.MessageCreate, command *Command) bool {
if command.Function == nil { // no restrictions for admins
// simple reply if isAdmin(message.Author) {
if command.OutputEmbed == nil { return true
messageContent := generateReply(message, command) }
session.ChannelMessageSend(message.ChannelID, messageContent) // blacklist admin commands for everyone else
} else { if command.AdminOnly {
session.ChannelMessageSendEmbed(message.ChannelID, command.OutputEmbed) return false
} }
if command.DeleteInput { // cooldowns are irrelevant in DMs
session.ChannelMessageDelete(message.ChannelID, message.ID) if !isDM(session, message) && command.UsersOnCooldown.Contains(message.Author.ID) {
} return false
} else { }
// execute custom function // the command is not limited to DMs or we are inside a DM chat
command.Function(session, message) if command.DMOnly && !isDM(session, message) {
} return false
} else { }
log.Printf("Denied command %s to user %s.", command.Trigger, userToString(message.Author)) // 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) { func removeCooldown(command *Command, uid string) {
time.Sleep(time.Duration(command.Cooldown) * time.Second) time.Sleep(time.Duration(command.Cooldown) * time.Second)
if command.UsersOnCooldown.Contains(uid) { if command.UsersOnCooldown.Contains(uid) {
command.UsersOnCooldown.Remove(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 { func generateReply(message *discordgo.MessageCreate, command *Command) string {
output := command.Output output := command.Output
if command.OutputIsReply { if command.OutputIsReply {
output = fmt.Sprintf(output, message.Author.ID) output = fmt.Sprintf(output, message.Author.ID)
} }
return output return output
} }
/* /*
Any message passed to this method will be redirected to config.ModChannel. * Im beginning to doubt my own self-imposed limitations of
This is useful for anonymous complaints or similar messages. * only allowing func(session, message) to be attached to commands,
* but refactoring that might be more effort than its worth.
* Hence, small wrappers around the redirect function.
*/ */
func redirectComplaint(s *discordgo.Session, m *discordgo.MessageCreate) { func modComplain(s *discordgo.Session, m *discordgo.MessageCreate) {
embed := &discordgo.MessageEmbed { success := redirectMessage(s, m, config.ModChannel, true)
Author: &discordgo.MessageEmbedAuthor{}, dm, _ := s.UserChannelCreate(m.Author.ID)
Color: 0xbb0000, if success {
Description: m.Content, s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
} } else {
s.ChannelMessageSendEmbed(config.ModChannel, embed) s.ChannelMessageSend(dm.ID, "Could not send message. Please tell kageru about this.")
dm, _ := s.UserChannelCreate(m.Author.ID) }
s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
} }
// copy paste programming btw :haHAA: func selphyComplain(s *discordgo.Session, m *discordgo.MessageCreate) {
func redirectComplaintToDM(s *discordgo.Session, m *discordgo.MessageCreate) { dm_target, _ := s.UserChannelCreate("190958368301645824")
embed := &discordgo.MessageEmbed { success := redirectMessage(s, m, dm_target.ID, true)
Author: &discordgo.MessageEmbedAuthor{}, dm, _ := s.UserChannelCreate(m.Author.ID)
Color: 0xbb0000, if success {
Description: m.Content, s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
} } else {
dm_target, _ := s.UserChannelCreate("190958368301645824") s.ChannelMessageSend(dm.ID, "Could not send message. Please tell kageru about this.")
s.ChannelMessageSendEmbed(dm_target.ID, embed) }
dm, _ := s.UserChannelCreate(m.Author.ID) }
s.ChannelMessageSend(dm.ID, config.ComplaintReceivedMessage)
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) { func echoMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
s.ChannelMessageSend(m.ChannelID, m.Content) s.ChannelMessageSend(m.ChannelID, m.Content)
} }
func giveAgeRole(s *discordgo.Session, m *discordgo.MessageCreate) { func giveAgeRole(s *discordgo.Session, m *discordgo.MessageCreate) {
Member, _ := s.GuildMember(config.ServerID, m.Author.ID) Member, err := s.GuildMember(config.ServerID, m.Author.ID)
dm, _ := s.UserChannelCreate(Member.User.ID) if err != nil {
for command, role := range config.RoleCommands { log.Printf("User could not be retrieved for role assignment\n%s", err)
if m.Content == command { return
// Found the command that was triggered }
// This is a restriction imposed by my own wrapper, dm, err := s.UserChannelCreate(Member.User.ID)
// but working around it is not actually necessary for performance and makes the code uglier in other places. if err != nil {
for _, newRole := range config.RoleCommands { log.Printf("Could not reply to user\n%s", err)
for _, curRole := range Member.Roles { return
// If the user already has one of the available roles, tell him and exit }
if newRole == curRole { required := mapset.NewSetWith("416184227672096780", "416184208470310922", "416184150404628480", "416184132473847810", "440996904948465664")
if curRole == role { for command, role := range config.RoleCommands {
// User is trying to get the role they already have if m.Content == command {
s.ChannelMessageSend(dm.ID, "Baka, die Rolle hast du doch schon.") // Found the command that was triggered
log.Printf("Denied Role %s to %s. User already has %s", roleName(s.State, curRole), userToString(m.Author), roleName(s.State, curRole)) // This is a restriction imposed by my own wrapper,
} else { // but working around it is not actually necessary for performance and makes the code uglier in other places.
s.ChannelMessageSend(dm.ID, "Baka, du kannst nur eine der Rollen haben.") for _, newRole := range config.RoleCommands {
log.Printf("Denied Role %s to %s. User already has %s", roleName(s.State, curRole), userToString(m.Author), roleName(s.State, curRole)) // check if the user has a twitch sub or any of the patreon roles
} isAllowed := false
return for _, curRole := range Member.Roles {
} if required.Contains(curRole) {
} isAllowed = true
} }
log.Printf("Giving Role %s to %s", roleName(s.State, role), userToString(m.Author)) }
s.ChannelMessageSend(dm.ID, "Haaai, Ryoukai desu~")
s.GuildMemberRoleAdd(config.ServerID, m.Author.ID, role) 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 { 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" 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 { for _, command := range commands {
if command.Type != CommandTypeRegex && !command.AdminOnly && !command.DMOnly { if command.Type != CommandTypeRegex && !command.AdminOnly && !command.DMOnly {
commandList += "- " + command.Trigger + "\n" commandList += "- " + command.Trigger + "\n"
} }
} }
commandList += "```" commandList += "```"
embed := &discordgo.MessageEmbed{ embed := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{}, Author: &discordgo.MessageEmbedAuthor{},
Color: 0xffb90f, Color: 0xffb90f,
Description: "__Hilfe__", Description: "__Hilfe__",
Fields: []*discordgo.MessageEmbedField { Fields: []*discordgo.MessageEmbedField{
&discordgo.MessageEmbedField { &discordgo.MessageEmbedField{
Name: "__Commands__", Name: "__Commands__",
Value: commandList, Value: commandList,
Inline: true, Inline: true,
}, },
&discordgo.MessageEmbedField { &discordgo.MessageEmbedField{
Name: "__Bugs__", 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]), 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, Inline: true,
}, },
}, },
Thumbnail: &discordgo.MessageEmbedThumbnail{ Thumbnail: &discordgo.MessageEmbedThumbnail{
URL: "https://static-cdn.jtvnw.net/emoticons/v1/1068185/3.0", URL: "https://static-cdn.jtvnw.net/emoticons/v1/1068185/3.0",
}, },
} }
return embed return embed
} }

View File

@ -1,40 +1,38 @@
package main package main
import ( import (
"os" "encoding/json"
"encoding/json" "os"
) )
type Embed struct { type Embed struct {
Message string Message string
QuestionsTitle string QuestionsTitle string
QuestionsText string QuestionsText string
BugsTitle string BugsTitle string
BugsText string BugsText string
Image string Image string
} }
type Config struct { type Config struct {
Admins []string Admins []string
ServerID string ServerID string
LockedRoleID string LockedRoleID string
Token string Token string
WelcomeChannel string WelcomeChannel string
GeneralChannel string GeneralChannel string
SendWelcomeDM bool SendWelcomeDM bool
RequireAccept bool RequireAccept bool
ComplaintReceivedMessage string ComplaintReceivedMessage string
ModChannel string ModChannel string
WelcomeEmbed Embed WelcomeEmbed Embed
RoleCommands map[string]string RoleCommands map[string]string
} }
func readConfig() Config { func readConfig() Config {
file, _ := os.Open("config.json") file, _ := os.Open("config.json")
conf := Config{} conf := Config{}
json.NewDecoder(file).Decode(&conf) json.NewDecoder(file).Decode(&conf)
file.Close() file.Close()
return conf return conf
} }

View File

@ -1,55 +1,53 @@
package main package main
import ( import (
"fmt" "fmt"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"log" "log"
) )
func onJoin(s *discordgo.Session, member *discordgo.GuildMemberAdd) { func onJoin(s *discordgo.Session, member *discordgo.GuildMemberAdd) {
if !member.User.Bot && config.RequireAccept { if !member.User.Bot && config.RequireAccept {
s.GuildMemberRoleAdd(config.ServerID, member.User.ID, config.LockedRoleID) s.GuildMemberRoleAdd(config.ServerID, member.User.ID, config.LockedRoleID)
} }
if !member.User.Bot && config.SendWelcomeDM { if !member.User.Bot && config.SendWelcomeDM {
dm, err := s.UserChannelCreate(member.User.ID) dm, err := s.UserChannelCreate(member.User.ID)
if err != nil { if err != nil {
log.Println(fmt.Sprintf("Error creating DM with %s", userToString(member.User), err)) log.Println(fmt.Sprintf("Error creating DM with %s", userToString(member.User), err))
} else { } else {
embed := getWelcomeEmbed() embed := getWelcomeEmbed()
_, err = s.ChannelMessageSendEmbed(dm.ID, embed) _, err = s.ChannelMessageSendEmbed(dm.ID, embed)
if err != nil { if err != nil {
log.Println(fmt.Sprintf("Error sending DM to %s", userToString(member.User), err)) log.Println(fmt.Sprintf("Error sending DM to %s", userToString(member.User), err))
} }
} }
if err != nil { if err != nil {
// if any of the preceding operations produced an error // if any of the preceding operations produced an error
log.Printf("Sending welcome @mention at %s", userToString(member.User)) log.Printf("Sending welcome @mention @%s", userToString(member.User))
s.ChannelMessageSend(config.GeneralChannel, fmt.Sprintf("Wilkommen <@%s>. Bitte aktiviere vorübergehend DMs für diesen Server und sende eine Nachricht mit !welcome an mich.", member.User.ID)) s.ChannelMessageSend(config.GeneralChannel, fmt.Sprintf("Wilkommen <@%s>. Bitte aktiviere vorübergehend DMs für diesen Server und sende eine Nachricht mit !welcome an mich. https://cdn.discordapp.com/attachments/333450842151714816/591581555219234826/Selphy-queen.png", member.User.ID))
} }
} }
log.Printf("User joined: %s", userToString(member.User)) log.Printf("User joined: %s", userToString(member.User))
} }
func onDM(s *discordgo.Session, m *discordgo.MessageCreate) { func onDM(s *discordgo.Session, m *discordgo.MessageCreate) {
log.Printf("Received DM from %s with content: “%s”", userToString(m.Author), m.Content) log.Printf("Received DM from %s with content: “%s”", userToString(m.Author), m.Content)
fmt.Sprintf("Received DM from %s with content: “%s”", userToString(m.Author), m.Content) Member, _ := s.GuildMember(config.ServerID, m.Author.ID)
Member, _ := s.GuildMember(config.ServerID, m.Author.ID) dm, _ := s.UserChannelCreate(Member.User.ID)
dm, _ := s.UserChannelCreate(Member.User.ID) for comm, role := range config.RoleCommands {
for comm, role := range config.RoleCommands { if m.Content == comm {
if m.Content == comm { for _, irole := range config.RoleCommands {
for _, irole := range config.RoleCommands { for _, mrole := range Member.Roles {
for _, mrole := range Member.Roles { if irole == mrole {
if irole == mrole { s.ChannelMessageSend(dm.ID, "Baka, du kannst nur eine der Rollen haben.")
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, irole), userToString(m.Author), roleName(s.State, irole))
log.Printf("Denied Role %s to %s. User already has %s", roleName(s.State, irole), userToString(m.Author), roleName(s.State, irole)) return
return }
} }
} }
} s.ChannelMessageSend(dm.ID, "Haaai, Ryoukai desu~")
log.Printf("Giving Role %s to %s", roleName(s.State, role), userToString(m.Author)) 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))
s.GuildMemberRoleAdd(config.ServerID, m.Author.ID, role) }
} }
}
} }

View File

@ -1,52 +1,67 @@
package main package main
import ( import (
"github.com/bwmarrin/discordgo" "fmt"
"log" "github.com/bwmarrin/discordgo"
"fmt" "log"
) )
func unlockUser(s *discordgo.Session, id string) { func unlockUser(s *discordgo.Session, id string) {
s.GuildMemberRoleRemove(config.ServerID, id, config.LockedRoleID) s.GuildMemberRoleRemove(config.ServerID, id, config.LockedRoleID)
log.Printf("Removed lock from user: %s", userToString(getUser(s, id))) log.Printf("Removed lock from user: %s", userToString(getUser(s, id)))
} }
func userToString(u *discordgo.User) string { func userToString(u *discordgo.User) string {
return fmt.Sprintf("%s#%s (ID: %s)", u.Username, u.Discriminator, u.ID) return fmt.Sprintf("%s#%s (ID: %s)", u.Username, u.Discriminator, u.ID)
} }
func roleName(s *discordgo.State, rid string) string { func roleName(s *discordgo.State, rid string) string {
role, _ := s.Role(config.ServerID, rid) role, _ := s.Role(config.ServerID, rid)
return role.Name return role.Name
} }
func channelToString(c *discordgo.Channel) string { func channelToString(c *discordgo.Channel) string {
return fmt.Sprintf("%s (ID: %s) on %s", c.Name, c.ID, c.GuildID) return fmt.Sprintf("%s (ID: %s) on %s", c.Name, c.ID, c.GuildID)
} }
func messageToString(m *discordgo.Message) string { func messageToString(m *discordgo.Message) string {
return fmt.Sprintf("<%s#%s>: %s", m.Author.Username, m.Author.Discriminator, m.Content) return fmt.Sprintf("<%s#%s>: %s", m.Author.Username, m.Author.Discriminator, m.Content)
} }
func getChannel(s *discordgo.State, cid string) *discordgo.Channel { func getChannel(s *discordgo.State, cid string) *discordgo.Channel {
channel, _ := s.Channel(cid) channel, _ := s.Channel(cid)
return channel return channel
} }
func getUser(s *discordgo.Session, uid string) *discordgo.User { func getUser(s *discordgo.Session, uid string) *discordgo.User {
user, _ := s.User(uid) user, _ := s.User(uid)
return user return user
} }
func isDM(s *discordgo.Session, m *discordgo.MessageCreate) bool { func isDM(s *discordgo.Session, m *discordgo.MessageCreate) bool {
return (getChannel(s.State, m.ChannelID).Type == discordgo.ChannelTypeDM) return (getChannel(s.State, m.ChannelID).Type == discordgo.ChannelTypeDM)
}
func getDMChannelFromMessage(s *discordgo.Session, m *discordgo.MessageCreate) *discordgo.Channel {
dm, _ := s.UserChannelCreate(m.Author.ID)
return dm
} }
func isAdmin(u *discordgo.User) bool { func isAdmin(u *discordgo.User) bool {
for _, admin := range config.Admins { for _, admin := range config.Admins {
if u.ID == admin { if u.ID == admin {
return true return true
} }
} }
return false return false
}
func getServer() (*discordgo.Guild, error) {
server, err := state.Guild(config.ServerID)
return server, err
}
func remove(channels []*discordgo.Channel, position int) []*discordgo.Channel {
channels[len(channels)-1], channels[position] = channels[position], channels[len(channels)-1]
return channels[:len(channels)-1]
} }

143
main.go
View File

@ -1,88 +1,103 @@
package main package main
import ( import (
"fmt" "fmt"
"os/signal" "github.com/bwmarrin/discordgo"
"os" "github.com/deckarep/golang-set"
"syscall" "log"
"log" "os"
"github.com/bwmarrin/discordgo" "os/signal"
"syscall"
) )
var config = readConfig() var config = readConfig()
var commands []*Command var commands []*Command
var state *discordgo.State
func main() { func main() {
dg, err := discordgo.New("Bot " + config.Token) session, err := discordgo.New("Bot " + config.Token)
if err != nil { if err != nil {
fmt.Println("error: ", err) fmt.Println("error: ", err)
return return
} }
defer dg.Close() defer session.Close()
dg.AddHandler(evaluateMessage) session.AddHandler(evaluateMessage)
dg.AddHandler(onJoin) session.AddHandler(onJoin)
err = dg.Open() err = session.Open()
if err != nil { if err != nil {
fmt.Println("No connection:\n", err) fmt.Println("No connection:\n", err)
return return
} }
f, err := os.OpenFile("selphybot.log", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0666) f, err := os.OpenFile("selphybot.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil { if err != nil {
fmt.Println("Error opening log file:\n", err) fmt.Println("Error opening log file:\n", err)
} }
defer f.Close() defer f.Close()
log.SetOutput(f) log.SetOutput(f)
dg.UpdateStatus(0, "!help") session.UpdateStatus(0, "!help")
addCommands() state = discordgo.NewState()
server, err := session.Guild(config.ServerID)
if err != nil {
fmt.Println("Guild incorrectly configured. Exiting...")
return
}
state.GuildAdd(server)
go checkAndDeleteUnusedChannels(session)
addCommands()
fmt.Println("Bot running. selphyWoo") fmt.Println("Bot running. selphyWoo")
log.Println("Bot running. selphyWoo") log.Println("Bot running. selphyWoo")
sc := make(chan os.Signal, 1) sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill) signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc <-sc
fmt.Println("Exiting...") for _, channel := range tempChannels {
log.Println("Exiting...") session.ChannelDelete(channel.ID)
}
fmt.Println("Exiting...")
log.Println("Exiting...")
} }
// I’ll just put all of the commands here for now. // I’ll just put all of the commands here for now.
func addCommands() { func addCommands() {
// Moderation // Moderation
registerCommand(Command{Trigger: "^[^`]*([()|DoOvVcC][-=^']?;|;[-=^']?[()|DoOpPvVcC]|:wink:|😉)[^`]*$", Output: "<@%s> Oboe!", DeleteInput: true, OutputIsReply: true, Type: CommandTypeRegex}) registerCommand(Command{Trigger: "^[^`]*([()|DoOvVcC][-=^']?;|;[-=^']?[()|DoOpPvVcC3]|:wink:|😉)[^`]*$", Output: "<@%s> Oboe!", DeleteInput: true, OutputIsReply: true, Type: CommandTypeRegex})
registerCommand(Command{Trigger: "(\\s|\n|^)[nN][hH]([ ?.,\n]|$)", Output: "<@%s> „nh“ ist kein Wort, du Oboe!", DeleteInput: true, OutputIsReply: true, Type: CommandTypeRegex}) registerCommand(Command{Trigger: "(\\s|\n|^)[nN][hH]([ ?.,\n]|$)", Output: "<@%s> „nh“ ist kein Wort, du Oboe!", DeleteInput: true, OutputIsReply: true, Type: CommandTypeRegex})
registerCommand(Command{Trigger: "einzigste", Output: "<@%s> Es heißt „einzige“, du Tuba.", DeleteInput: true, OutputIsReply: true, Type: CommandTypeContains}) registerCommand(Command{Trigger: "einzigste", Output: "<@%s> Es heißt „einzige“, du Tuba.", DeleteInput: true, OutputIsReply: true, Type: CommandTypeContains})
registerCommand(Command{Trigger: "!complain", Type: CommandTypePrefix, DMOnly: true, Function: redirectComplaint}) registerCommand(Command{Trigger: "!complain", Type: CommandTypePrefix, DMOnly: true, Function: modComplain})
registerCommand(Command{Trigger: "!scomplain", Type: CommandTypePrefix, DMOnly: true, Function: redirectComplaintToDM}) registerCommand(Command{Trigger: "!scomplain", Type: CommandTypePrefix, DMOnly: true, Function: selphyComplain})
registerCommand(Command{Trigger: "!beschwerde", Type: CommandTypePrefix, DMOnly: true, Function: redirectComplaint}) registerCommand(Command{Trigger: "!beschwerde", Type: CommandTypePrefix, DMOnly: true, Function: modComplain})
for comm, _ := range config.RoleCommands { for comm, _ := range config.RoleCommands {
registerCommand(Command{Trigger: comm, Type: CommandTypeFullMatch, DMOnly: true, Function: giveAgeRole}) registerCommand(Command{Trigger: comm, Type: CommandTypeFullMatch, DMOnly: true, Function: giveAgeRole})
} }
// Misc commands // Fluff
registerCommand(Command{Trigger: "o/", Output: "\\o", Type: CommandTypeFullMatch, Cooldown: 10}) registerCommand(Command{Trigger: "o/", Output: "\\o", Type: CommandTypeFullMatch, Cooldown: 10})
registerCommand(Command{Trigger: "\\o", Output: "o/", Type: CommandTypeFullMatch, Cooldown: 10}) registerCommand(Command{Trigger: "\\o", Output: "o/", Type: CommandTypeFullMatch, Cooldown: 10})
registerCommand(Command{Trigger: "\\o/", Output: "/o\\", Type: CommandTypeFullMatch, Cooldown: 10}) registerCommand(Command{Trigger: "\\o/", Output: "/o\\", Type: CommandTypeFullMatch, Cooldown: 10})
registerCommand(Command{Trigger: "/o\\", Output: "\\o/", Type: CommandTypeFullMatch, Cooldown: 10}) registerCommand(Command{Trigger: "/o\\", Output: "\\o/", Type: CommandTypeFullMatch, Cooldown: 10})
registerCommand(Command{Trigger: "!heil", Output: "(ノ・ェ・)ノ Selphy (ノ・ェ・)ノ", Type: CommandTypeFullMatch, Cooldown: 30}) registerCommand(Command{Trigger: "!heil", Output: "(ノ・ェ・)ノ Selphy (ノ・ェ・)ノ", Type: CommandTypeFullMatch, Cooldown: 30})
registerCommand(Command{Trigger: "ayy", Output: "lmao", Type: CommandTypeFullMatch, Cooldown: 0}) registerCommand(Command{Trigger: "ayy", Output: "lmao", Type: CommandTypeFullMatch, Cooldown: 0})
registerCommand(Command{Trigger: "<:selphyDango:441001954542616576>", Output: "<:dango:430669469799677953> :notes: Dango Daikazoku :notes: <:dango:430669469799677953>", Type: CommandTypeFullMatch, Cooldown: 10800}) registerCommand(Command{Trigger: "<:selphyDango:531594585424527370>", Output: "<:dango:430669469799677953> :notes: Dango Daikazoku :notes: <:dango:430669469799677953>", Type: CommandTypeFullMatch, Cooldown: 10800})
registerCommand(Command{Trigger: "praise the sun", Output: "If only I could be so grossly incandescent \\\\[T]/", Type: CommandTypeContains, IgnoreCase: true, Cooldown: 85600}) registerCommand(Command{Trigger: "praise the sun", Output: "If only I could be so grossly incandescent \\\\[T]/", Type: CommandTypeContains, IgnoreCase: true, Cooldown: 85600})
// Information // Information
registerCommand(Command{Trigger: "!welcome", OutputEmbed: getWelcomeEmbed(), Type: CommandTypeFullMatch, DMOnly: true}) registerCommand(Command{Trigger: "!welcome", OutputEmbed: getWelcomeEmbed(), Type: CommandTypeFullMatch, DMOnly: true})
registerCommand(Command{Trigger: "!mods", Output: "Bei Fragen, Problemen und Beschwerden wende dich bitte an die Moderatoren oder schick mir eine Nachricht beginnend mit !complain, um dich anonym zu beschweren.\nAktuell anwesende Mods werden dir rechts mit dem Rang „Maid“ angezeigt.", Type: CommandTypeFullMatch}) registerCommand(Command{Trigger: "!mods", Output: "Bei Fragen, Problemen und Beschwerden wende dich bitte an die Moderatoren oder schick mir eine Nachricht beginnend mit !complain, um dich anonym zu beschweren.\nAktuell anwesende Mods werden dir rechts mit dem Rang „Maid“ angezeigt.", Type: CommandTypeFullMatch})
// Admin and/or debug // Features :Pog:
registerCommand(Command{Trigger: "<@%s> <3", Output: "<@%s> <3", Type: CommandTypeFullMatch, AdminOnly: true, OutputIsReply: true, RequiresMention: true}) registerCommand(Command{Trigger: "!vc ", Type: CommandTypePrefix, Function: parseVoiceChannelCommand, AllowedChannels: mapset.NewSetWith("525852491976278016")})
registerCommand(Command{Trigger: "echo", Type: CommandTypePrefix, Function: echoMessage, AdminOnly: true})
// This needs to be the last command because getHelpEmbed is evaluated here once, not on every function call. Putting it too early will result in missing commands in the output. // Admin and/or debug
registerCommand(Command{Trigger: "!help", OutputEmbed: getHelpEmbed(), Type: CommandTypeFullMatch}) registerCommand(Command{Trigger: "<@%s> <3", Output: "<@%s> <3", Type: CommandTypeFullMatch, AdminOnly: true, OutputIsReply: true, RequiresMention: true})
registerCommand(Command{Trigger: "echo", Type: CommandTypePrefix, Function: echoMessage, AdminOnly: true})
fmt.Printf("Successfully initialized %d commands\n", len(commands)) // This needs to be the last command because getHelpEmbed is evaluated here once, not on every function call. Putting it too early will result in missing commands in the output.
log.Printf("Successfully initialized %d commands", len(commands)) registerCommand(Command{Trigger: "!help", OutputEmbed: getHelpEmbed(), Type: CommandTypeFullMatch})
fmt.Printf("Successfully initialized %d commands\n", len(commands))
log.Printf("Successfully initialized %d commands", len(commands))
} }

74
voicechannel.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"fmt"
"github.com/bwmarrin/discordgo"
"log"
"strconv"
"strings"
"time"
)
var tempChannels []*discordgo.Channel
func parseVoiceChannelCommand(session *discordgo.Session, message *discordgo.MessageCreate) {
userLimit, err := strconv.Atoi(strings.Split(message.Content, " ")[1])
if err != nil {
session.ChannelMessageSend(message.ChannelID, "Error: Expected a number after !vc")
log.Printf("Incorrect syntax for !vc, “%s” triggered by %s", message.Content, userToString(message.Author))
return
}
if userLimit > 99 {
session.ChannelMessageSend(message.ChannelID, fmt.Sprintf("Als ob %d Leute *mit dir* in einen Channel wollen.", userLimit-1))
log.Printf("%s tried to create a channel with %d slots", userToString(message.Author), userLimit)
return
}
createData := discordgo.GuildChannelCreateData{
Name: fmt.Sprintf("%s’s Volatile Corner", message.Author.Username),
Type: discordgo.ChannelTypeGuildVoice,
UserLimit: userLimit,
ParentID: "410162599762853909",
}
channel, err := session.GuildChannelCreateComplex(message.GuildID, createData)
if err != nil {
session.ChannelMessageSend(message.ChannelID, "Couldn’t create the voice channel. Please bug kageru about this.")
log.Printf("Failed to create voice channel, %s", err)
return
}
tempChannels = append(tempChannels, channel)
err = session.GuildMemberMove(config.ServerID, message.Author.ID, channel.ID)
if err != nil {
log.Printf("Couldn’t move user %s: %s", userToString(message.Author), err)
}
session.ChannelMessageSend(message.ChannelID, "haaaai~")
log.Printf("Created channel %s", channel.ID)
}
func checkAndDeleteUnusedChannels(session *discordgo.Session) {
for true {
time.Sleep(30 * time.Second)
server, err := getServer()
if err == nil {
for i, channel := range tempChannels {
if channelIsEmpty(channel.ID, server.VoiceStates) {
session.ChannelDelete(channel.ID)
tempChannels = remove(tempChannels, i)
log.Printf("Deleted channel %s", channel.ID)
log.Printf("Tempchannels: %d", len(tempChannels))
break
}
}
} else {
log.Printf("Could not retrieve voice state from API, %s", err)
}
}
}
func channelIsEmpty(channelID string, voiceStates []*discordgo.VoiceState) bool {
for _, state := range voiceStates {
if channelID == state.ChannelID {
return false
}
}
return true
}

View File

@ -1,29 +1,29 @@
package main package main
import ( import (
"github.com/bwmarrin/discordgo" "fmt"
"fmt" "github.com/bwmarrin/discordgo"
) )
func getWelcomeEmbed() *discordgo.MessageEmbed { func getWelcomeEmbed() *discordgo.MessageEmbed {
return &discordgo.MessageEmbed { return &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{}, Author: &discordgo.MessageEmbedAuthor{},
Color: 0xffb90f, Color: 0xffb90f,
Description: config.WelcomeEmbed.Message, Description: config.WelcomeEmbed.Message,
Fields: []*discordgo.MessageEmbedField { Fields: []*discordgo.MessageEmbedField{
&discordgo.MessageEmbedField { &discordgo.MessageEmbedField{
Name: config.WelcomeEmbed.QuestionsTitle, Name: config.WelcomeEmbed.QuestionsTitle,
Value: config.WelcomeEmbed.QuestionsText, Value: config.WelcomeEmbed.QuestionsText,
Inline: true, Inline: true,
}, },
&discordgo.MessageEmbedField { &discordgo.MessageEmbedField{
Name: config.WelcomeEmbed.BugsTitle, Name: config.WelcomeEmbed.BugsTitle,
Value: fmt.Sprintf(config.WelcomeEmbed.BugsText, config.Admins[0]), Value: fmt.Sprintf(config.WelcomeEmbed.BugsText, config.Admins[0]),
Inline: true, Inline: true,
}, },
}, },
Thumbnail: &discordgo.MessageEmbedThumbnail{ Thumbnail: &discordgo.MessageEmbedThumbnail{
URL: config.WelcomeEmbed.Image, URL: config.WelcomeEmbed.Image,
}, },
} }
} }