feature/improvements #8
1
.gitignore
vendored
1
.gitignore
vendored
@@ -26,3 +26,4 @@ go.work.sum
|
||||
.env
|
||||
|
||||
/cmd/*
|
||||
/cmd
|
||||
72
README.md
72
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"},
|
||||
},
|
||||
)
|
||||
|
||||
//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
|
||||
|
||||
57
bolt.go
57
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,16 +160,19 @@ 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,
|
||||
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,
|
||||
ChannelID: channel.ID,
|
||||
Server: server.Name,
|
||||
serverID: server.ID,
|
||||
ServerID: server.ID,
|
||||
sesh: b,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
34
command.go
34
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)
|
||||
}
|
||||
|
||||
5
go.mod
5
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
|
||||
|
||||
2
go.sum
2
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=
|
||||
|
||||
68
message.go
68
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 {
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user