package bolt import ( "fmt" "log" "os" "os/signal" "slices" "strings" "syscall" "time" dg "github.com/bwmarrin/discordgo" ) const ( TOKEN_ENV_VAR = "DISCORD_TOKEN" //label for token environment variable BOT_INTENTS = dg.IntentGuilds | dg.IntentGuildMembers | dg.IntentGuildPresences | dg.IntentMessageContent | dg.IntentsGuildMessages ) // basic bot structure containing discordgo connection as well as the command map type bolt struct { *dg.Session //holds discordgo internals commands map[string]Command //maps trigger phrase to command struct for fast lookup indicator string //the indicator used to detect whether a message is a command logLvl LogLevel //determines how much the bot logs } type Bolt interface { Start() error AddCommands(cmd ...Command) //filtered methods stop() error messageHandler(s *dg.Session, msg *dg.MessageCreate) handleCommand(msg *dg.MessageCreate, s *dg.Session, server *dg.Guild, channel *dg.Channel, lg int) error createReply(content, message, channel, guild string) *dg.MessageSend getRemainingTimeout(timeout time.Time) string roleCheck(msg *dg.MessageCreate, s *dg.Session, run Command) (bool, error) timeoutCheck(msg *dg.MessageCreate, s *dg.Session, run Command) (bool, error) } // setup func init() { //validate environment variables _, check := os.LookupEnv(TOKEN_ENV_VAR) if !check { log.Fatalf("the %s environment variable must be set", TOKEN_ENV_VAR) } } // create a new bolt interface func New(opts ...Option) Bolt { bot, err := dg.New(fmt.Sprintf("Bot %s", os.Getenv(TOKEN_ENV_VAR))) if err != nil { log.Fatal(err) } bot.Identify.Intents = BOT_INTENTS b := &bolt{ Session: bot, commands: make(map[string]Command, 0), logLvl: LogLevelAll, } //set default command indicator b.indicator = "." //apply options to bolt for _, opt := range opts { opt(b) } return b } // starts the bot, commands are added and the connection to Discord is opened, this is a BLOCKING // call that handles safe shutdown of the bot func (b *bolt) Start() error { //register commands and open connection b.AddHandler(b.messageHandler) err := b.Open() if err != nil { return err } //safe shutdown handler sigChannel := make(chan os.Signal, 1) signal.Notify(sigChannel, syscall.SIGINT) <-sigChannel if err := b.stop(); err != nil { return err } return nil } // stops the bot func (b *bolt) stop() error { return b.Close() } // adds commands to bot command map for use func (b *bolt) AddCommands(cmd ...Command) { for _, c := range cmd { b.commands[c.Trigger] = c } } // handler function that parses message data and executes any command payloads func (b *bolt) messageHandler(s *dg.Session, msg *dg.MessageCreate) { //get server information server, err := s.Guild(msg.GuildID) if err != nil { log.Println(err) return } channel, err := s.Channel(msg.ChannelID) if err != nil { log.Println(err) return } //if there is no content it is likely an image or a GIF, updating message content for //better logging and to avoid confusion if len(msg.Content) == 0 { msg.Content = "GIF/IMAGE" } if b.logLvl == LogLevelAll { //log message log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } //the bot will ignore it's own messages to prevent command loops if msg.Author.ID == s.State.User.ID { if b.logLvl == LogLevelCmd { //log commands log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } return } //does the message have the command indicator lg := len(b.indicator) if msg.Content[:lg] == b.indicator { err := b.handleCommand(msg, s, server, channel, lg) if err != nil { log.Println(err) return } } } // parses command from message and handles timeout checks, role checks, and command execution. All command responses are sent back to Discord func (b *bolt) handleCommand(msg *dg.MessageCreate, s *dg.Session, server *dg.Guild, channel *dg.Channel, lg int) error { if b.logLvl == LogLevelCmd { //log commands log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } words := strings.Split(msg.Content, " ") run, ok := b.commands[words[0][lg:]] if !ok { return nil //command doesn't exist, maybe log or respond to author } //has command met its timeout requirements tc, err := b.timeoutCheck(msg, s, run) if err != nil { return err } if !tc { return nil } //does user have correct permissions if run.Roles != nil { check, err := b.roleCheck(msg, s, run) if err != nil { return err } if !check { return nil } } //run command payload res, err := run.Payload(Message{ Author: msg.Author.Username, ID: msg.Author.ID, Words: words, Content: msg.Content, Channel: channel.Name, Server: server.Name, }) if err != nil { return err } //if a reply is returned send back to Discord if res != "" { reply := b.createReply(res, msg.ID, msg.ChannelID, msg.GuildID) _, err := s.ChannelMessageSendComplex(msg.ChannelID, reply) if err != nil { return err } } //update run time run.lastRun = time.Now() b.commands[run.Trigger] = run return nil } // basic wrapper function to create easy Discord responses func (b *bolt) createReply(content, message, channel, guild string) *dg.MessageSend { details := &dg.MessageReference{ MessageID: message, ChannelID: channel, GuildID: guild, } return &dg.MessageSend{ Content: content, Reference: details, } } // used to calculate the remaining time left in a timeout and returning it in a human-readable format func (b *bolt) getRemainingTimeout(timeout time.Time) string { r := time.Until(timeout) var ( timeLeft int metric string ) timeLeft = int(r.Hours()) metric = "h" if timeLeft < 1 { timeLeft = int(r.Minutes()) metric = "m" if timeLeft < 1 { timeLeft = int(r.Seconds()) metric = "s" } } return fmt.Sprintf("%d%s", timeLeft, metric) } // checks if the author of msg has the correct role to run the requested command func (b *bolt) roleCheck(msg *dg.MessageCreate, s *dg.Session, run Command) (bool, error) { var found bool //loop thru author roles, there may be a better way to check for this UNION //TODO: improve role search performance to support bigger lists for _, r := range msg.Member.Roles { //get role name from ID n, err := s.State.Role(msg.GuildID, r) if err != nil { return false, err } //does this role exist in command roles check := slices.Contains(run.Roles, n.Name) if check { found = true break } } //can't find role, don't run command, alert user of missing permissions if !found { reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.ChannelID, msg.GuildID) _, err := s.ChannelMessageSendComplex(msg.ChannelID, reply) if err != nil { return false, err } return false, nil } return true, nil } // check if the command timeout has been met, responding with remaining time if timeout has not been met yet. func (b *bolt) timeoutCheck(msg *dg.MessageCreate, s *dg.Session, run Command) (bool, error) { wait := run.lastRun.Add(run.Timeout) if !time.Now().After(wait) { reply := b.createReply(fmt.Sprintf("that command cannot be run for another %s", b.getRemainingTimeout(wait)), msg.ID, msg.ChannelID, msg.GuildID) _, err := s.ChannelMessageSendComplex(msg.ChannelID, reply) if err != nil { return false, err } return false, nil } return true, nil }