package bot 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 { 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 _, mod := range cfg.ModrinthMods { if mod.Pinned() { continue } versions, err := client.GetVersions(mod.Slug) if err != nil { log.Printf("[Modrinth] failed for %s: %v", mod.Slug, err) continue } latest := modrinth.LatestVersion(versions) compatible := modrinth.CompatibleVersion(versions, cfg.MinecraftVersion, "") cached := cache[mod.Slug] state := config.ModState{} if compatible != nil { state.Compatible = compatible.ID } if latest != nil { state.Latest = latest.ID } newCache[mod.Slug] = state switch { case compatible == nil: rep.noCompatible = append(rep.noCompatible, mod.Slug) case cached.Compatible == "": rep.newlyCompatible = append(rep.newlyCompatible, mod.Slug) case latest != nil && latest.ID != cached.Latest: rep.stillCompatibleUpdate = append(rep.stillCompatibleUpdate, mod.Slug) } } if err := config.SaveCache(newCache); err != nil { log.Println("[Cache] failed to save:", err) } return rep } const ( colorGood = 0x22C55E colorWarn = 0xEF4444 ) 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"), } }