bolt/bolt.go
2025-06-07 09:31:00 -04:00

289 lines
7.0 KiB
Go

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