Files
swiftspiracy-bot/cmd/bot/main.go
T

287 lines
7.8 KiB
Go

package main
import (
"embed"
"encoding/json"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
"github.com/joho/godotenv"
)
var (
// CommitHash and BuildData get replaced during build with the commit hash and time of build.
CommitHash string
BuildDate string
// StartTime is reset every time the application is restarted
StartTime = time.Now()
)
// Embed JSON files at build time
//go:embed praises.json
var praisesFile embed.FS
//go:embed conspiracies.json
var conspiraciesFile embed.FS
var (
// messages sourced from the embedded files
praises []string
conspiracies []string
// internally track which messages have been sent
praiseIndex int
conspiracyIndex int
)
// Settings for bot sourced from env variables
var (
botToken string
channelID string
SendMessageIntervalMin int
SendMessageIntervalMax int
SendMessageUnit time.Duration
DeleteConspiracyDelay time.Duration
ConspiracyProbability float64
)
func main() {
if err := loadEnvConfig(); err != nil {
log.Fatalf("[x] Failed to load configuration: %v", err)
}
// Load messages at build time
if err := loadMessages(); err != nil {
log.Fatalf("[x] Error loading messages: %v", err)
}
// Initialize bot session
dg, err := discordgo.New("Bot " + botToken)
if err != nil {
log.Fatalf("[x] Error creating Discord session: %v", err)
}
dg.AddHandler(readyHandler)
interactionHandlers := make(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate))
for _, interaction := range interactions {
interactionHandlers[interaction.command.Name] = interaction.handler
}
dg.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if h, ok := interactionHandlers[i.ApplicationCommandData().Name]; ok {
h(s, i)
}
})
// Open WebSocket connection
if err := dg.Open(); err != nil {
log.Fatalf("[x] Error opening connection: %v", err)
}
defer dg.Close()
for _, entry := range interactions {
created, err := dg.ApplicationCommandCreate(dg.State.User.ID, "", entry.command)
if err != nil {
log.Fatalf("Cannot create '%s' command: %v", entry.command.Name, err)
}
interactionHandlers[created.Name] = entry.handler
}
// Start the message scheduler
go startScheduler(dg, channelID)
// Graceful shutdown handling
log.Println("[✓] Swiftspiracy Bot is now running. Press CTRL+C to exit.")
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-sc
log.Println("[↓] Shutting down bot gracefully...")
}
func loadEnvConfig() error {
if err := godotenv.Load(); err != nil {
log.Println("[!] No .env file found, using system environment variables.")
}
botToken = os.Getenv("DISCORD_BOT_TOKEN")
if botToken == "" {
return fmt.Errorf("Missing DISCORD_BOT_TOKEN")
}
channelID = os.Getenv("DISCORD_CHANNEL_ID")
if botToken == "" {
return fmt.Errorf("Missing DISCORD_CHANNEL_ID")
}
var err error
SendMessageIntervalMin, err = strconv.Atoi(os.Getenv("SEND_MESSAGE_INTERVAL_MIN"))
if err != nil {
return fmt.Errorf("SEND_MESSAGE_INTERVAL_MIN: %w", err)
}
SendMessageIntervalMax, err = strconv.Atoi(os.Getenv("SEND_MESSAGE_INTERVAL_MAX"))
if err != nil {
return fmt.Errorf("SEND_MESSAGE_INTERVAL_MAX: %w", err)
}
switch os.Getenv("SEND_MESSAGE_UNIT") {
case "second", "seconds":
SendMessageUnit = time.Second
case "minute", "minutes":
SendMessageUnit = time.Minute
case "millisecond", "milliseconds":
SendMessageUnit = time.Millisecond
default:
return fmt.Errorf("unsupported SEND_MESSAGE_UNIT: %s", os.Getenv("SEND_MESSAGE_UNIT"))
}
DeleteConspiracyDelay, err = time.ParseDuration(os.Getenv("DELETE_CONSPIRACY_DELAY"))
if err != nil {
return fmt.Errorf("DELETE_CONSPIRACY_DELAY: %w", err)
}
ConspiracyProbability, err = strconv.ParseFloat(os.Getenv("CONSPIRACY_PROBABILITY"), 64)
if err != nil {
return fmt.Errorf("CONSPIRACY_PROBABILITY: %w", err)
}
// Get values in case of resuming at an index
praiseIndex = 0
if valStr := os.Getenv("PRAISE_INDEX"); valStr != "" {
praiseIndex, err = strconv.Atoi(valStr)
if err != nil {
log.Printf("[!] Warning: %s is not a valid int, using default %d", "PRAISE_INDEX", 0)
praiseIndex = 0
}
}
conspiracyIndex = 0
if valStr := os.Getenv("CONSPIRACY_INDEX"); valStr != "" {
conspiracyIndex, err = strconv.Atoi(valStr)
if err != nil {
log.Printf("[!] Warning: %s is not a valid int, using default %d", "CONSPIRACY_INDEX", 0)
conspiracyIndex = 0
}
}
return nil
}
// loadMessages loads JSON files into memory at build time
func loadMessages() error {
var err error
if praises, err = loadJSONFromEmbed(praisesFile, "praises.json"); err != nil {
return fmt.Errorf("failed to load praises.json: %w", err)
}
if conspiracies, err = loadJSONFromEmbed(conspiraciesFile, "conspiracies.json"); err != nil {
return fmt.Errorf("failed to load conspiracies.json: %w", err)
}
return nil
}
// loadJSONFromEmbed reads JSON from an embedded file system
func loadJSONFromEmbed(fs embed.FS, filename string) ([]string, error) {
data, err := fs.ReadFile(filename)
if err != nil {
return nil, err
}
var messages []string
if err := json.Unmarshal(data, &messages); err != nil {
return nil, err
}
return messages, nil
}
// readyHandler confirms the bot is online
func readyHandler(s *discordgo.Session, r *discordgo.Ready) {
log.Println("[✓] Swiftspiracy Bot is online and ready!")
}
var interactions = []struct {
command *discordgo.ApplicationCommand
handler func(s *discordgo.Session, i *discordgo.InteractionCreate)
}{
{
command: &discordgo.ApplicationCommand{
Name: "buildinfo",
Description: "Get the bot's build info and uptime",
},
handler: func(s *discordgo.Session, i *discordgo.InteractionCreate) {
uptime := time.Since(StartTime).Round(time.Second)
response := fmt.Sprintf(
":tools: Build Info:\n- Commit Hash: `%s`\n- Build Date: `%s`\n- Uptime: `%s`",
CommitHash,
BuildDate,
uptime,
)
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: response,
},
})
if err != nil {
log.Printf("[x] Failed to respond to interaction: %v", err)
}
},
},
}
// startScheduler handles sending messages at intervals
func startScheduler(s *discordgo.Session, channelID string) {
randomDuration := 0 * time.Second
for {
// Wait for the randomized time before sending a message
time.Sleep(randomDuration)
sendMessage(praises[praiseIndex%len(praises)], s, channelID)
praiseIndex++
// Chance to send a conspiracy theory
if rand.Float64() < ConspiracyProbability {
discordMessage := sendMessage(conspiracies[conspiracyIndex%len(conspiracies)], s, channelID)
conspiracyIndex++
go deleteMessageAfterDelay(discordMessage, s, channelID)
}
randomDuration = time.Duration(rand.Intn(SendMessageIntervalMax-SendMessageIntervalMin)+(SendMessageIntervalMin)) * SendMessageUnit
log.Printf("[!] Next message in %v", randomDuration)
}
}
// sendMessage sends a random message from a given list
func sendMessage(message string, s *discordgo.Session, channelID string) *discordgo.Message {
discordMessage, err := s.ChannelMessageSend(channelID, message)
if err != nil {
log.Printf("[x] Error sending message: %v\n", err)
return nil
}
return discordMessage
}
// deleteMessageAfterDelay deletes the message after a given delay
func deleteMessageAfterDelay(discordMessage *discordgo.Message, s *discordgo.Session, channelID string) {
if discordMessage == nil {
return
}
time.Sleep(DeleteConspiracyDelay)
err := s.ChannelMessageDelete(channelID, discordMessage.ID)
if err != nil {
log.Printf("[!] Error deleting conspiracy: %v\n", err)
} else {
log.Println("[↓] Conspiracy deleted!")
}
}