hachem.wtf
aboutsummaryrefslogtreecommitdiff
path: root/internal/bot
diff options
context:
space:
mode:
authorhachem <im@hachem.wtf>2026-08-29 10:44:22 +0200
committerhachem <im@hachem.wtf>2026-08-29 10:44:22 +0200
commitaf4a6f4c5e9a3e3a501deac71d92bbbcd12ef81f (patch)
tree4882c0dd5e7e252e6216e00cc5cba0e6c2bfd533 /internal/bot
parent25bdd381d6dcf8581b1391c1c1fa22c108913ccb (diff)
add: reimplement discord bot
Diffstat (limited to 'internal/bot')
-rw-r--r--internal/bot/bot.go206
1 files changed, 203 insertions, 3 deletions
diff --git a/internal/bot/bot.go b/internal/bot/bot.go
index 1c8182d..4fb8fc8 100644
--- a/internal/bot/bot.go
+++ b/internal/bot/bot.go
@@ -1,8 +1,208 @@
package bot
-import "fmt"
+import (
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+
+ "github.com/hachem-wtf/benson/internal/config"
+ "github.com/hachem-wtf/benson/internal/modrinth"
+)
func Run() error {
- fmt.Println("benson bot — not yet implemented")
- return nil
+ cfg, err := config.Load()
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("no config found — run `benson setup` to create one")
+ }
+ return fmt.Errorf("failed to load %s: %w", config.Path(), err)
+ }
+
+ api, err := config.LoadAPI()
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("no bot token found — run `benson setup` to create one")
+ }
+ return fmt.Errorf("failed to load %s: %w", config.APIPath(), err)
+ }
+ if api.Token == "" {
+ return errors.New("config/api.json: token is empty")
+ }
+ if cfg.ChannelID == "" {
+ return errors.New("config/config.json: channel_id is empty")
+ }
+
+ session, err := discordgo.New("Bot " + api.Token)
+ if err != nil {
+ return err
+ }
+ session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages
+
+ ready := make(chan struct{})
+ session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
+ log.Printf("[OK] Logged in as %s", r.User.String())
+ close(ready)
+ })
+
+ if err := session.Open(); err != nil {
+ return err
+ }
+ defer session.Close()
+
+ <-ready
+
+ client := modrinth.NewClient()
+ checkAndReport(session, cfg, client)
+
+ interval := time.Duration(cfg.CheckIntervalMinutes) * time.Minute
+ if interval <= 0 {
+ interval = time.Hour
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+
+ log.Printf("checking every %s — press Ctrl+C to stop", interval)
+
+ for {
+ select {
+ case <-ticker.C:
+ checkAndReport(session, cfg, client)
+ case <-stop:
+ log.Println("shutting down")
+ return nil
+ }
+ }
+}
+
+type report struct {
+ noCompatible []string
+ newlyCompatible []string
+ stillCompatibleUpdate []string
+}
+
+func checkAndReport(session *discordgo.Session, cfg *config.Config, client *modrinth.Client) {
+ rep := checkMods(cfg, client)
+
+ embed := buildEmbed(cfg, rep)
+ if embed == nil {
+ return
+ }
+
+ if _, err := session.ChannelMessageSendEmbed(cfg.ChannelID, embed); err != nil {
+ log.Println("[Discord] failed to send update:", err)
+ return
+ }
+ log.Println("[Discord] update message sent.")
+}
+
+func checkMods(cfg *config.Config, client *modrinth.Client) report {
+ cache, err := config.LoadCache()
+ if err != nil {
+ log.Println("[Cache] failed to load:", err)
+ cache = config.Cache{}
+ }
+ newCache := config.Cache{}
+
+ var rep report
+ for _, slug := range cfg.ModrinthMods {
+ versions, err := client.GetVersions(slug)
+ if err != nil {
+ log.Printf("[Modrinth] failed for %s: %v", slug, err)
+ continue
+ }
+
+ latest := modrinth.LatestVersion(versions)
+ compatible := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "")
+
+ cached := cache[slug]
+
+ state := config.ModState{}
+ if compatible != nil {
+ state.Compatible = compatible.ID
+ }
+ if latest != nil {
+ state.Latest = latest.ID
+ }
+ newCache[slug] = state
+
+ switch {
+ case compatible == nil:
+ rep.noCompatible = append(rep.noCompatible, slug)
+ case cached.Compatible == "":
+ rep.newlyCompatible = append(rep.newlyCompatible, slug)
+ case latest != nil && latest.ID != cached.Latest:
+ rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, slug)
+ }
+ }
+
+ if err := config.SaveCache(newCache); err != nil {
+ log.Println("[Cache] failed to save:", err)
+ }
+ return rep
+}
+
+const (
+ colorGood = 0x22C55E // green — everything compatible
+ colorWarn = 0xEF4444 // red — something has no compatible build
+)
+
+func buildEmbed(cfg *config.Config, rep report) *discordgo.MessageEmbed {
+ if len(rep.noCompatible) == 0 && len(rep.newlyCompatible) == 0 && len(rep.stillCompatibleUpdate) == 0 {
+ return nil
+ }
+
+ color := colorGood
+ if len(rep.noCompatible) > 0 {
+ color = colorWarn
+ }
+
+ var fields []*discordgo.MessageEmbedField
+ if f := section("🎉 Now compatible", rep.newlyCompatible); f != nil {
+ fields = append(fields, f)
+ }
+ if f := section("🟢 Updated (still compatible)", rep.stillCompatibleUpdate); f != nil {
+ fields = append(fields, f)
+ }
+ if f := section("❌ No compatible version", rep.noCompatible); f != nil {
+ fields = append(fields, f)
+ }
+
+ return &discordgo.MessageEmbed{
+ Author: &discordgo.MessageEmbedAuthor{
+ Name: "Modrinth compatibility",
+ },
+ Description: fmt.Sprintf("Fabric · Minecraft `%s`", cfg.MinecraftVersion),
+ Color: color,
+ Fields: fields,
+ Timestamp: time.Now().Format(time.RFC3339),
+ Footer: &discordgo.MessageEmbedFooter{
+ Text: fmt.Sprintf("Benson · tracking %d mods", len(cfg.ModrinthMods)),
+ },
+ }
+}
+
+func section(name string, slugs []string) *discordgo.MessageEmbedField {
+ if len(slugs) == 0 {
+ return nil
+ }
+
+ var b strings.Builder
+ for _, slug := range slugs {
+ fmt.Fprintf(&b, "[`%s`](https://modrinth.com/mod/%s)\n", slug, slug)
+ }
+
+ return &discordgo.MessageEmbedField{
+ Name: name,
+ Value: strings.TrimRight(b.String(), "\n"),
+ }
}