diff options
| author | hachem <im@hachem.wtf> | 2026-08-29 10:44:22 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-29 10:44:22 +0200 |
| commit | af4a6f4c5e9a3e3a501deac71d92bbbcd12ef81f (patch) | |
| tree | 4882c0dd5e7e252e6216e00cc5cba0e6c2bfd533 /internal | |
| parent | 25bdd381d6dcf8581b1391c1c1fa22c108913ccb (diff) | |
add: reimplement discord bot
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/bot/bot.go | 206 | ||||
| -rw-r--r-- | internal/cli/cli.go | 4 | ||||
| -rw-r--r-- | internal/config/cache.go | 63 | ||||
| -rw-r--r-- | internal/setup/setup.go | 224 |
4 files changed, 494 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"), + } } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d019730..7e7705e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "os" "strings" "github.com/charmbracelet/bubbles/spinner" @@ -41,6 +42,9 @@ type model struct { func Run(version string) 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) } diff --git a/internal/config/cache.go b/internal/config/cache.go new file mode 100644 index 0000000..825af7f --- /dev/null +++ b/internal/config/cache.go @@ -0,0 +1,63 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type API struct { + Token string `json:"token"` +} + +func LoadAPI() (*API, error) { + data, err := os.ReadFile(APIPath()) + if err != nil { + return nil, err + } + + var a API + if err := json.Unmarshal(data, &a); err != nil { + return nil, err + } + return &a, nil +} + +type ModState struct { + Compatible string `json:"compatible"` + Latest string `json:"latest"` +} + +type Cache map[string]ModState + +func CachePath() string { + return filepath.Join("cache", "mod_versions.json") +} + +func LoadCache() (Cache, error) { + data, err := os.ReadFile(CachePath()) + if err != nil { + if os.IsNotExist(err) { + return Cache{}, nil + } + return nil, err + } + + cache := Cache{} + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + return cache, nil +} + +func SaveCache(cache Cache) error { + if err := os.MkdirAll(filepath.Dir(CachePath()), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return err + } + return os.WriteFile(CachePath(), data, 0o644) +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..eafaaa0 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,224 @@ +package setup + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" + + "github.com/hachem-wtf/benson/internal/config" +) + +// Run is the server-manager wizard. It configures Minecraft/Fabric versions, +// paths, merge hooks and mods — everything except the Discord bot. +func Run() error { + cfg := loadConfig() + + var ( + mcVersion = cfg.MinecraftVersion + fabricLoader = cfg.FabricLoaderVersion + loaderMeta = cfg.LoaderMetaVersion + javaPath = cfg.JavaPath + serverLoc = cfg.ServerLocation + backupLoc = cfg.BackupLocation + preCmd = cfg.MergePreCommand + postCmd = cfg.MergePostCommand + modsText = strings.Join(cfg.ModrinthMods, "\n") + confirm = true + ) + + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Benson setup"). + Description("Configure the server manager.\nThe Discord bot is optional — run `benson setup bot` for that."), + ), + huh.NewGroup( + huh.NewInput().Title("Minecraft version").Placeholder("1.21.10").Value(&mcVersion).Validate(required("Minecraft version")), + huh.NewInput().Title("Fabric loader version").Placeholder("0.17.3").Value(&fabricLoader).Validate(required("Fabric loader version")), + huh.NewInput().Title("Loader / installer version").Placeholder("1.1.0").Value(&loaderMeta).Validate(required("loader version")), + huh.NewInput().Title("Java path").Description("Leave blank to use 'java' from PATH").Value(&javaPath), + ).Title("Minecraft / Fabric"), + huh.NewGroup( + huh.NewInput().Title("Server location").Description("Live server directory, merged into by 'merge'").Value(&serverLoc), + huh.NewInput().Title("Backup location").Description("Where server backups are written").Value(&backupLoc), + huh.NewInput().Title("Pre-merge command").Description("Optional shell command run before merging").Value(&preCmd), + huh.NewInput().Title("Post-merge command").Description("Optional shell command run after merging").Value(&postCmd), + ).Title("Paths & hooks (optional)"), + huh.NewGroup( + huh.NewText().Title("Modrinth mods").Description("One mod slug per line").Lines(8).Value(&modsText), + ).Title("Mods"), + huh.NewGroup( + huh.NewConfirm().Title("Write config/config.json?").Affirmative("Write").Negative("Cancel").Value(&confirm), + ), + ) + + if done, err := runForm(form); err != nil || !done { + return err + } + + cfg.MinecraftVersion = strings.TrimSpace(mcVersion) + cfg.FabricLoaderVersion = strings.TrimSpace(fabricLoader) + cfg.LoaderMetaVersion = strings.TrimSpace(loaderMeta) + cfg.JavaPath = strings.TrimSpace(javaPath) + cfg.ServerLocation = strings.TrimSpace(serverLoc) + cfg.BackupLocation = strings.TrimSpace(backupLoc) + cfg.MergePreCommand = strings.TrimSpace(preCmd) + cfg.MergePostCommand = strings.TrimSpace(postCmd) + cfg.ModrinthMods = parseMods(modsText) + + if err := writeConfigFile(cfg); err != nil { + return err + } + + fmt.Printf("\n %s Wrote %s\n\n Run `benson` for the CLI, or `benson setup bot` to set up the Discord bot.\n\n", + check(), config.Path()) + return nil +} + +// RunBot is the Discord-bot wizard. It configures the bot token, channel and +// check interval, leaving the server-manager settings untouched. +func RunBot() error { + cfg := loadConfig() + api := loadAPI() + + interval := cfg.CheckIntervalMinutes + if interval <= 0 { + interval = 60 + } + + var ( + token = api.Token + channelID = cfg.ChannelID + intervalStr = strconv.Itoa(interval) + confirm = true + ) + + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("Benson bot setup"). + Description("Configure the Discord bot that posts Modrinth compatibility reports."), + ), + huh.NewGroup( + huh.NewInput().Title("Discord bot token").Description("Developer Portal → Bot → Reset/Copy Token").Password(true).Value(&token).Validate(required("token")), + huh.NewInput().Title("Channel ID").Description("Channel where reports are posted").Value(&channelID).Validate(required("channel ID")), + huh.NewInput().Title("Check interval (minutes)").Value(&intervalStr).Validate(positiveInt), + ).Title("Discord"), + huh.NewGroup( + huh.NewConfirm().Title("Write config/config.json and config/api.json?").Affirmative("Write").Negative("Cancel").Value(&confirm), + ), + ) + + if done, err := runForm(form); err != nil || !done { + return err + } + + minutes, _ := strconv.Atoi(strings.TrimSpace(intervalStr)) + cfg.ChannelID = strings.TrimSpace(channelID) + cfg.CheckIntervalMinutes = minutes + api.Token = strings.TrimSpace(token) + + if err := writeConfigFile(cfg); err != nil { + return err + } + if err := writeAPIFile(api); err != nil { + return err + } + + fmt.Printf("\n %s Wrote %s\n %s Wrote %s\n\n Run `benson bot` to start the bot.\n\n", + check(), config.Path(), check(), config.APIPath()) + return nil +} + +func loadConfig() *config.Config { + if cfg, err := config.Load(); err == nil { + return cfg + } + return &config.Config{} +} + +func loadAPI() *config.API { + if api, err := config.LoadAPI(); err == nil { + return api + } + return &config.API{} +} + +// runForm runs a themed form, reporting whether the user confirmed the write. +func runForm(form *huh.Form) (bool, error) { + if err := form.WithTheme(emberTheme()).Run(); err != nil { + if errors.Is(err, huh.ErrUserAborted) { + fmt.Println("Setup cancelled.") + return false, nil + } + return false, err + } + return true, nil +} + +func required(label string) func(string) error { + return func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("%s is required", label) + } + return nil + } +} + +func positiveInt(s string) error { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n <= 0 { + return errors.New("must be a positive number") + } + return nil +} + +func parseMods(text string) []string { + mods := []string{} + for _, line := range strings.Split(text, "\n") { + if slug := strings.TrimSpace(line); slug != "" { + mods = append(mods, slug) + } + } + return mods +} + +func check() string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Render("✓") +} + +func emberTheme() *huh.Theme { + theme := huh.ThemeCharm() + ember := lipgloss.Color("#F97316") + theme.Focused.Title = theme.Focused.Title.Foreground(ember).Bold(true) + theme.Focused.Base = theme.Focused.Base.BorderForeground(ember) + return theme +} + +func writeConfigFile(cfg *config.Config) error { + if err := os.MkdirAll(config.Dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(config.Path(), append(data, '\n'), 0o644) +} + +func writeAPIFile(api *config.API) error { + if err := os.MkdirAll(config.Dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(api, "", " ") + if err != nil { + return err + } + return os.WriteFile(config.APIPath(), append(data, '\n'), 0o600) +} |
