hachem.wtf
aboutsummaryrefslogtreecommitdiff
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
parent25bdd381d6dcf8581b1391c1c1fa22c108913ccb (diff)
add: reimplement discord bot
-rw-r--r--config/api.example.json3
-rw-r--r--config/config.example.json19
-rw-r--r--go.mod10
-rw-r--r--go.sum41
-rw-r--r--internal/bot/bot.go206
-rw-r--r--internal/cli/cli.go4
-rw-r--r--internal/config/cache.go63
-rw-r--r--internal/setup/setup.go224
-rw-r--r--main.go19
9 files changed, 561 insertions, 28 deletions
diff --git a/config/api.example.json b/config/api.example.json
deleted file mode 100644
index df9b602..0000000
--- a/config/api.example.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "token": "your-discord-bot-token-here"
-}
diff --git a/config/config.example.json b/config/config.example.json
deleted file mode 100644
index 647f2e7..0000000
--- a/config/config.example.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "channel_id": "YOUR_CHANNEL_ID_HERE",
- "check_interval_minutes": 60,
- "minecraft_version": "1.21.10",
- "fabric_loader_version": "0.17.3",
- "loader_meta_version": "1.1.0",
- "java_path": null,
-
- "server_location": null,
- "backup_location": null,
- "merge_pre_command": null,
- "merge_post_command": null,
-
- "modrinth_mods": [
- "fabric-api",
- "lithium",
- "simple-voice-chat"
- ]
-}
diff --git a/go.mod b/go.mod
index 22f3897..bae9ab1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,31 +3,39 @@ module github.com/hachem-wtf/benson
go 1.26.6
require (
+ github.com/bwmarrin/discordgo v0.29.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+ github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
+ github.com/gorilla/websocket v1.4.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.38.0 // indirect
- golang.org/x/text v0.3.8 // indirect
+ golang.org/x/text v0.23.0 // indirect
)
diff --git a/go.sum b/go.sum
index e92ed90..d6e3171 100644
--- a/go.sum
+++ b/go.sum
@@ -1,29 +1,57 @@
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
+github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
+github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
+github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
+github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
+github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
+github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
+github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
+github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -32,6 +60,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -42,11 +72,18 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
+golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
+golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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)
+}
diff --git a/main.go b/main.go
index dfe0d7f..b736194 100644
--- a/main.go
+++ b/main.go
@@ -6,6 +6,7 @@ import (
"github.com/hachem-wtf/benson/internal/bot"
"github.com/hachem-wtf/benson/internal/cli"
+ "github.com/hachem-wtf/benson/internal/setup"
)
const version = "2.0"
@@ -15,6 +16,8 @@ func usage() {
Usage:
benson Start the interactive server-manager CLI
+ benson setup Configure the server manager
+ benson setup bot Configure the Discord bot
benson bot Run the Discord bot
benson version Print the version
benson help Show this help
@@ -35,6 +38,22 @@ func run(args []string) int {
}
switch args[0] {
+ case "setup":
+ var err error
+ switch {
+ case len(args) == 1:
+ err = setup.Run()
+ case args[1] == "bot":
+ err = setup.RunBot()
+ default:
+ fmt.Fprintf(os.Stderr, "benson: unknown setup target %q (try `benson setup` or `benson setup bot`)\n", args[1])
+ return 2
+ }
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "error:", err)
+ return 1
+ }
+ return 0
case "bot":
if err := bot.Run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)