diff --git a/app/Config.go b/app/Config.go new file mode 100644 index 0000000..a6ba8d7 --- /dev/null +++ b/app/Config.go @@ -0,0 +1,60 @@ +package app + +import ( + "fmt" + "os" + "strconv" +) + +type SmtpConfig struct { + Hostname string + Port int + Username string + Password string + + // Max Mustermann + SenderMail string + ReplyMail string +} + +type BotConfig struct { + Token string + Channel string + VerificationRole string +} + +type Config struct { + Smtp *SmtpConfig + Bot *BotConfig +} + +func (c *Config) Load() { + c.loadSmtp() + c.loadBot() +} + +func (c *Config) loadSmtp() { + smtpPort, err := strconv.ParseInt(os.Getenv("SMTP_PORT"), 10, 16) + + if err != nil { + err = fmt.Errorf("an error has occurred while reading the smtp configuration: %v", err) + panic(err) + } + + c.Smtp = &SmtpConfig{ + Hostname: os.Getenv("SMTP_HOST"), + Port: int(smtpPort), + Username: os.Getenv("SMTP_AUTH_USERNAME"), + Password: os.Getenv("SMTP_AUTH_PASSWORD"), + SenderMail: os.Getenv("SMTP_SENDER_EMAIL"), + ReplyMail: os.Getenv("SMTP_REPLY_EMAIL"), + } +} + +func (c *Config) loadBot() { + c.Bot = &BotConfig{ + Token: os.Getenv("DISCORD_TOKEN"), + Channel: os.Getenv("DISCORD_CHANNEL"), + VerificationRole: os.Getenv("VERIFICATION_ROLE"), + } +} diff --git a/app/discord-bot.go b/app/discord-bot.go new file mode 100644 index 0000000..319fcbf --- /dev/null +++ b/app/discord-bot.go @@ -0,0 +1,365 @@ +package app + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + expiremap "github.com/nursik/go-expire-map" + "log" + "net/mail" + "strings" + "time" +) + +type DiscordBot struct { + IsStarted bool + Session *discordgo.Session + config BotConfig + expirationMap *expiremap.ExpireMap + userVerification UserVerification +} + +func (b *DiscordBot) Start(c Config) { + b.config = *c.Bot + b.expirationMap = expiremap.New() + b.userVerification = &MapUserVerification{ + tokens: expiremap.New(), + } + + if !b.IsStarted { + fmt.Println("Starting discord bot.") + + session, err := discordgo.New(c.Bot.Token) + + if err != nil { + err = fmt.Errorf("an error has occurred while initializing the discord bot: %v", err) + panic(err) + } + + session.Identify.Intents = discordgo.IntentsGuildMessages + + b.Session = session + + err = b.Session.Open() + + if err != nil { + err = fmt.Errorf("an error has occurred while starting the discord bot: %v", err) + panic(err) + } + + b.addListener() + b.sendMessage() + } +} + +func (b *DiscordBot) sendMessage() { + buildMessage := b.buildMessage() + + message, err := b.Session.ChannelMessageSendComplex(b.config.Channel, &buildMessage) + + if err != nil { + err = fmt.Errorf("an error has occurred while sending messages in channel: %v", err) + log.Fatal(err) + return + } + + //defer b.Session.ChannelMessageDelete(b.config.Channel, message.ID) + b.deleteAllMessagesInChannel(message.ID) +} + +func (b *DiscordBot) buildMessage() discordgo.MessageSend { + return discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + { + Type: discordgo.EmbedTypeArticle, + Title: "Kontoverifizierung", + Description: "Guten Tag,\nSie befinden sich auf einem Discord Server für Studierende der Hochschule für Wirtschaft und Recht. Damit wir überprüfen können ob Sie Studierender der Hochschule sind, benötigen wir Ihren Namen und Ihre E-Mail Adresse.", + Fields: []*discordgo.MessageEmbedField{}, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Verifizieren", + Style: discordgo.PrimaryButton, + CustomID: "verify", + }, + discordgo.Button{ + Label: "Verlassen", + Style: discordgo.DangerButton, + CustomID: "leave-discord", + }, + }, + }, + }, + } +} + +func (b *DiscordBot) addListener() { + b.Session.AddHandler(b.listenButtons) +} + +func (b *DiscordBot) listenButtons(s *discordgo.Session, m *discordgo.InteractionCreate) { + if m.Type != discordgo.InteractionMessageComponent { + return + } + + data := m.MessageComponentData() + + if data.CustomID == "verify" { + if b.memberHasRole(m) { + return + } + + if b.checkMemberExpiration(m) { + return + } + + b.openVerificationModal(m) + } else { + fmt.Println("TODO: Kick User") + } +} + +func (b *DiscordBot) listenModal(s *discordgo.Session, m *discordgo.InteractionCreate) { + if m.Type != discordgo.InteractionModalSubmit { + return + } + + data := m.ModalSubmitData() + + if data.CustomID == "verification-modal" { + + } +} + +func (b *DiscordBot) handleVerificationModal(m *discordgo.InteractionCreate, messageComponent []discordgo.MessageComponent) { + // Provides user input + fullname := messageComponent[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value + email := messageComponent[1].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value + + // Check email address + if !strings.HasSuffix(email, ".hwr-berlin.de") && !strings.HasSuffix(email, "@hwr-berlin.de") { + b.Session.InteractionRespond(m.Interaction, &InvalidEmailAddressResponse) + return + } + + _, err := mail.ParseAddress(email) + + if err != nil { + b.Session.InteractionRespond(m.Interaction, &InvalidEmailAddressResponse) + return + } + + token := b.userVerification.GenerateToken(&VerficationRequest{ + guildId: m.GuildID, + userId: m.Member.User.ID, + expiringAt: time.Now().Add(time.Duration(5) * time.Minute), + }) + vURLBuilder := new(strings.Builder) + + /* + err = verificationURL.Execute(vURLBuilder, token) + if err != nil { + panic(err) + } + + request := &VerificationRequest{Fullname: fullname, DiscordTag: m.Member.User.String(), AvatarURL: m.Member.User.AvatarURL("512"), BannerURL: m.Member.User.BannerURL("512"), VerificationURL: vURLBuilder.String()} + + // Executes templates + emailSubject := new(strings.Builder) + emailSubjectTemplate.Execute(emailSubject, request) + + emailBody := new(strings.Builder) + emailTemplate.Execute(emailBody, request) + + // Sends email to the email address + err = SendMail(smtpConfig, email, emailSubject.String(), emailBody.String()) + b.expirationMap.Set(m.Member.User.ID, true, time.Duration(1)*time.Minute) + fmt.Println("Sending email to", email, "...") + if err != nil { + fmt.Println("Failed to send email", err) + err = s.InteractionRespond(m.Interaction, &InvalidEmailAddressResponse) + if err != nil { + fmt.Println(err) + } + return + } + fmt.Println("Email successfully sent.") + err = b.Session.InteractionRespond(m.Interaction, &SuccessEmailAddressResponse) + if err != nil { + fmt.Println(err) + }*/ + +} + +func (b *DiscordBot) memberHasRole(ic *discordgo.InteractionCreate) bool { + m := ic.Member + + if contains(m.Roles, b.config.VerificationRole) { + err := b.Session.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Du bist bereits verifiziert.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + if err != nil { + err = fmt.Errorf("an error has occurred while send reaction: %v", err) + log.Fatal(err) + } + + return true + } + + return false +} + +func (b *DiscordBot) checkMemberExpiration(ic *discordgo.InteractionCreate) bool { + m := ic.Member + + _, ok := b.expirationMap.Get(m.User.ID) + + if ok { + err := b.Session.InteractionRespond(ic.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Probiere es bitte später erneut.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + if err != nil { + err = fmt.Errorf("an error has occurred while send reaction: %v", err) + log.Fatal(err) + } + + return true + } + + return false +} + +func (b *DiscordBot) openVerificationModal(ic *discordgo.InteractionCreate) { + response := b.buildVerificationModal() + + err := b.Session.InteractionRespond(ic.Interaction, &response) + + if err != nil { + err = fmt.Errorf("an error has occurred while open verification modal: %v", err) + log.Fatal(err) + } +} + +func (b *DiscordBot) buildVerificationModal() discordgo.InteractionResponse { + data := discordgo.InteractionResponseData{ + Title: "Test", + CustomID: "verification-modal", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "name", + Label: "Vor- und Nachname", + Placeholder: "Max Mustermann", + Required: true, + MinLength: 7, // 3 + space + 3 + Style: discordgo.TextInputShort, + }, + }, + }, + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "email", + Label: "E-Mail Adresse", + Placeholder: "s_mustermann22@stud.hwr-berlin.de", + Required: true, + MinLength: 26, + Style: discordgo.TextInputShort, + }, + }, + }, + // TODO: Implement role selection but currently only text field are supported: https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-modal + // discordgo.ActionsRow{ + // Components: []discordgo.MessageComponent{ + // discordgo.SelectMenu{ + // MenuType: discordgo.UserSelectMenu, + // Placeholder: "teass", + // CustomID: "course", + // MaxValues: 1, + // MinValues: &a, // shit of pointers + // Placeholder: "a", + // MinValues: nil, + // Options: []discordgo.SelectMenuOption{ + // { + // Label: "Kurs A", + // Emoji: discordgo.ComponentEmoji{Name: "🅰️"}, + // Value: "a", + // Description: "Test", + // }, + // { + // Label: "Kurs B", + // Emoji: discordgo.ComponentEmoji{Name: "🇧"}, + // Value: "b", + // Description: "test", + // }, + // }, + // }, + // }, + // }, + }, + } + + return discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &data, + } +} + +func (b *DiscordBot) deleteAllMessagesInChannel(messageId string) { + messages, err := b.Session.ChannelMessages(b.config.Channel, 100, messageId, "", "") + + if err != nil { + err = fmt.Errorf("an error has occurred while getting old messages in channel: %v", err) + log.Fatal(err) + return + } + + messageIDs := make([]string, len(messages)) + for i, message := range messages { + messageIDs[i] = message.ID + } + + if err := b.Session.ChannelMessagesBulkDelete(b.config.Channel, messageIDs); err != nil { + err = fmt.Errorf("an error has occurred while deleting old messages in channel: %v", err) + log.Fatal(err) + return + } +} + +func contains[C comparable](s []C, e C) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +var InvalidEmailAddressResponse = discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "Die angegebene E-Mail Adresse ist nicht gültig.", + }, +} + +var SuccessEmailAddressResponse = discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "Bitte gucken Sie in Ihr E-Mail Postfach und bestätigen Sie die E-Mail.", + }, +} diff --git a/discord-verification.go b/app/old/discord-verification.go similarity index 100% rename from discord-verification.go rename to app/old/discord-verification.go diff --git a/mail.go b/app/old/mail.go similarity index 100% rename from mail.go rename to app/old/mail.go diff --git a/user-verification.go b/app/old/user-verification.go similarity index 100% rename from user-verification.go rename to app/old/user-verification.go diff --git a/app/user-verification.go b/app/user-verification.go new file mode 100644 index 0000000..a8020a0 --- /dev/null +++ b/app/user-verification.go @@ -0,0 +1,54 @@ +package app + +import ( + "fmt" + expiremap "github.com/nursik/go-expire-map" + "math/rand" + "strconv" + "time" +) + +type UserVerification interface { + GenerateToken(request *VerficationRequest) (token string) + + VerifyToken(token string) (request *VerficationRequest, err error) + + Close() +} + +type VerficationRequest struct { + guildId string + userId string + expiringAt time.Time +} + +type MapUserVerification struct { + /* [string] *VerificationRequest */ + tokens *expiremap.ExpireMap +} + +func NewMapUserVerification() UserVerification { + return &MapUserVerification{ + tokens: expiremap.New(), + } +} + +func (s *MapUserVerification) GenerateToken(request *VerficationRequest) string { + t := strconv.FormatUint(rand.Uint64(), 16) + strconv.FormatUint(rand.Uint64(), 16) + fmt.Println("Token generated: "+t+". Which expire in", time.Until(request.expiringAt).Round(time.Second)) + s.tokens.Set(t, request, time.Until(request.expiringAt)) + return t +} + +func (s *MapUserVerification) VerifyToken(token string) (request *VerficationRequest, err error) { + value, ok := s.tokens.Get(token) + if !ok { + return nil, fmt.Errorf("cannot find token %s", token) + } + s.tokens.Delete(token) + return value.(*VerficationRequest), nil +} + +func (c *MapUserVerification) Close() { + c.tokens.Close() +} diff --git a/go.mod b/go.mod index 829af72..d8f0d40 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,13 @@ module discord-verification go 1.19 -require github.com/bwmarrin/discordgo v0.26.2-0.20221202224030-b8188269f98b +require github.com/bwmarrin/discordgo v0.27.1 require github.com/nursik/go-ordered-set v0.0.0-20190626022851-0e8872c36517 // indirect require ( github.com/gorilla/websocket v1.4.2 // indirect - github.com/joho/godotenv v1.4.0 + github.com/joho/godotenv v1.5.1 github.com/nursik/go-expire-map v1.1.0 golang.org/x/crypto v0.1.0 // indirect golang.org/x/sys v0.1.0 // indirect diff --git a/go.sum b/go.sum index d3fb4c2..7cda80b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -github.com/bwmarrin/discordgo v0.26.2-0.20221202224030-b8188269f98b h1:YQVjPn2LVTGV7jRkvkQSOd+8sCKMeqbmFQo1mKEtlu4= -github.com/bwmarrin/discordgo v0.26.2-0.20221202224030-b8188269f98b/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= +github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 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.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= -github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/nursik/go-expire-map v1.1.0 h1:C+OJ81JtHDSPJXfuu0g3e8RRjHLd5of8dVzyfDOB9KY= github.com/nursik/go-expire-map v1.1.0/go.mod h1:wdQsai5n32Uw1IuVXXZoopePGCFh5vb0Dka/TRcboHs= github.com/nursik/go-ordered-set v0.0.0-20190626022851-0e8872c36517 h1:jau4pavdQo5lHeVTjZEGrm4+zvVGZj8SFQt4awsLLXE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..edea5d6 --- /dev/null +++ b/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "discord-verification/app" + "fmt" + "github.com/joho/godotenv" + "os" + "os/signal" + "syscall" +) + +func main() { + err := godotenv.Load() + + if err != nil { + err = fmt.Errorf("an error has occurred while reading the environment variables: %v", err) + panic(err) + } + + config := new(app.Config) + config.Load() + + bot := new(app.DiscordBot) + bot.Start(*config) + + fmt.Println("Waiting on interruption...") + sc := make(chan os.Signal, 1) + signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt) + <-sc +}