From 6816d7359b87755d00678c4464607635fce51fa8 Mon Sep 17 00:00:00 2001 From: jake Date: Tue, 24 Feb 2026 16:54:25 -0500 Subject: [PATCH 1/5] WIP started the facelift of the repo, adding in bans and timeouts. That comes with some restructure. Also implementing a goroutine limit for command handlers and options around that, as well as moving the intents to options to allow stronger restrictions --- README.md | 8 ++ bolt.go | 257 +++++++++++++++++++++++++++++------------------------ command.go | 2 +- go.mod | 2 +- message.go | 90 +++++++++++-------- option.go | 43 ++++++++- 6 files changed, 247 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index f281c18..f764f48 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # bolt +look into using retries and context's + +also add mentioned users to struct so the ban functions can use them + +also do we move towards message handler or focus on command handling, this will dictate the structure going forward with the bans, etc. +started on msg handler but it doesn't make sense, the Adding of the handlers won't work with messages since the trigger is always blank. Might need a catch all one, but then +that blacklists a command trigger, hmmmm + The nuts-and-bolts of Discord bots. Bolt is a wrapper for [discordgo](https://github.com/bwmarrin/discordgo) that provides quick and easy bootstrapping for simple Discord bots. ## Usage diff --git a/bolt.go b/bolt.go index b4ef3fe..4a9afdc 100644 --- a/bolt.go +++ b/bolt.go @@ -1,12 +1,14 @@ package bolt import ( + "context" "fmt" "log" "os" "os/signal" "slices" "strings" + "sync" "syscall" "time" @@ -14,14 +16,12 @@ import ( ) const ( - TOKEN_ENV_VAR = "DISCORD_TOKEN" //label for token environment variable + //Environment variable name for discord token, this is the only required variable + TOKEN_ENV_VAR = "DISCORD_TOKEN" - BOT_INTENTS = dg.IntentGuilds | - dg.IntentGuildMembers | - dg.IntentGuildPresences | - dg.IntentMessageContent | - dg.IntentsGuildMessages | - dg.IntentGuildMessageReactions + //bot defaults + DEFAULT_INDICATOR = "." + DEFAULT_MAX_GOROUTINES = 50 ) // basic bot structure containing discordgo connection as well as the command map @@ -30,6 +30,10 @@ type bolt struct { 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 + wg sync.WaitGroup + pool chan struct{} + maxRoutines int + msgHandlerf Payload } type Bolt interface { @@ -37,15 +41,15 @@ type Bolt interface { AddCommands(cmd ...Command) //filtered methods stop() error - messageHandler(s *dg.Session, msg *dg.MessageCreate) - handleCommand(msgEvent *MessageCreateEvent, s *dg.Session, lg int) error + msgEventHandler(s *dg.Session, msg *dg.MessageCreate) + handleCommand(msgEvent *Message, lg int) error + handleMessage(event *Message) error createReply(content, message, channel, guild string) *dg.MessageSend - getRemainingTimeout(timeout time.Time) string + remainingTimeout(timeout time.Time) string roleCheck(guild string, roles []string, s *dg.Session, run Command) (bool, error) timeoutCheck(msgID, channelID, guildID string, s *dg.Session, run Command) (bool, error) } -// create a new bolt interface func New(opts ...Option) (Bolt, error) { _, check := os.LookupEnv(TOKEN_ENV_VAR) if !check { @@ -56,48 +60,65 @@ func New(opts ...Option) (Bolt, error) { if err != nil { return nil, fmt.Errorf("failed to create Discord session: %e", err) } - bot.Identify.Intents = BOT_INTENTS b := &bolt{ - Session: bot, - commands: make(map[string]Command, 0), - logLvl: LogLevelAll, + Session: bot, + commands: make(map[string]Command, 0), + logLvl: LogLevelAll, + indicator: DEFAULT_INDICATOR, + wg: sync.WaitGroup{}, + maxRoutines: DEFAULT_MAX_GOROUTINES, } - //set default command indicator - b.indicator = "." //apply options for _, opt := range opts { opt(b) } + //options can change pool size, create post-options + b.pool = make(chan struct{}, b.maxRoutines) + return b, nil } -// 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) - + b.AddHandler(b.msgEventHandler) err := b.Open() if err != nil { return fmt.Errorf("failed to open websocket connection with Discord: %e", err) } - //safe shutdown handler + log.Println("bot started") + sigChannel := make(chan os.Signal, 1) signal.Notify(sigChannel, syscall.SIGINT) <-sigChannel + //move this to an option, maybe? + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + closeChan := make(chan struct{}, 0) + go func() { + b.wg.Wait() + close(closeChan) + }() + + select { + case <-ctx.Done(): + log.Println("shutdown timed out waiting for commands to finish, some may have been incomplete") + case <-closeChan: + log.Println("command routines cleaned up, exiting") + } + if err := b.stop(); err != nil { return err } + log.Println("bot stopped") + return nil } -// stops the bot func (b *bolt) stop() error { return b.Close() } @@ -109,9 +130,7 @@ func (b *bolt) AddCommands(cmd ...Command) { } } -// handler function that parses message data, handles logging the message based on logLevel, and executes -// the payload function in a goroutine -func (b *bolt) messageHandler(s *dg.Session, msg *dg.MessageCreate) { +func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //get server information server, err := s.Guild(msg.GuildID) if err != nil { @@ -124,15 +143,9 @@ func (b *bolt) messageHandler(s *dg.Session, msg *dg.MessageCreate) { return } - //if there is no content it is likely an image, gif, or sticker, updating message content for - //better logging and to avoid confusion - if len(msg.Content) == 0 { - msg.Content = "[Embedded 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 || b.logLvl == LogLevelAll { + if b.logLvl != LogLevelErr && b.logLvl != LogLevelNone { //log command responses log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } @@ -144,86 +157,31 @@ func (b *bolt) messageHandler(s *dg.Session, msg *dg.MessageCreate) { log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } - //does the message have the command indicator - lg := len(b.indicator) - if msg.Content[:lg] == b.indicator { - mCreate := &MessageCreateEvent{ - AuthorUsername: msg.Author.Username, - AuthorID: msg.Author.ID, - AuthorRoles: msg.Member.Roles, - MsgID: msg.ID, - Msg: msg.Content, - MsgChanID: msg.ChannelID, - MsgGuildID: msg.GuildID, - MsgAttachments: msg.Attachments, - } - - if b.logLvl == LogLevelCmd { - //log commands - log.Printf("< %s | %s | %s > %s\n", mCreate.MsgGuildName, mCreate.MsgChanName, mCreate.AuthorUsername, mCreate.Msg) - } - - //handled in its own goroutine to allow for async commands - go func() { - err := b.handleCommand(mCreate, s, lg) - if err != nil { - log.Println(err) - } - }() - } -} - -// 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(msgEvent *MessageCreateEvent, s *dg.Session, lg int) error { - words := strings.Split(msgEvent.Msg, " ") - run, ok := b.commands[words[0][lg:]] - if !ok { - return nil //command doesn't exist, maybe log or respond to author + m := Message{ + Author: msg.Author.Username, + authorID: msg.Author.ID, + authorRoles: msg.Member.Roles, + ID: msg.ID, + Content: msg.Content, + Channel: channel.Name, + channelID: channel.ID, + Server: server.Name, + serverID: server.ID, + sesh: b, } - //has command met its timeout requirements - tc, err := b.timeoutCheck(msgEvent.MsgID, msgEvent.MsgChanID, msgEvent.MsgGuildID, s, run) - if err != nil { - return fmt.Errorf("failed to calculate timeout for %s\n%e", run.Trigger, err) - } - if !tc { - return nil + w := strings.Fields(msg.Content) + if len(w) > 0 { + m.Words = w } - //does user have correct permissions - if run.Roles != nil { - check, err := b.roleCheck(msgEvent.MsgGuildID, msgEvent.AuthorRoles, s, run) - if err != nil { - return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err) - } - if !check { - reply := b.createReply("you do not have permissions to run that command", msgEvent.MsgID, msgEvent.MsgChanID, msgEvent.MsgGuildID) - _, err := s.ChannelMessageSendComplex(msgEvent.MsgChanID, reply) - if err != nil { - return err - } - return nil - } + if len(msg.Mentions) > 0 { + m.Mentions = msg.Mentions } - //populate message struct exposed to client - plMsg := Message{ - Author: msgEvent.AuthorUsername, - ID: msgEvent.AuthorID, - msgID: msgEvent.MsgID, - Words: words, - Content: msgEvent.Msg, - Channel: msgEvent.MsgChanName, - channelID: msgEvent.MsgChanID, - Server: msgEvent.MsgGuildName, - serverID: msgEvent.MsgGuildID, - sesh: b, - } - - //check for file attachments - if len(msgEvent.MsgAttachments) > 0 { + if len(msg.Attachments) > 0 { var att []MessageAttachment - for _, a := range msgEvent.MsgAttachments { + for _, a := range msg.Attachments { att = append(att, MessageAttachment{ ID: a.ID, URL: a.URL, @@ -237,13 +195,85 @@ func (b *bolt) handleCommand(msgEvent *MessageCreateEvent, s *dg.Session, lg int }) } - plMsg.Attachments = att + m.Attachments = att } - //run command payload - err = run.Payload(plMsg) + //using a patter based on a stackoverflow comment I saw that mentioned the use of a buffered channel as a lock (semaphore) + //to limit the amount of goroutines used at once + + //could be an issue if the bot is used like a long-term calendar, not sure that is my concern we now have a timeout so it will only wait so long + + lg := len(b.indicator) + if msg.Content[:lg] == b.indicator { + if b.logLvl == LogLevelCmd { + //log commands + log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.authorID, m.Content) + } + + b.pool <- struct{}{} //'aquire' a routine + + //handled in its own goroutine to allow for async commands + b.wg.Go(func() { + err := b.handleCommand(&m, lg) + if err != nil { + log.Println(err) + } + <-b.pool //release routine + }) + } else { + b.pool <- struct{}{} //'aquire' a routine + b.wg.Go(func() { + err := b.handleMessage(&m) + if err != nil { + log.Println(err) + } + <-b.pool //release routine + }) + } +} + +func (b *bolt) handleMessage(event *Message) error { + if b.msgHandlerf != nil { + return b.msgHandlerf(event) + } + + return nil +} + +func (b *bolt) handleCommand(msg *Message, lg int) error { + run, ok := b.commands[msg.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.ID, msg.channelID, msg.serverID, b.Session, run) if err != nil { - return fmt.Errorf("failed to execute payload function: %e", err) + return fmt.Errorf("failed to calculate timeout for %s\n%e", run.Trigger, err) + } + if !tc { + return nil + } + + //does user have correct permissions + if run.Roles != nil { + check, err := b.roleCheck(msg.serverID, msg.authorRoles, b.Session, run) + if err != nil { + return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err) + } + if !check { + reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.channelID, msg.serverID) + _, err := b.Session.ChannelMessageSendComplex(msg.channelID, reply) + if err != nil { + return err + } + return nil + } + } + + err = run.Payload(msg) + if err != nil { + return fmt.Errorf("encountered an error while handling command (%s): %e", msg.Words[0], err) } //update run time @@ -267,7 +297,7 @@ func (b *bolt) createReply(content, message, channel, guild string) *dg.MessageS } // 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 { +func (b *bolt) remainingTimeout(timeout time.Time) string { r := time.Until(timeout) var ( timeLeft int @@ -314,11 +344,10 @@ func (b *bolt) roleCheck(guild string, roles []string, s *dg.Session, run Comman 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(msgID, channelID, guildID string, 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)), msgID, channelID, guildID) + reply := b.createReply(fmt.Sprintf("that command cannot be run for another %s", b.remainingTimeout(wait)), msgID, channelID, guildID) _, err := s.ChannelMessageSendComplex(channelID, reply) if err != nil { return false, fmt.Errorf("failed to send timeout response: %e", err) diff --git a/command.go b/command.go index a14a70d..0dbe9c9 100644 --- a/command.go +++ b/command.go @@ -14,4 +14,4 @@ type Command struct { } // command payload functions, any strings returned are sent as a response to the command -type Payload func(msg Message) error +type Payload func(msg *Message) error diff --git a/go.mod b/go.mod index 1d9a5fa..9f95850 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module code.jakeyoungdev.com/jake/bolt -go 1.24.0 +go 1.25.0 require github.com/bwmarrin/discordgo v0.29.0 diff --git a/message.go b/message.go index 59599a0..3e72902 100644 --- a/message.go +++ b/message.go @@ -2,44 +2,48 @@ package bolt import ( "fmt" + "time" dg "github.com/bwmarrin/discordgo" ) const ( - // the max discord allows for basic messages + // the max length allowed for basic messages MSG_MAX_LENGTH = 2000 ) -// the message struct is passed to the command payload providing basic -// message information and needed methods +// Message contains basic information about the messages received and provides a few methods +// for handling replies, bans, timeouts, reaction, and deletion. All Discord utilities will use +// a timeout to prevent hanging for too long, this timeout can be customized with the WithTimeout +// option. type Message struct { - Author string //username of message author - ID string //discord ID of message author - msgID string //id string of message - Words []string //words from message split on whitespace - Content string //entire message content - Channel string //message channel - channelID string //id of channel message was sent in - Server string //message guild - serverID string //id of guild message was sent in - Attachments []MessageAttachment + Author string //current username of the message author + authorID string //discord ID of message author + authorRoles []string + ID string //message ID + Words []string //message data split on whitespaces + Content string //entire message data string + Channel string //name of channel message was sent in + channelID string //ID of channel message was sent in + Server string //name of guild message was sent in + serverID string //ID of guild message was sent in + Attachments []MessageAttachment //any attachments bound to the message + Mentions []*dg.User sesh *bolt } -// applies reaction to message +// React applies reaction to the message func (m *Message) React(emoji Reaction) error { - return m.sesh.MessageReactionAdd(m.channelID, m.msgID, fmt.Sprint(emoji)) + return m.sesh.MessageReactionAdd(m.channelID, m.ID, fmt.Sprint(emoji)) } -// sends response to message, if the response length is greater than 2000 characters the -// messages are split and sent seperatly +// Respond sends a response to the message, handling chunking if the message exceeds max length func (m *Message) Respond(res string) error { if len(res) > MSG_MAX_LENGTH { for len(res) > 0 { //send full chunk size allowed by discord sc := res[:MSG_MAX_LENGTH] - rep := m.sesh.createReply(sc, m.msgID, m.channelID, m.serverID) + rep := m.sesh.createReply(sc, m.ID, m.channelID, m.serverID) _, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) if err != nil { return err @@ -48,7 +52,7 @@ func (m *Message) Respond(res string) error { //if we have left than a full chunk send the rest and break the loop if len(res) < MSG_MAX_LENGTH { - final := m.sesh.createReply(res, m.msgID, m.channelID, m.serverID) + final := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) _, err := m.sesh.ChannelMessageSendComplex(m.channelID, final) if err != nil { return err @@ -61,30 +65,44 @@ func (m *Message) Respond(res string) error { return nil } - //short enough message to send in one message - rep := m.sesh.createReply(res, m.msgID, m.channelID, m.serverID) + //short enough message to send in one go + rep := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) _, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) return err } -// deletes the message from the channel +// Delete removes the message from the current channel func (m *Message) Delete() error { - return m.sesh.ChannelMessageDelete(m.channelID, m.msgID, nil) + return m.sesh.ChannelMessageDelete(m.channelID, m.ID, nil) } -// this struct has all of the needed information from the messageCreate event so that -// commands can be run asynchronously. Passing the messageCreate to payloads can block routines -type MessageCreateEvent struct { - AuthorUsername string - AuthorID string - AuthorRoles []string - MsgID string - Msg string - MsgChanID string - MsgChanName string - MsgGuildID string - MsgGuildName string - MsgAttachments []*dg.MessageAttachment +// Timeout sets a timeout for the message author +func (m *Message) Timeout(duration time.Time) error { + return m.sesh.GuildMemberTimeout(m.serverID, m.authorID, &duration) +} + +// ClearTimeout removes all timeouts for the message author +func (m *Message) ClearTimeout() error { + return m.sesh.GuildMemberTimeout(m.serverID, m.authorID, nil) +} + +// Ban removes a user from the server, banning them and removing all messages within the range of +// the days parameter +func (m *Message) Ban(reason string, days int) error { + return m.sesh.GuildBanCreateWithReason(m.serverID, m.authorID, reason, days) +} + +// unban user + +// ClearBan deletes the ban on message Authors + +// lol this won't work, they're banned, same with all clear* +func (m *Message) ClearBan() error { + return m.sesh.GuildBanDelete(m.serverID, m.authorID) +} + +func (m *Message) Mute(username string) error { + return m.sesh.GuildMemberMute(m.serverID, m.authorID, true) } // message attachment details diff --git a/option.go b/option.go index 9cac768..b629baa 100644 --- a/option.go +++ b/option.go @@ -1,15 +1,52 @@ package bolt +import ( + dg "github.com/bwmarrin/discordgo" +) + type Option func(b *bolt) type LogLevel int +type Permission dg.Intent + +type HandlerLevel int + const ( - LogLevelAll LogLevel = iota //logs all messages, and errors - LogLevelCmd LogLevel = iota //log only commands and responses, and errors - LogLevelErr LogLevel = iota //logs only errors + LogLevelAll LogLevel = iota //log all messages, and errors + LogLevelCmd LogLevel = iota //log only commands and responses, and errors + LogLevelErr LogLevel = iota //log only errors + LogLevelNone LogLevel = iota //log nothing, let the handlers sort it out + + msgPerms dg.Intent = dg.IntentGuilds | + dg.IntentGuildMembers | + dg.IntentGuildPresences | + dg.IntentMessageContent | + dg.IntentsGuildMessages + + MessagePermissions Permission = Permission(msgPerms) + ReactionPermissions Permission = Permission(dg.IntentGuildMessageReactions) + //we also need a ModeratorPermissions for banning, kicking, etc. ) +func WithPermissions(perms ...Permission) Option { + return func(b *bolt) { + var fullPerms dg.Intent + for _, p := range perms { + fullPerms |= dg.Intent(p) + } + + //set intents + b.Identify.Intents = fullPerms + } +} + +func WithMaxGoroutines(max int) Option { + return func(b *bolt) { + b.maxRoutines = max + } +} + // sets the substring that must be present at the beginning of the message to indicate a command func WithIndicator(i string) Option { return func(b *bolt) { -- 2.49.1 From 5297a480b83d264a5583c2399a533251d84ac269 Mon Sep 17 00:00:00 2001 From: jake Date: Tue, 24 Feb 2026 18:19:41 -0500 Subject: [PATCH 2/5] updates from testing --- .gitignore | 3 ++- README.md | 74 ++++++++++++++++++++++++++++-------------------------- bolt.go | 67 ++++++++++++++++++++++++++---------------------- command.go | 34 ++++++++++++++++++++++++- go.mod | 5 +++- go.sum | 2 ++ message.go | 68 +++++++++++++++++++++---------------------------- option.go | 10 +++++--- 8 files changed, 153 insertions(+), 110 deletions(-) diff --git a/.gitignore b/.gitignore index bd2274b..4d62159 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ go.work.sum # env file .env -/cmd/* \ No newline at end of file +/cmd/* +/cmd \ No newline at end of file diff --git a/README.md b/README.md index f764f48..7849b16 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # bolt -look into using retries and context's +TODO +pre-1. Break up msg handler method its insane +1. Read through code and ensure I didn't miss anything +2. do research on intents for 'admin' jobs +3. comments and README updates, things have changed +4. determine adjustments to timeouts and contexts and const vs options for it +5. Figure out why the exiting printing is going after terminal exit -also add mentioned users to struct so the ban functions can use them - -also do we move towards message handler or focus on command handling, this will dictate the structure going forward with the bans, etc. -started on msg handler but it doesn't make sense, the Adding of the handlers won't work with messages since the trigger is always blank. Might need a catch all one, but then -that blacklists a command trigger, hmmmm +--- The nuts-and-bolts of Discord bots. Bolt is a wrapper for [discordgo](https://github.com/bwmarrin/discordgo) that provides quick and easy bootstrapping for simple Discord bots. @@ -62,60 +64,62 @@ The Delete method will delete the message from the text channel package main import ( + "strings" "time" "code.jakeyoungdev.com/jake/bolt" + _ "github.com/joho/godotenv/autoload" ) func main() { - //bolt defaults the command indicator to '.' however that can be changed with the options - //Example: bolt.New(bolt.WithIndicator('!')) would support commands like !ping - b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelCmd)) + b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelAll), + bolt.WithMaxGoroutines(50), + bolt.WithPermissions(bolt.MessagePermissions, bolt.AdminPermissions)) + if err != nil { panic(err) } b.AddCommands( - // basic ping pong command, .ping can be run at anytime by anyone and will reply "pong" bolt.Command{ - Trigger: "ping", - Payload: func(msg bolt.Message) error { - return msg.Respond("pong") + Trigger: "test", + Payload: func(msg *bolt.Message, admin bolt.AdminToolBox) error { + return msg.Respond("hi") }, }, - // .react will add a +1 reaction to the command message, .react can be run by anyone at any rate bolt.Command{ - Trigger: "react", - Payload: func(msg bolt.Message) error { - return msg.React(bolt.ReactionThumbsUp) + Trigger: "timeout", + Payload: func(msg *bolt.Message, tools bolt.AdminToolBox) error { + if len(msg.Mentions) > 0 { + err := tools.Timeout(msg.Mentions[0].ID, msg.ServerID, time.Now().Add(time.Minute*5)) + if err != nil { + return err + } + } + return msg.Respond("done") }, - }, - // .time responds with the current date/time, .time can be run once every 25 seconds by any role - bolt.Command{ - Trigger: "time", - Payload: func(msg bolt.Message) error { - return msg.Respond(time.Now().String()) - }, - Timeout: time.Second * 25, - }, - // .role command can be ran every 10 seconds by anyone with the admin role and will return the string "admin" - bolt.Command{ - Trigger: "role", - Payload: func(msg bolt.Message) error { - return msg.Respond("admin") - }, - Timeout: time.Second * 10, - Roles: []string{"admin"}, + Roles: []string{"admin"}, }, ) - //start is a blocking call that handles safe-shutdown, all configuration and setup should be done before calling Start() + b.AddMessageHandler(func(msg *bolt.Message, tools bolt.AdminToolBox) error { + if strings.Contains(msg.Content, "swear word") { + return tools.Timeout(msg.Author.ID, msg.ServerID, time.Now().Add(time.Hour*1)) + } + + if msg.Content == "im a menace in VC" { + return tools.Mute(msg.Author.ID, msg.ServerID) + } + return nil + }) + err = b.Start() if err != nil { panic(err) } } + ``` ## Development diff --git a/bolt.go b/bolt.go index 4a9afdc..939ccf6 100644 --- a/bolt.go +++ b/bolt.go @@ -9,7 +9,6 @@ import ( "slices" "strings" "sync" - "syscall" "time" dg "github.com/bwmarrin/discordgo" @@ -34,11 +33,14 @@ type bolt struct { pool chan struct{} maxRoutines int msgHandlerf Payload + admin bool + tools AdminToolBox } type Bolt interface { Start() error AddCommands(cmd ...Command) + AddMessageHandler(p Payload) //filtered methods stop() error msgEventHandler(s *dg.Session, msg *dg.MessageCreate) @@ -67,6 +69,7 @@ func New(opts ...Option) (Bolt, error) { logLvl: LogLevelAll, indicator: DEFAULT_INDICATOR, wg: sync.WaitGroup{}, + admin: false, maxRoutines: DEFAULT_MAX_GOROUTINES, } @@ -75,8 +78,9 @@ func New(opts ...Option) (Bolt, error) { opt(b) } - //options can change pool size, create post-options + //options can change these fields so we must create post-opts b.pool = make(chan struct{}, b.maxRoutines) + b.tools = NewToolbox(b) return b, nil } @@ -91,7 +95,7 @@ func (b *bolt) Start() error { log.Println("bot started") sigChannel := make(chan os.Signal, 1) - signal.Notify(sigChannel, syscall.SIGINT) + signal.Notify(sigChannel, os.Interrupt) <-sigChannel //move this to an option, maybe? @@ -105,18 +109,13 @@ func (b *bolt) Start() error { select { case <-ctx.Done(): - log.Println("shutdown timed out waiting for commands to finish, some may have been incomplete") + log.Println("shutdown timed out waiting for handlers to finish, some may have been incomplete") case <-closeChan: - log.Println("command routines cleaned up, exiting") + log.Println("handler routines cleaned") } - if err := b.stop(); err != nil { - return err - } - - log.Println("bot stopped") - - return nil + log.Println("exiting") + return b.stop() } func (b *bolt) stop() error { @@ -130,6 +129,10 @@ func (b *bolt) AddCommands(cmd ...Command) { } } +func (b *bolt) AddMessageHandler(p Payload) { + b.msgHandlerf = p +} + func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //get server information server, err := s.Guild(msg.GuildID) @@ -157,17 +160,20 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } + //this hsould be moved to a parseMessageEvent method m := Message{ - Author: msg.Author.Username, - authorID: msg.Author.ID, - authorRoles: msg.Member.Roles, - ID: msg.ID, - Content: msg.Content, - Channel: channel.Name, - channelID: channel.ID, - Server: server.Name, - serverID: server.ID, - sesh: b, + Author: Author{ + Name: msg.Author.Username, + ID: msg.Author.ID, + Roles: msg.Member.Roles, + }, + ID: msg.ID, + Content: msg.Content, + Channel: channel.Name, + ChannelID: channel.ID, + Server: server.Name, + ServerID: server.ID, + sesh: b, } w := strings.Fields(msg.Content) @@ -207,7 +213,7 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { if msg.Content[:lg] == b.indicator { if b.logLvl == LogLevelCmd { //log commands - log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.authorID, m.Content) + log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.Author.Name, m.Content) } b.pool <- struct{}{} //'aquire' a routine @@ -234,7 +240,7 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { func (b *bolt) handleMessage(event *Message) error { if b.msgHandlerf != nil { - return b.msgHandlerf(event) + return b.msgHandlerf(event, b.tools) } return nil @@ -247,7 +253,7 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { } //has command met its timeout requirements - tc, err := b.timeoutCheck(msg.ID, msg.channelID, msg.serverID, b.Session, run) + tc, err := b.timeoutCheck(msg.ID, msg.ChannelID, msg.ServerID, b.Session, run) if err != nil { return fmt.Errorf("failed to calculate timeout for %s\n%e", run.Trigger, err) } @@ -257,13 +263,13 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { //does user have correct permissions if run.Roles != nil { - check, err := b.roleCheck(msg.serverID, msg.authorRoles, b.Session, run) + check, err := b.roleCheck(msg.ServerID, msg.Author.Roles, b.Session, run) if err != nil { return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err) } if !check { - reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.channelID, msg.serverID) - _, err := b.Session.ChannelMessageSendComplex(msg.channelID, reply) + reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.ChannelID, msg.ServerID) + _, err := b.Session.ChannelMessageSendComplex(msg.ChannelID, reply) if err != nil { return err } @@ -271,7 +277,7 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { } } - err = run.Payload(msg) + err = run.Payload(msg, b.tools) if err != nil { return fmt.Errorf("encountered an error while handling command (%s): %e", msg.Words[0], err) } @@ -346,7 +352,8 @@ func (b *bolt) roleCheck(guild string, roles []string, s *dg.Session, run Comman func (b *bolt) timeoutCheck(msgID, channelID, guildID string, s *dg.Session, run Command) (bool, error) { wait := run.lastRun.Add(run.Timeout) - if !time.Now().After(wait) { + now := time.Now() + if !now.After(wait) && !now.Equal(wait) { reply := b.createReply(fmt.Sprintf("that command cannot be run for another %s", b.remainingTimeout(wait)), msgID, channelID, guildID) _, err := s.ChannelMessageSendComplex(channelID, reply) if err != nil { diff --git a/command.go b/command.go index 0dbe9c9..5deee6b 100644 --- a/command.go +++ b/command.go @@ -14,4 +14,36 @@ type Command struct { } // command payload functions, any strings returned are sent as a response to the command -type Payload func(msg *Message) error +type Payload func(msg *Message, admin AdminToolBox) error + +type adminToolbox struct { + *bolt +} +type AdminToolBox interface { + Timeout(userId, serverId string, duration time.Time) error + ClearTimeout(userId, serverId string) error + Mute(userId, serverId string) error + Unmute(userId, serverId string) error +} + +func NewToolbox(b *bolt) AdminToolBox { + return &adminToolbox{ + bolt: b, + } +} + +func (a *adminToolbox) Timeout(userId, serverId string, duration time.Time) error { + return a.GuildMemberTimeout(serverId, userId, &duration) +} + +func (a *adminToolbox) ClearTimeout(userId, serverId string) error { + return a.GuildMemberTimeout(serverId, userId, nil) +} + +func (a *adminToolbox) Mute(userId, serverId string) error { + return a.GuildMemberMute(serverId, userId, true) +} + +func (a *adminToolbox) Unmute(userId, serverId string) error { + return a.GuildMemberMute(serverId, userId, false) +} diff --git a/go.mod b/go.mod index 9f95850..29c7db3 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module code.jakeyoungdev.com/jake/bolt go 1.25.0 -require github.com/bwmarrin/discordgo v0.29.0 +require ( + github.com/bwmarrin/discordgo v0.29.0 + github.com/joho/godotenv v1.5.1 +) require ( github.com/gorilla/websocket v1.4.2 // indirect diff --git a/go.sum b/go.sum index 8e8eb37..04715b6 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+Eg github.com/bwmarrin/discordgo v0.29.0/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.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= diff --git a/message.go b/message.go index 3e72902..cc054a6 100644 --- a/message.go +++ b/message.go @@ -2,7 +2,6 @@ package bolt import ( "fmt" - "time" dg "github.com/bwmarrin/discordgo" ) @@ -17,24 +16,28 @@ const ( // a timeout to prevent hanging for too long, this timeout can be customized with the WithTimeout // option. type Message struct { - Author string //current username of the message author - authorID string //discord ID of message author - authorRoles []string + Author Author ID string //message ID Words []string //message data split on whitespaces Content string //entire message data string Channel string //name of channel message was sent in - channelID string //ID of channel message was sent in + ChannelID string //ID of channel message was sent in Server string //name of guild message was sent in - serverID string //ID of guild message was sent in + ServerID string //ID of guild message was sent in Attachments []MessageAttachment //any attachments bound to the message Mentions []*dg.User sesh *bolt } +type Author struct { + Name string + ID string + Roles []string +} + // React applies reaction to the message func (m *Message) React(emoji Reaction) error { - return m.sesh.MessageReactionAdd(m.channelID, m.ID, fmt.Sprint(emoji)) + return m.sesh.MessageReactionAdd(m.ChannelID, m.ID, fmt.Sprint(emoji)) } // Respond sends a response to the message, handling chunking if the message exceeds max length @@ -43,8 +46,8 @@ func (m *Message) Respond(res string) error { for len(res) > 0 { //send full chunk size allowed by discord sc := res[:MSG_MAX_LENGTH] - rep := m.sesh.createReply(sc, m.ID, m.channelID, m.serverID) - _, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) + rep := m.sesh.createReply(sc, m.ID, m.ChannelID, m.ServerID) + _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep) if err != nil { return err } @@ -52,8 +55,8 @@ func (m *Message) Respond(res string) error { //if we have left than a full chunk send the rest and break the loop if len(res) < MSG_MAX_LENGTH { - final := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) - _, err := m.sesh.ChannelMessageSendComplex(m.channelID, final) + final := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID) + _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, final) if err != nil { return err } @@ -66,44 +69,31 @@ func (m *Message) Respond(res string) error { } //short enough message to send in one go - rep := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) - _, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) + rep := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID) + _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep) return err } // Delete removes the message from the current channel func (m *Message) Delete() error { - return m.sesh.ChannelMessageDelete(m.channelID, m.ID, nil) + return m.sesh.ChannelMessageDelete(m.ChannelID, m.ID, nil) } -// Timeout sets a timeout for the message author -func (m *Message) Timeout(duration time.Time) error { - return m.sesh.GuildMemberTimeout(m.serverID, m.authorID, &duration) -} +// func (m *Message) Timeout(userID string, duration time.Time) error { +// return m.sesh.GuildMemberTimeout(m.serverID, userID, &duration) +// } -// ClearTimeout removes all timeouts for the message author -func (m *Message) ClearTimeout() error { - return m.sesh.GuildMemberTimeout(m.serverID, m.authorID, nil) -} +// func (m *Message) ClearTimeout(userID string) error { +// return m.sesh.GuildMemberTimeout(m.serverID, userID, nil) +// } -// Ban removes a user from the server, banning them and removing all messages within the range of -// the days parameter -func (m *Message) Ban(reason string, days int) error { - return m.sesh.GuildBanCreateWithReason(m.serverID, m.authorID, reason, days) -} +// func (m *Message) Mute(userID string) error { +// return m.sesh.GuildMemberMute(m.serverID, userID, true) +// } -// unban user - -// ClearBan deletes the ban on message Authors - -// lol this won't work, they're banned, same with all clear* -func (m *Message) ClearBan() error { - return m.sesh.GuildBanDelete(m.serverID, m.authorID) -} - -func (m *Message) Mute(username string) error { - return m.sesh.GuildMemberMute(m.serverID, m.authorID, true) -} +// func (m *Author) Unmute(userID string) error { +// return m.sesh.GuildMemberMute(m.serverID, userID, false) +// } // message attachment details type MessageAttachment struct { diff --git a/option.go b/option.go index b629baa..d5a1cb2 100644 --- a/option.go +++ b/option.go @@ -22,10 +22,11 @@ const ( dg.IntentGuildMembers | dg.IntentGuildPresences | dg.IntentMessageContent | - dg.IntentsGuildMessages + dg.IntentsGuildMessages | + dg.IntentGuildMessageReactions - MessagePermissions Permission = Permission(msgPerms) - ReactionPermissions Permission = Permission(dg.IntentGuildMessageReactions) + MessagePermissions Permission = Permission(msgPerms) + AdminPermissions Permission = 0 //fake //we also need a ModeratorPermissions for banning, kicking, etc. ) @@ -33,6 +34,9 @@ func WithPermissions(perms ...Permission) Option { return func(b *bolt) { var fullPerms dg.Intent for _, p := range perms { + if p == AdminPermissions { + b.admin = true + } fullPerms |= dg.Intent(p) } -- 2.49.1 From 01dd3633ef0c52c36f026cea7733df88b42563c6 Mon Sep 17 00:00:00 2001 From: jake Date: Tue, 24 Feb 2026 18:45:00 -0500 Subject: [PATCH 3/5] adding Context - converting messages and methods into one context per event --- README.md | 2 +- bolt.go | 29 +++++---- command.go | 55 ++++++++--------- message.go | 170 +++++++++++++++++++++++++++-------------------------- option.go | 6 +- 5 files changed, 134 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 7849b16..9af7bb3 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ import ( func main() { b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelAll), - bolt.WithMaxGoroutines(50), + bolt.WithMaxGoroutines(150), bolt.WithPermissions(bolt.MessagePermissions, bolt.AdminPermissions)) if err != nil { diff --git a/bolt.go b/bolt.go index 939ccf6..97bd94a 100644 --- a/bolt.go +++ b/bolt.go @@ -33,8 +33,7 @@ type bolt struct { pool chan struct{} maxRoutines int msgHandlerf Payload - admin bool - tools AdminToolBox + // admin bool } type Bolt interface { @@ -64,12 +63,12 @@ func New(opts ...Option) (Bolt, error) { } b := &bolt{ - Session: bot, - commands: make(map[string]Command, 0), - logLvl: LogLevelAll, - indicator: DEFAULT_INDICATOR, - wg: sync.WaitGroup{}, - admin: false, + Session: bot, + commands: make(map[string]Command, 0), + logLvl: LogLevelAll, + indicator: DEFAULT_INDICATOR, + wg: sync.WaitGroup{}, + // admin: false, maxRoutines: DEFAULT_MAX_GOROUTINES, } @@ -80,7 +79,7 @@ func New(opts ...Option) (Bolt, error) { //options can change these fields so we must create post-opts b.pool = make(chan struct{}, b.maxRoutines) - b.tools = NewToolbox(b) + // b.tools = NewToolbox(b) return b, nil } @@ -173,7 +172,7 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { ChannelID: channel.ID, Server: server.Name, ServerID: server.ID, - sesh: b, + // sesh: b, } w := strings.Fields(msg.Content) @@ -240,7 +239,10 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { func (b *bolt) handleMessage(event *Message) error { if b.msgHandlerf != nil { - return b.msgHandlerf(event, b.tools) + return b.msgHandlerf(&Context{ + Message: event, + bolt: b, + }) } return nil @@ -277,7 +279,10 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { } } - err = run.Payload(msg, b.tools) + err = run.Payload(&Context{ + Message: msg, + bolt: b, + }) if err != nil { return fmt.Errorf("encountered an error while handling command (%s): %e", msg.Words[0], err) } diff --git a/command.go b/command.go index 5deee6b..14655bb 100644 --- a/command.go +++ b/command.go @@ -13,37 +13,34 @@ type Command struct { Roles []string //roles that can use command, if none are set anyone can run the command } -// command payload functions, any strings returned are sent as a response to the command -type Payload func(msg *Message, admin AdminToolBox) error +// type adminToolbox struct { +// *bolt +// } +// type AdminToolBox interface { +// Timeout(userId, serverId string, duration time.Time) error +// ClearTimeout(userId, serverId string) error +// Mute(userId, serverId string) error +// Unmute(userId, serverId string) error +// } -type adminToolbox struct { - *bolt -} -type AdminToolBox interface { - Timeout(userId, serverId string, duration time.Time) error - ClearTimeout(userId, serverId string) error - Mute(userId, serverId string) error - Unmute(userId, serverId string) error -} +// func NewToolbox(b *bolt) AdminToolBox { +// return &adminToolbox{ +// bolt: b, +// } +// } -func NewToolbox(b *bolt) AdminToolBox { - return &adminToolbox{ - bolt: b, - } -} +// func (a *adminToolbox) Timeout(userId, serverId string, duration time.Time) error { +// return a.GuildMemberTimeout(serverId, userId, &duration) +// } -func (a *adminToolbox) Timeout(userId, serverId string, duration time.Time) error { - return a.GuildMemberTimeout(serverId, userId, &duration) -} +// func (a *adminToolbox) ClearTimeout(userId, serverId string) error { +// return a.GuildMemberTimeout(serverId, userId, nil) +// } -func (a *adminToolbox) ClearTimeout(userId, serverId string) error { - return a.GuildMemberTimeout(serverId, userId, nil) -} +// func (a *adminToolbox) Mute(userId, serverId string) error { +// return a.GuildMemberMute(serverId, userId, true) +// } -func (a *adminToolbox) Mute(userId, serverId string) error { - return a.GuildMemberMute(serverId, userId, true) -} - -func (a *adminToolbox) Unmute(userId, serverId string) error { - return a.GuildMemberMute(serverId, userId, false) -} +// func (a *adminToolbox) Unmute(userId, serverId string) error { +// return a.GuildMemberMute(serverId, userId, false) +// } diff --git a/message.go b/message.go index cc054a6..e4a8902 100644 --- a/message.go +++ b/message.go @@ -2,6 +2,7 @@ package bolt import ( "fmt" + "time" dg "github.com/bwmarrin/discordgo" ) @@ -11,89 +12,8 @@ const ( MSG_MAX_LENGTH = 2000 ) -// Message contains basic information about the messages received and provides a few methods -// for handling replies, bans, timeouts, reaction, and deletion. All Discord utilities will use -// a timeout to prevent hanging for too long, this timeout can be customized with the WithTimeout -// option. -type Message struct { - Author Author - ID string //message ID - Words []string //message data split on whitespaces - Content string //entire message data string - Channel string //name of channel message was sent in - ChannelID string //ID of channel message was sent in - Server string //name of guild message was sent in - ServerID string //ID of guild message was sent in - Attachments []MessageAttachment //any attachments bound to the message - Mentions []*dg.User - sesh *bolt -} - -type Author struct { - Name string - ID string - Roles []string -} - -// React applies reaction to the message -func (m *Message) React(emoji Reaction) error { - return m.sesh.MessageReactionAdd(m.ChannelID, m.ID, fmt.Sprint(emoji)) -} - -// Respond sends a response to the message, handling chunking if the message exceeds max length -func (m *Message) Respond(res string) error { - if len(res) > MSG_MAX_LENGTH { - for len(res) > 0 { - //send full chunk size allowed by discord - sc := res[:MSG_MAX_LENGTH] - rep := m.sesh.createReply(sc, m.ID, m.ChannelID, m.ServerID) - _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep) - if err != nil { - return err - } - res = res[MSG_MAX_LENGTH:] - - //if we have left than a full chunk send the rest and break the loop - if len(res) < MSG_MAX_LENGTH { - final := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID) - _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, final) - if err != nil { - return err - } - - break - } - } - - return nil - } - - //short enough message to send in one go - rep := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID) - _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep) - return err -} - -// Delete removes the message from the current channel -func (m *Message) Delete() error { - return m.sesh.ChannelMessageDelete(m.ChannelID, m.ID, nil) -} - -// func (m *Message) Timeout(userID string, duration time.Time) error { -// return m.sesh.GuildMemberTimeout(m.serverID, userID, &duration) -// } - -// func (m *Message) ClearTimeout(userID string) error { -// return m.sesh.GuildMemberTimeout(m.serverID, userID, nil) -// } - -// func (m *Message) Mute(userID string) error { -// return m.sesh.GuildMemberMute(m.serverID, userID, true) -// } - -// func (m *Author) Unmute(userID string) error { -// return m.sesh.GuildMemberMute(m.serverID, userID, false) -// } +// command payload functions, any strings returned are sent as a response to the command +type Payload func(c *Context) error // message attachment details type MessageAttachment struct { @@ -107,3 +27,87 @@ type MessageAttachment struct { Size int DurationSecs float64 } + +type Author struct { + Name string + ID string + Roles []string +} + +type Message struct { + Author Author + ID string //message ID + Words []string //message data split on whitespaces + Content string //entire message data string + Channel string //name of channel message was sent in + ChannelID string //ID of channel message was sent in + Server string //name of guild message was sent in + ServerID string //ID of guild message was sent in + Attachments []MessageAttachment //any attachments bound to the message + Mentions []*dg.User +} + +type Context struct { + Message *Message + bolt *bolt +} + +// React applies reaction to the message +func (c *Context) React(emoji Reaction) error { + return c.bolt.MessageReactionAdd(c.Message.ChannelID, c.Message.ID, fmt.Sprint(emoji)) +} + +// Respond sends a response to the message, handling chunking if the message exceeds max length +func (c *Context) Respond(res string) error { + if len(res) > MSG_MAX_LENGTH { + for len(res) > 0 { + //send full chunk size allowed by discord + sc := res[:MSG_MAX_LENGTH] + rep := c.bolt.createReply(sc, c.Message.ID, c.Message.ChannelID, c.Message.ServerID) + _, err := c.bolt.ChannelMessageSendComplex(c.Message.ChannelID, rep) + if err != nil { + return err + } + res = res[MSG_MAX_LENGTH:] + + //if we have left than a full chunk send the rest and break the loop + if len(res) < MSG_MAX_LENGTH { + final := c.bolt.createReply(res, c.Message.ID, c.Message.ChannelID, c.Message.ServerID) + _, err := c.bolt.ChannelMessageSendComplex(c.Message.ChannelID, final) + if err != nil { + return err + } + + break + } + } + + return nil + } + + //short enough message to send in one go + rep := c.bolt.createReply(res, c.Message.ID, c.Message.ChannelID, c.Message.ServerID) + _, err := c.bolt.ChannelMessageSendComplex(c.Message.ChannelID, rep) + return err +} + +// Delete removes the message from the current channel +func (c *Context) Delete() error { + return c.bolt.ChannelMessageDelete(c.Message.ChannelID, c.Message.ID, nil) +} + +func (c *Context) Timeout(userId string, duration time.Time) error { + return c.bolt.GuildMemberTimeout(c.Message.ServerID, userId, &duration) +} + +func (c *Context) ClearTimeout(userId string) error { + return c.bolt.GuildMemberTimeout(c.Message.ServerID, userId, nil) +} + +func (c *Context) Mute(userId string) error { + return c.bolt.GuildMemberMute(c.Message.ServerID, userId, true) +} + +func (c *Context) Unmute(userId string) error { + return c.bolt.GuildMemberMute(c.Message.ServerID, userId, false) +} diff --git a/option.go b/option.go index d5a1cb2..e07b553 100644 --- a/option.go +++ b/option.go @@ -34,9 +34,9 @@ func WithPermissions(perms ...Permission) Option { return func(b *bolt) { var fullPerms dg.Intent for _, p := range perms { - if p == AdminPermissions { - b.admin = true - } + // if p == AdminPermissions { + // b.admin = true + // } fullPerms |= dg.Intent(p) } -- 2.49.1 From 4732e94c1bd71b48f46cbc4755e8b483e3689c86 Mon Sep 17 00:00:00 2001 From: jake Date: Tue, 24 Feb 2026 19:04:33 -0500 Subject: [PATCH 4/5] starting comment mountain --- README.md | 1 + bolt.go | 69 ++++++++++++++++++++++++++++++++----------------------- 2 files changed, 41 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9af7bb3..832f94c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ TODO pre-1. Break up msg handler method its insane +pre-2. Copy main into README 1. Read through code and ensure I didn't miss anything 2. do research on intents for 'admin' jobs 3. comments and README updates, things have changed diff --git a/bolt.go b/bolt.go index 97bd94a..320f79e 100644 --- a/bolt.go +++ b/bolt.go @@ -15,25 +15,35 @@ import ( ) const ( - //Environment variable name for discord token, this is the only required variable + //the name of the environment variable that should contain the token for the bot, it is + //required for bolt to run TOKEN_ENV_VAR = "DISCORD_TOKEN" - //bot defaults - DEFAULT_INDICATOR = "." - DEFAULT_MAX_GOROUTINES = 50 + //bot default command indicator, if messages begin with this substring they are processed + //through the command handler instead of the generic message handler + DEFAULT_INDICATOR = "." + //max amount of concurrent goroutines that bolt can use for events. A lower amount + //may lower the resource usage of bolt but may cause a delay in event handling + DEFAULT_MAX_GOROUTINES = 250 ) -// 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 - wg sync.WaitGroup + //discordgo internals + *dg.Session + //maps trigger phrase to command struct for instant lookup + commands map[string]Command + //used to detect whether a message is a command + indicator string + //verbosity of logs bolt outputs + logLvl LogLevel + //waitgroup for event routines + wg sync.WaitGroup + //pool is a buffered channel used as a semaphore for event handler routines, it is limited to + //only spawn maxRoutines to handle events pool chan struct{} maxRoutines int + //generic message handler func msgHandlerf Payload - // admin bool } type Bolt interface { @@ -77,13 +87,15 @@ func New(opts ...Option) (Bolt, error) { opt(b) } - //options can change these fields so we must create post-opts + //options can change max routine number, so create after b.pool = make(chan struct{}, b.maxRoutines) - // b.tools = NewToolbox(b) return b, nil } +// Start applies the message event handler function to the bot and opens the initial websocket connection +// with Discord. Start is a blocking call that also handles safe shutdown, on Interrupt, bolt will give +// command routines a window to finish before closing the connection. func (b *bolt) Start() error { b.AddHandler(b.msgEventHandler) err := b.Open() @@ -117,21 +129,27 @@ func (b *bolt) Start() error { return b.stop() } -func (b *bolt) stop() error { - return b.Close() -} - -// adds commands to bot command map for use +// AddCommands registers command handlers, any messages that begin with the command indicator will be forwarded +// to a handler if the command string matches a trigger func (b *bolt) AddCommands(cmd ...Command) { for _, c := range cmd { b.commands[c.Trigger] = c } } +// AddMessageHandler registers the generic message handler, any messages that are not commands will be forwarded +// to the Payload func (b *bolt) AddMessageHandler(p Payload) { b.msgHandlerf = p } +// stop closes the websocket connection with Discord +func (b *bolt) stop() error { + return b.Close() +} + +// msgEventHandler is a beefy boy that handles message logging, command parsing, and executing payload functions. It needs cleanup then +// i'll worry about this comment func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //get server information server, err := s.Guild(msg.GuildID) @@ -148,14 +166,12 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //the bot will ignore it's own messages to prevent command loops if msg.Author.ID == s.State.User.ID { if b.logLvl != LogLevelErr && b.logLvl != LogLevelNone { - //log command responses log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } return } if b.logLvl == LogLevelAll { - //log message log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } @@ -172,7 +188,6 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { ChannelID: channel.ID, Server: server.Name, ServerID: server.ID, - // sesh: b, } w := strings.Fields(msg.Content) @@ -203,21 +218,13 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { m.Attachments = att } - //using a patter based on a stackoverflow comment I saw that mentioned the use of a buffered channel as a lock (semaphore) - //to limit the amount of goroutines used at once - - //could be an issue if the bot is used like a long-term calendar, not sure that is my concern we now have a timeout so it will only wait so long - lg := len(b.indicator) if msg.Content[:lg] == b.indicator { if b.logLvl == LogLevelCmd { - //log commands log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.Author.Name, m.Content) } b.pool <- struct{}{} //'aquire' a routine - - //handled in its own goroutine to allow for async commands b.wg.Go(func() { err := b.handleCommand(&m, lg) if err != nil { @@ -237,6 +244,7 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { } } +// handleMessage forwards the message data to the handler function, if one was set func (b *bolt) handleMessage(event *Message) error { if b.msgHandlerf != nil { return b.msgHandlerf(&Context{ @@ -248,6 +256,9 @@ func (b *bolt) handleMessage(event *Message) error { return nil } +// handleCommand maps the first word of the message to the command payload, if it exists. Checking the timeout +// and role restrictions before forwarding the message to the Command Payload. If restrictions have not been met +// a response is sent to the message func (b *bolt) handleCommand(msg *Message, lg int) error { run, ok := b.commands[msg.Words[0][lg:]] if !ok { -- 2.49.1 From b9c26c631964ef689a2a8a649778e98e5d156d87 Mon Sep 17 00:00:00 2001 From: jake Date: Wed, 25 Feb 2026 13:24:27 -0500 Subject: [PATCH 5/5] [chore] cleaning - removing old or commented code - removing unused or deprecated types - commenting everything, probably too much - split msgEventHandler logic into methods - readme update, removed most docs the pkg site looks good - adjusted intents option, no need for a type --- README.md | 127 ++++++++++++++++++++---------------------------- bolt.go | 135 +++++++++++++++++++++++++++++++--------------------- command.go | 51 +++++--------------- message.go | 43 +++++++++++------ option.go | 46 ++++++------------ reaction.go | 4 +- 6 files changed, 191 insertions(+), 215 deletions(-) diff --git a/README.md b/README.md index 832f94c..ca92907 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,17 @@ # bolt -TODO -pre-1. Break up msg handler method its insane -pre-2. Copy main into README -1. Read through code and ensure I didn't miss anything -2. do research on intents for 'admin' jobs -3. comments and README updates, things have changed -4. determine adjustments to timeouts and contexts and const vs options for it -5. Figure out why the exiting printing is going after terminal exit - ---- - -The nuts-and-bolts of Discord bots. Bolt is a wrapper for [discordgo](https://github.com/bwmarrin/discordgo) that provides quick and easy bootstrapping for simple Discord bots. +A fast [discordgo](https://github.com/bwmarrin/discordgo) wrapper for bootstrapping Discord bots. ## Usage -### Token +### Prerequisites Bolt requires a Discord bot token to run, the token must be set as an environment variable labeled "DISCORD_TOKEN" -### Commands -Commands are represented by the command struct and contain all needed information for bolt to handle the command payload. Including the command timeout as well as the Roles allowed to run the command. -```go -type Command struct { - Trigger string //command that triggers payload NOT including the indicator - Payload Payload //payload function to run when a command is detected - Timeout time.Duration //the amount of time before command can be run again - lastRun time.Time //timestamp of last command run - Roles []string //roles that can use command, if none are set anyone can run -} -``` - -### Payload -Payload functions are executed when a command is detected -```go -type Payload func(msg Message) error -``` -Payload functions are given a Message argument containing the needed data for handling commands -```go -type Message struct { - Author string //username of message author - ID string //discord ID of message author - Words []string //words from message split on whitespace - Content string //entire message content - Channel string //message channel - Server string //message guild - Attachments []MessageAttachment //message attachments -} -``` -The Message struct also exposes some methods to support replying to, or acknowledging command messages -```go -func (m *Message) React(emoji Reaction) error -``` -The React method will react to the command message by adding the requested emoji as a reaction. Bolt comes with a few preset emoji's for easy handling but any valid emoji string can be passed. -```go -func (m *Message) Respond(res string) error -``` -The Respond method will send the value of res in response to the command message. -```go -func Delete() error -``` -The Delete method will delete the message from the text channel - ### Example ```go package main import ( + "fmt" "strings" "time" @@ -72,10 +19,26 @@ import ( _ "github.com/joho/godotenv/autoload" ) +/* +A basic example of a bot with two commands and a general message handler for non-command messages. The bot uses a +verbose log level which will log everything for debugging purposes, and registers Discord Intents for message and admin +related permissions. This allows the bot to parse messages, send them, delete them, etc. as well as timeout and mute users. + +This example registers three commands: + +1. .ping - a basic ping/pong command that can be run by anyone at any time +2. .wait - a dummy command that replies "okay" it can only be run by users with the "user" role and can only be ran once every 25 seconds +3. .timeout - a admin command that can only be run by users with an "admin" role, this command will timeout any mentioned users for 5 minutes + +A message handler is also registered in this example, message handlers are used to handle messages that do not contain a command. This enables +auto-moderation from the bot without manual intervention. The example message handler does two arbitrary things to demo functionality: + +1. Checks the message content for the phrase "swear word" and, if found, times the user out for 5 minutes +2. Checks the message for the phrase "im going to yell in VoiceChat" and mutes the message author until Unmute is called on them +*/ + func main() { - b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelAll), - bolt.WithMaxGoroutines(150), - bolt.WithPermissions(bolt.MessagePermissions, bolt.AdminPermissions)) + b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelAll)) if err != nil { panic(err) @@ -83,33 +46,47 @@ func main() { b.AddCommands( bolt.Command{ - Trigger: "test", - Payload: func(msg *bolt.Message, admin bolt.AdminToolBox) error { - return msg.Respond("hi") + Trigger: "ping", + Payload: func(c *bolt.Context) error { + return c.Respond("pong") }, }, + bolt.Command{ + Trigger: "wait", + Payload: func(c *bolt.Context) error { + return c.Respond("okay") + }, + Timeout: time.Second * 25, + Roles: []string{"user"}, + }, bolt.Command{ Trigger: "timeout", - Payload: func(msg *bolt.Message, tools bolt.AdminToolBox) error { - if len(msg.Mentions) > 0 { - err := tools.Timeout(msg.Mentions[0].ID, msg.ServerID, time.Now().Add(time.Minute*5)) - if err != nil { - return err + Payload: func(c *bolt.Context) error { + if len(c.Message.Mentions) > 0 { + count := 0 + for _, m := range c.Message.Mentions { + err := c.Timeout(m.ID, time.Now().Add(time.Minute*5)) + if err != nil { + return err + } + count++ } + + return c.Respond(fmt.Sprintf("timed out %d users\n", count)) } - return msg.Respond("done") + return nil }, Roles: []string{"admin"}, }, ) - b.AddMessageHandler(func(msg *bolt.Message, tools bolt.AdminToolBox) error { - if strings.Contains(msg.Content, "swear word") { - return tools.Timeout(msg.Author.ID, msg.ServerID, time.Now().Add(time.Hour*1)) + b.AddMessageHandler(func(c *bolt.Context) error { + if strings.Contains(c.Message.Content, "swear word") { + return c.Timeout(c.Message.Author.ID, time.Now().Add(time.Hour*1)) } - if msg.Content == "im a menace in VC" { - return tools.Mute(msg.Author.ID, msg.ServerID) + if c.Message.Content == "im going to yell in VoiceChat" { + return c.Mute(c.Message.Author.ID) } return nil }) @@ -120,8 +97,8 @@ func main() { } } - ``` ## Development -bolt is in development at the moment and may break occasionally before a v1 release \ No newline at end of file +bolt is in early development and may encounter breaking changes until a full v1 rollout, I will do my best to communicate +these changes. Use a tagged version to avoid any surprises with live code in main \ No newline at end of file diff --git a/bolt.go b/bolt.go index 320f79e..2629677 100644 --- a/bolt.go +++ b/bolt.go @@ -25,6 +25,14 @@ const ( //max amount of concurrent goroutines that bolt can use for events. A lower amount //may lower the resource usage of bolt but may cause a delay in event handling DEFAULT_MAX_GOROUTINES = 250 + + //minimum intents for bots to function, intents can be changed with options + DEFAULT_INTENTS = dg.IntentGuilds | + dg.IntentGuildMembers | + dg.IntentGuildPresences | + dg.IntentMessageContent | + dg.IntentsGuildMessages | + dg.IntentGuildMessageReactions ) type bolt struct { @@ -53,6 +61,7 @@ type Bolt interface { //filtered methods stop() error msgEventHandler(s *dg.Session, msg *dg.MessageCreate) + mapEventToMsg(msg *dg.MessageCreate, channel *dg.Channel, server *dg.Guild) Message handleCommand(msgEvent *Message, lg int) error handleMessage(event *Message) error createReply(content, message, channel, guild string) *dg.MessageSend @@ -61,6 +70,7 @@ type Bolt interface { timeoutCheck(msgID, channelID, guildID string, s *dg.Session, run Command) (bool, error) } +// New creates a new bolt instance and applies any supplied options func New(opts ...Option) (Bolt, error) { _, check := os.LookupEnv(TOKEN_ENV_VAR) if !check { @@ -73,16 +83,15 @@ func New(opts ...Option) (Bolt, error) { } b := &bolt{ - Session: bot, - commands: make(map[string]Command, 0), - logLvl: LogLevelAll, - indicator: DEFAULT_INDICATOR, - wg: sync.WaitGroup{}, - // admin: false, + Session: bot, + commands: make(map[string]Command, 0), + logLvl: LogLevelAll, + indicator: DEFAULT_INDICATOR, + wg: sync.WaitGroup{}, maxRoutines: DEFAULT_MAX_GOROUTINES, } - //apply options + b.Identify.Intents = DEFAULT_INTENTS for _, opt := range opts { opt(b) } @@ -109,7 +118,7 @@ func (b *bolt) Start() error { signal.Notify(sigChannel, os.Interrupt) <-sigChannel - //move this to an option, maybe? + //give handler routines a 5 second window to finish processes before closing connection ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() closeChan := make(chan struct{}, 0) @@ -129,8 +138,8 @@ func (b *bolt) Start() error { return b.stop() } -// AddCommands registers command handlers, any messages that begin with the command indicator will be forwarded -// to a handler if the command string matches a trigger +// AddCommands registers command handlers. Any messages that begin with the command indicator will be forwarded +// to the handler func (b *bolt) AddCommands(cmd ...Command) { for _, c := range cmd { b.commands[c.Trigger] = c @@ -138,18 +147,22 @@ func (b *bolt) AddCommands(cmd ...Command) { } // AddMessageHandler registers the generic message handler, any messages that are not commands will be forwarded -// to the Payload +// to the handler func (b *bolt) AddMessageHandler(p Payload) { b.msgHandlerf = p } -// stop closes the websocket connection with Discord +// stop closes the websocket connection to Discord func (b *bolt) stop() error { return b.Close() } // msgEventHandler is a beefy boy that handles message logging, command parsing, and executing payload functions. It needs cleanup then // i'll worry about this comment + +// msgEventHandler handles the routing of messages to either the command or message handlers. If LogLvl is set, logging is handled here before +// the event is mapped to the Message struct and forwarded to the handlers. Each message is handled in its own goroutine, the max allowed goroutines +// is set as a default and can be altered using options for better performance. func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //get server information server, err := s.Guild(msg.GuildID) @@ -165,7 +178,7 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { //the bot will ignore it's own messages to prevent command loops if msg.Author.ID == s.State.User.ID { - if b.logLvl != LogLevelErr && b.logLvl != LogLevelNone { + if b.logLvl != LogLevelErr { log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } return @@ -175,7 +188,35 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { log.Printf("< %s | %s | %s > %s\n", server.Name, channel.Name, msg.Author.Username, msg.Content) } - //this hsould be moved to a parseMessageEvent method + m := b.mapEventToMsg(msg, channel, server) + lg := len(b.indicator) + if msg.Content[:lg] == b.indicator { + if b.logLvl == LogLevelCmd { + log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.Author.Name, m.Content) + } + + b.pool <- struct{}{} //'aquire' a routine + b.wg.Go(func() { + err := b.handleCommand(&m, lg) + if err != nil { + log.Println(err) + } + <-b.pool //release routine + }) + } else { + b.pool <- struct{}{} //'aquire' a routine + b.wg.Go(func() { + err := b.handleMessage(&m) + if err != nil { + log.Println(err) + } + <-b.pool //release routine + }) + } +} + +// mapEventToMsg maps the Discord event message struct into bolt's user-friendly Message struct +func (b *bolt) mapEventToMsg(msg *dg.MessageCreate, channel *dg.Channel, server *dg.Guild) Message { m := Message{ Author: Author{ Name: msg.Author.Username, @@ -218,33 +259,10 @@ func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) { m.Attachments = att } - lg := len(b.indicator) - if msg.Content[:lg] == b.indicator { - if b.logLvl == LogLevelCmd { - log.Printf("< %s | %s | %s > %s\n", m.Server, m.Channel, m.Author.Name, m.Content) - } - - b.pool <- struct{}{} //'aquire' a routine - b.wg.Go(func() { - err := b.handleCommand(&m, lg) - if err != nil { - log.Println(err) - } - <-b.pool //release routine - }) - } else { - b.pool <- struct{}{} //'aquire' a routine - b.wg.Go(func() { - err := b.handleMessage(&m) - if err != nil { - log.Println(err) - } - <-b.pool //release routine - }) - } + return m } -// handleMessage forwards the message data to the handler function, if one was set +// handleMessage forwards the message data to the handler function, if one is set func (b *bolt) handleMessage(event *Message) error { if b.msgHandlerf != nil { return b.msgHandlerf(&Context{ @@ -256,31 +274,35 @@ func (b *bolt) handleMessage(event *Message) error { return nil } -// handleCommand maps the first word of the message to the command payload, if it exists. Checking the timeout -// and role restrictions before forwarding the message to the Command Payload. If restrictions have not been met -// a response is sent to the message +// handleCommand maps the first word of the message to the command payload, if it exists. It then forwards the message +// data to the handler after checking the timeout and role restrictions. If restrictions have not been met a generic +// response is sent to the message +// TODO: accept a string for timeout/role rejection messages to allow customization func (b *bolt) handleCommand(msg *Message, lg int) error { run, ok := b.commands[msg.Words[0][lg:]] if !ok { - return nil //command doesn't exist, maybe log or respond to author + return nil //command doesn't exist } - //has command met its timeout requirements + //has command met its timeout requirements, if timeout has not expired a response is sent to the message + //from the timeoutCheck method tc, err := b.timeoutCheck(msg.ID, msg.ChannelID, msg.ServerID, b.Session, run) if err != nil { return fmt.Errorf("failed to calculate timeout for %s\n%e", run.Trigger, err) } + //method handles sending a response, so we do not need to send another here, simply return if !tc { return nil } - //does user have correct permissions + //does user have correct permissions to run this command if run.Roles != nil { check, err := b.roleCheck(msg.ServerID, msg.Author.Roles, b.Session, run) if err != nil { return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err) } if !check { + //this alert should probably be moved into the roleCheck function to match the pattern of the timeoutCheck method reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.ChannelID, msg.ServerID) _, err := b.Session.ChannelMessageSendComplex(msg.ChannelID, reply) if err != nil { @@ -290,6 +312,7 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { } } + //execute handler func with message context err = run.Payload(&Context{ Message: msg, bolt: b, @@ -304,7 +327,7 @@ func (b *bolt) handleCommand(msg *Message, lg int) error { return nil } -// basic wrapper function to create easy Discord responses +// createReploy is a basic wrapper function to map message data to the actual MessageSend struct func (b *bolt) createReply(content, message, channel, guild string) *dg.MessageSend { details := &dg.MessageReference{ MessageID: message, @@ -318,7 +341,8 @@ func (b *bolt) createReply(content, message, channel, guild string) *dg.MessageS } } -// used to calculate the remaining time left in a timeout and returning it in a human-readable format +// remainingTimeout calculates the amount of time left before a command can be run again. Returning a string +// representing a readable version of the time left, example: 1h (1 hour) func (b *bolt) remainingTimeout(timeout time.Time) string { r := time.Until(timeout) var ( @@ -336,14 +360,18 @@ func (b *bolt) remainingTimeout(timeout time.Time) string { } } + //provide a user-friendly time string to send back to the user return fmt.Sprintf("%d%s", timeLeft, metric) } // checks if the author of msg has the correct role to run the requested command + +// roleCheck loops through the provided user role ID's grabs the role "name" and ensures the role is present +// in the commands whitelist. If the role exists in the Command whitelist a true response is returned func (b *bolt) roleCheck(guild string, roles []string, 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 + //this needs a bit of love, looping through roles could get messy as server and role lists grow on big servers + //especially with the Role() call inside of the loop for _, r := range roles { //get role name from ID n, err := s.State.Role(guild, r) @@ -358,14 +386,11 @@ func (b *bolt) roleCheck(guild string, roles []string, s *dg.Session, run Comman } } - //can't find role, don't run command - if !found { - return false, nil - } - - return true, nil + return found, nil } +// timeoutCheck determines if a Command timeout has expired, if it hasn't expired a response is sent back to the message alerting the user +// of the remaining time func (b *bolt) timeoutCheck(msgID, channelID, guildID string, s *dg.Session, run Command) (bool, error) { wait := run.lastRun.Add(run.Timeout) now := time.Now() diff --git a/command.go b/command.go index 14655bb..221c462 100644 --- a/command.go +++ b/command.go @@ -4,43 +4,18 @@ import ( "time" ) -// custom Discord commands +// Command represents a bolt command in its entirety type Command struct { - Trigger string //command that triggers payload NOT including the indicator - Payload Payload //payload function to run when a command is detected - Timeout time.Duration //the amount of time before command can be run again - lastRun time.Time //timestamp of last command run - Roles []string //roles that can use command, if none are set anyone can run the command + //the trigger phrase for the command, this field cannot include the command indicator. If the command is .ping + //Trigger should be "ping" + Trigger string + //handler function to run when command is detected + Payload Payload + //the amount of time that must pass before a command can be run again + Timeout time.Duration + //timestamp of last command run + lastRun time.Time + //the roles that are allowed to execute this command, the command author must have at least one of these roles + //in order to use the command + Roles []string } - -// type adminToolbox struct { -// *bolt -// } -// type AdminToolBox interface { -// Timeout(userId, serverId string, duration time.Time) error -// ClearTimeout(userId, serverId string) error -// Mute(userId, serverId string) error -// Unmute(userId, serverId string) error -// } - -// func NewToolbox(b *bolt) AdminToolBox { -// return &adminToolbox{ -// bolt: b, -// } -// } - -// func (a *adminToolbox) Timeout(userId, serverId string, duration time.Time) error { -// return a.GuildMemberTimeout(serverId, userId, &duration) -// } - -// func (a *adminToolbox) ClearTimeout(userId, serverId string) error { -// return a.GuildMemberTimeout(serverId, userId, nil) -// } - -// func (a *adminToolbox) Mute(userId, serverId string) error { -// return a.GuildMemberMute(serverId, userId, true) -// } - -// func (a *adminToolbox) Unmute(userId, serverId string) error { -// return a.GuildMemberMute(serverId, userId, false) -// } diff --git a/message.go b/message.go index e4a8902..9b771a1 100644 --- a/message.go +++ b/message.go @@ -8,14 +8,14 @@ import ( ) const ( - // the max length allowed for basic messages + // the max length allowed for basic messages, if the message content exceeds this amount + // then messages are split unto chunks of max size MSG_MAX_LENGTH = 2000 ) -// command payload functions, any strings returned are sent as a response to the command +// command and message payload function type type Payload func(c *Context) error -// message attachment details type MessageAttachment struct { ID string URL string @@ -28,31 +28,41 @@ type MessageAttachment struct { DurationSecs float64 } +// Author contains basic information about message authors type Author struct { Name string ID string Roles []string } +// Message contains all needed data to handle message events type Message struct { - Author Author - ID string //message ID - Words []string //message data split on whitespaces - Content string //entire message data string - Channel string //name of channel message was sent in - ChannelID string //ID of channel message was sent in - Server string //name of guild message was sent in - ServerID string //ID of guild message was sent in + Author Author + //current message ID + ID string + //message content split on whitespace to allow for easy argument parsing with commands + Words []string + //entire message content unchanged + Content string + //channel message was sent in, name and ID + Channel string + ChannelID string + //guild message was sent in, name and ID + Server string + ServerID string + //message extras Attachments []MessageAttachment //any attachments bound to the message - Mentions []*dg.User + Mentions []*dg.User //users mention in the message with @ } +// Context is the struct passed to message and command handlers, it contains all needed message data as well as +// some methods to make interaction with the message as easy as possible type Context struct { Message *Message bolt *bolt } -// React applies reaction to the message +// React applies the reaction to the message func (c *Context) React(emoji Reaction) error { return c.bolt.MessageReactionAdd(c.Message.ChannelID, c.Message.ID, fmt.Sprint(emoji)) } @@ -85,7 +95,7 @@ func (c *Context) Respond(res string) error { return nil } - //short enough message to send in one go + //short enough message to send in one go, so ship it rep := c.bolt.createReply(res, c.Message.ID, c.Message.ChannelID, c.Message.ServerID) _, err := c.bolt.ChannelMessageSendComplex(c.Message.ChannelID, rep) return err @@ -96,18 +106,23 @@ func (c *Context) Delete() error { return c.bolt.ChannelMessageDelete(c.Message.ChannelID, c.Message.ID, nil) } +// Timeout creates a user timeout for the supplied userID that lasts until duration is exceeded or the +// timeout is cleared func (c *Context) Timeout(userId string, duration time.Time) error { return c.bolt.GuildMemberTimeout(c.Message.ServerID, userId, &duration) } +// ClearTimeout clears existing user timeouts, allowing them access again func (c *Context) ClearTimeout(userId string) error { return c.bolt.GuildMemberTimeout(c.Message.ServerID, userId, nil) } +// Mute handles muting a user from Voice Chat, this mute stays until it is Unmute()'d func (c *Context) Mute(userId string) error { return c.bolt.GuildMemberMute(c.Message.ServerID, userId, true) } +// Unmute removes a mute on the user, allowing them vc access again func (c *Context) Unmute(userId string) error { return c.bolt.GuildMemberMute(c.Message.ServerID, userId, false) } diff --git a/option.go b/option.go index e07b553..938f939 100644 --- a/option.go +++ b/option.go @@ -5,60 +5,44 @@ import ( ) type Option func(b *bolt) - type LogLevel int -type Permission dg.Intent - -type HandlerLevel int - const ( - LogLevelAll LogLevel = iota //log all messages, and errors - LogLevelCmd LogLevel = iota //log only commands and responses, and errors - LogLevelErr LogLevel = iota //log only errors - LogLevelNone LogLevel = iota //log nothing, let the handlers sort it out - - msgPerms dg.Intent = dg.IntentGuilds | - dg.IntentGuildMembers | - dg.IntentGuildPresences | - dg.IntentMessageContent | - dg.IntentsGuildMessages | - dg.IntentGuildMessageReactions - - MessagePermissions Permission = Permission(msgPerms) - AdminPermissions Permission = 0 //fake - //we also need a ModeratorPermissions for banning, kicking, etc. + LogLevelAll LogLevel = iota //log all messages, and errors + LogLevelCmd LogLevel = iota //log only commands and responses, and errors + LogLevelErr LogLevel = iota //log only errors ) -func WithPermissions(perms ...Permission) Option { +// WithIntents provides an option to use custom intents for the bot. Bolt comes preconfigured with the basic +// intents needed to run a bot but those are completely overwritten by any supplied here +func WithIntents(intents ...dg.Intent) Option { return func(b *bolt) { - var fullPerms dg.Intent - for _, p := range perms { - // if p == AdminPermissions { - // b.admin = true - // } - fullPerms |= dg.Intent(p) + var full dg.Intent + for _, i := range intents { + full |= i } - //set intents - b.Identify.Intents = fullPerms + b.Identify.Intents = full } } +// WithMaxGoroutines limits the amount of handler routines the bot is able to spawn at the same time. A lower value +// may cause higher latency but may reduce resources needed to run bolt func WithMaxGoroutines(max int) Option { return func(b *bolt) { b.maxRoutines = max } } -// sets the substring that must be present at the beginning of the message to indicate a command +// WithIndicator sets the substring that must be present at the beginning of a message to trigger a +// command, for example "." or "!" func WithIndicator(i string) Option { return func(b *bolt) { b.indicator = i } } -// sets the log level to determine how much bolt logs +// WithLogLevel adjusts bolt logging verbosity func WithLogLevel(lvl LogLevel) Option { return func(b *bolt) { b.logLvl = lvl diff --git a/reaction.go b/reaction.go index dff112b..662d89a 100644 --- a/reaction.go +++ b/reaction.go @@ -1,9 +1,9 @@ package bolt -// built-in Discord reactions type Reaction string -// a few easy-to-use emojis, Discordgo/Discord API requires them to be saved like this. +// a few easy-to-use emojis, Discordgo/Discord API requires them to be saved like this. Some appear "broken" but do not play friendly +// when saved in-file const ( ReactionThumbsUp Reaction = "👍" ReactionThumbsDown Reaction = "👎" -- 2.49.1