From 5297a480b83d264a5583c2399a533251d84ac269 Mon Sep 17 00:00:00 2001 From: jake Date: Tue, 24 Feb 2026 18:19:41 -0500 Subject: [PATCH] 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) }