feature/improvements #8

Merged
jake merged 5 commits from feature/improvements into main 2026-02-25 19:22:27 +00:00
8 changed files with 153 additions and 110 deletions
Showing only changes of commit 5297a480b8 - Show all commits

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ go.work.sum
# env file # env file
.env .env
/cmd/* /cmd/*
/cmd

View File

@@ -1,12 +1,14 @@
# bolt # 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. 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 package main
import ( import (
"strings"
"time" "time"
"code.jakeyoungdev.com/jake/bolt" "code.jakeyoungdev.com/jake/bolt"
_ "github.com/joho/godotenv/autoload"
) )
func main() { func main() {
//bolt defaults the command indicator to '.' however that can be changed with the options b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelAll),
//Example: bolt.New(bolt.WithIndicator('!')) would support commands like !ping bolt.WithMaxGoroutines(50),
b, err := bolt.New(bolt.WithLogLevel(bolt.LogLevelCmd)) bolt.WithPermissions(bolt.MessagePermissions, bolt.AdminPermissions))
if err != nil { if err != nil {
panic(err) panic(err)
} }
b.AddCommands( b.AddCommands(
// basic ping pong command, .ping can be run at anytime by anyone and will reply "pong"
bolt.Command{ bolt.Command{
Trigger: "ping", Trigger: "test",
Payload: func(msg bolt.Message) error { Payload: func(msg *bolt.Message, admin bolt.AdminToolBox) error {
return msg.Respond("pong") 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{ bolt.Command{
Trigger: "react", Trigger: "timeout",
Payload: func(msg bolt.Message) error { Payload: func(msg *bolt.Message, tools bolt.AdminToolBox) error {
return msg.React(bolt.ReactionThumbsUp) 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")
}, },
}, Roles: []string{"admin"},
// .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"},
}, },
) )
//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() err = b.Start()
if err != nil { if err != nil {
panic(err) panic(err)
} }
} }
``` ```
## Development ## Development

67
bolt.go
View File

@@ -9,7 +9,6 @@ import (
"slices" "slices"
"strings" "strings"
"sync" "sync"
"syscall"
"time" "time"
dg "github.com/bwmarrin/discordgo" dg "github.com/bwmarrin/discordgo"
@@ -34,11 +33,14 @@ type bolt struct {
pool chan struct{} pool chan struct{}
maxRoutines int maxRoutines int
msgHandlerf Payload msgHandlerf Payload
admin bool
tools AdminToolBox
} }
type Bolt interface { type Bolt interface {
Start() error Start() error
AddCommands(cmd ...Command) AddCommands(cmd ...Command)
AddMessageHandler(p Payload)
//filtered methods //filtered methods
stop() error stop() error
msgEventHandler(s *dg.Session, msg *dg.MessageCreate) msgEventHandler(s *dg.Session, msg *dg.MessageCreate)
@@ -67,6 +69,7 @@ func New(opts ...Option) (Bolt, error) {
logLvl: LogLevelAll, logLvl: LogLevelAll,
indicator: DEFAULT_INDICATOR, indicator: DEFAULT_INDICATOR,
wg: sync.WaitGroup{}, wg: sync.WaitGroup{},
admin: false,
maxRoutines: DEFAULT_MAX_GOROUTINES, maxRoutines: DEFAULT_MAX_GOROUTINES,
} }
@@ -75,8 +78,9 @@ func New(opts ...Option) (Bolt, error) {
opt(b) 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.pool = make(chan struct{}, b.maxRoutines)
b.tools = NewToolbox(b)
return b, nil return b, nil
} }
@@ -91,7 +95,7 @@ func (b *bolt) Start() error {
log.Println("bot started") log.Println("bot started")
sigChannel := make(chan os.Signal, 1) sigChannel := make(chan os.Signal, 1)
signal.Notify(sigChannel, syscall.SIGINT) signal.Notify(sigChannel, os.Interrupt)
<-sigChannel <-sigChannel
//move this to an option, maybe? //move this to an option, maybe?
@@ -105,18 +109,13 @@ func (b *bolt) Start() error {
select { select {
case <-ctx.Done(): 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: case <-closeChan:
log.Println("command routines cleaned up, exiting") log.Println("handler routines cleaned")
} }
if err := b.stop(); err != nil { log.Println("exiting")
return err return b.stop()
}
log.Println("bot stopped")
return nil
} }
func (b *bolt) stop() error { 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) { func (b *bolt) msgEventHandler(s *dg.Session, msg *dg.MessageCreate) {
//get server information //get server information
server, err := s.Guild(msg.GuildID) 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) 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{ m := Message{
Author: msg.Author.Username, Author: Author{
authorID: msg.Author.ID, Name: msg.Author.Username,
authorRoles: msg.Member.Roles, ID: msg.Author.ID,
ID: msg.ID, Roles: msg.Member.Roles,
Content: msg.Content, },
Channel: channel.Name, ID: msg.ID,
channelID: channel.ID, Content: msg.Content,
Server: server.Name, Channel: channel.Name,
serverID: server.ID, ChannelID: channel.ID,
sesh: b, Server: server.Name,
ServerID: server.ID,
sesh: b,
} }
w := strings.Fields(msg.Content) 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 msg.Content[:lg] == b.indicator {
if b.logLvl == LogLevelCmd { if b.logLvl == LogLevelCmd {
//log commands //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 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 { func (b *bolt) handleMessage(event *Message) error {
if b.msgHandlerf != nil { if b.msgHandlerf != nil {
return b.msgHandlerf(event) return b.msgHandlerf(event, b.tools)
} }
return nil return nil
@@ -247,7 +253,7 @@ func (b *bolt) handleCommand(msg *Message, lg int) error {
} }
//has command met its timeout requirements //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 { if err != nil {
return fmt.Errorf("failed to calculate timeout for %s\n%e", run.Trigger, err) 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 //does user have correct permissions
if run.Roles != nil { 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 { if err != nil {
return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err) return fmt.Errorf("failed to perform permission checks for %s\n%e", run.Trigger, err)
} }
if !check { if !check {
reply := b.createReply("you do not have permissions to run that command", msg.ID, msg.channelID, msg.serverID) 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) _, err := b.Session.ChannelMessageSendComplex(msg.ChannelID, reply)
if err != nil { if err != nil {
return err 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 { if err != nil {
return fmt.Errorf("encountered an error while handling command (%s): %e", msg.Words[0], err) 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) { func (b *bolt) timeoutCheck(msgID, channelID, guildID string, s *dg.Session, run Command) (bool, error) {
wait := run.lastRun.Add(run.Timeout) 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) reply := b.createReply(fmt.Sprintf("that command cannot be run for another %s", b.remainingTimeout(wait)), msgID, channelID, guildID)
_, err := s.ChannelMessageSendComplex(channelID, reply) _, err := s.ChannelMessageSendComplex(channelID, reply)
if err != nil { if err != nil {

View File

@@ -14,4 +14,36 @@ type Command struct {
} }
// command payload functions, any strings returned are sent as a response to the command // 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)
}

5
go.mod
View File

@@ -2,7 +2,10 @@ module code.jakeyoungdev.com/jake/bolt
go 1.25.0 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 ( require (
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect

2
go.sum
View File

@@ -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/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 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 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= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=

View File

@@ -2,7 +2,6 @@ package bolt
import ( import (
"fmt" "fmt"
"time"
dg "github.com/bwmarrin/discordgo" 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 // a timeout to prevent hanging for too long, this timeout can be customized with the WithTimeout
// option. // option.
type Message struct { type Message struct {
Author string //current username of the message author Author Author
authorID string //discord ID of message author
authorRoles []string
ID string //message ID ID string //message ID
Words []string //message data split on whitespaces Words []string //message data split on whitespaces
Content string //entire message data string Content string //entire message data string
Channel string //name of channel message was sent in 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 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 Attachments []MessageAttachment //any attachments bound to the message
Mentions []*dg.User Mentions []*dg.User
sesh *bolt sesh *bolt
} }
type Author struct {
Name string
ID string
Roles []string
}
// React applies reaction to the message // React applies reaction to the message
func (m *Message) React(emoji Reaction) error { 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 // 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 { for len(res) > 0 {
//send full chunk size allowed by discord //send full chunk size allowed by discord
sc := res[:MSG_MAX_LENGTH] sc := res[:MSG_MAX_LENGTH]
rep := m.sesh.createReply(sc, m.ID, m.channelID, m.serverID) rep := m.sesh.createReply(sc, m.ID, m.ChannelID, m.ServerID)
_, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep)
if err != nil { if err != nil {
return err 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 we have left than a full chunk send the rest and break the loop
if len(res) < MSG_MAX_LENGTH { if len(res) < MSG_MAX_LENGTH {
final := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) final := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID)
_, err := m.sesh.ChannelMessageSendComplex(m.channelID, final) _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, final)
if err != nil { if err != nil {
return err return err
} }
@@ -66,44 +69,31 @@ func (m *Message) Respond(res string) error {
} }
//short enough message to send in one go //short enough message to send in one go
rep := m.sesh.createReply(res, m.ID, m.channelID, m.serverID) rep := m.sesh.createReply(res, m.ID, m.ChannelID, m.ServerID)
_, err := m.sesh.ChannelMessageSendComplex(m.channelID, rep) _, err := m.sesh.ChannelMessageSendComplex(m.ChannelID, rep)
return err return err
} }
// Delete removes the message from the current channel // Delete removes the message from the current channel
func (m *Message) Delete() error { 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(userID string, duration time.Time) error {
func (m *Message) Timeout(duration time.Time) error { // return m.sesh.GuildMemberTimeout(m.serverID, userID, &duration)
return m.sesh.GuildMemberTimeout(m.serverID, m.authorID, &duration) // }
}
// ClearTimeout removes all timeouts for the message author // func (m *Message) ClearTimeout(userID string) error {
func (m *Message) ClearTimeout() error { // return m.sesh.GuildMemberTimeout(m.serverID, userID, nil)
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 // func (m *Message) Mute(userID string) error {
// the days parameter // return m.sesh.GuildMemberMute(m.serverID, userID, true)
func (m *Message) Ban(reason string, days int) error { // }
return m.sesh.GuildBanCreateWithReason(m.serverID, m.authorID, reason, days)
}
// unban user // func (m *Author) Unmute(userID string) error {
// return m.sesh.GuildMemberMute(m.serverID, userID, false)
// 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 // message attachment details
type MessageAttachment struct { type MessageAttachment struct {

View File

@@ -22,10 +22,11 @@ const (
dg.IntentGuildMembers | dg.IntentGuildMembers |
dg.IntentGuildPresences | dg.IntentGuildPresences |
dg.IntentMessageContent | dg.IntentMessageContent |
dg.IntentsGuildMessages dg.IntentsGuildMessages |
dg.IntentGuildMessageReactions
MessagePermissions Permission = Permission(msgPerms) MessagePermissions Permission = Permission(msgPerms)
ReactionPermissions Permission = Permission(dg.IntentGuildMessageReactions) AdminPermissions Permission = 0 //fake
//we also need a ModeratorPermissions for banning, kicking, etc. //we also need a ModeratorPermissions for banning, kicking, etc.
) )
@@ -33,6 +34,9 @@ func WithPermissions(perms ...Permission) Option {
return func(b *bolt) { return func(b *bolt) {
var fullPerms dg.Intent var fullPerms dg.Intent
for _, p := range perms { for _, p := range perms {
if p == AdminPermissions {
b.admin = true
}
fullPerms |= dg.Intent(p) fullPerms |= dg.Intent(p)
} }