diff --git a/SESSION_PLAN.md b/SESSION_PLAN.md index e5f663b..e62da7c 100644 --- a/SESSION_PLAN.md +++ b/SESSION_PLAN.md @@ -35,7 +35,7 @@ Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix comman - All adds: monitored=true, search-on-add=true, quality profile + root folder from config - Unit tests with httptest -- [ ] **Session 4 — Command dispatch + main** (`internal/bot/`, `main.go`) +- [x] **Session 4 — Command dispatch + main** (`internal/bot/`, `main.go`) - Parse ` ` (default prefix `!`) - Commands: `movie`, `tv`, `music`, `help` - On match: search → take `[0]` → add → reply with title/year (or error) diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..9d5a797 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,162 @@ +// Package bot wires Matrix command messages to the *arr clients. +// +// Commands take the form " " (e.g. "!movie dune"). The +// dispatcher parses the message, picks the *arr client by command name, +// performs a lookup, and adds the top hit. The reply is posted back into a +// thread rooted at the request event so a busy room stays readable. +package bot + +import ( + "context" + "fmt" + "html" + "log/slog" + "strings" + "time" + + "bellhop/internal/arr" + + "maunium.net/go/mautrix/id" +) + +// Replier sends a threaded reply into roomID under rootEventID. +type Replier interface { + PostThreadedReply(roomID id.RoomID, rootEventID id.EventID, plain, htmlBody string) error +} + +// Services holds the configured *arr clients. Any field may be nil; commands +// that target an unconfigured service reply with "not configured". +type Services struct { + Radarr arr.Client + Sonarr arr.Client + Lidarr arr.Client +} + +// Dispatcher routes parsed commands to services and posts replies. +type Dispatcher struct { + prefix string + services Services + replier Replier + timeout time.Duration +} + +// New builds a Dispatcher. prefix is the leading character(s) on a command +// (e.g. "!"); a zero value defaults to "!". +func New(prefix string, services Services, replier Replier) *Dispatcher { + if prefix == "" { + prefix = "!" + } + return &Dispatcher{ + prefix: prefix, + services: services, + replier: replier, + timeout: 60 * time.Second, + } +} + +// Handle is the matrix.MessageHandler entry point. It parses body, dispatches +// the command, and posts a reply. Non-command messages are ignored silently. +func (d *Dispatcher) Handle(roomID id.RoomID, eventID id.EventID, sender id.UserID, body string) { + cmd, query, ok := parseCommand(d.prefix, body) + if !ok { + return + } + + slog.Info("bot: command received", "room", roomID, "sender", sender, "cmd", cmd, "query", query) + + ctx, cancel := context.WithTimeout(context.Background(), d.timeout) + defer cancel() + + plain, htmlBody := d.dispatch(ctx, cmd, query) + if err := d.replier.PostThreadedReply(roomID, eventID, plain, htmlBody); err != nil { + slog.Error("bot: reply failed", "room", roomID, "err", err) + } +} + +// parseCommand pulls (cmd, query) out of a body like "!movie dune". Returns +// ok=false when the message is not a command for us. +func parseCommand(prefix, body string) (cmd, query string, ok bool) { + body = strings.TrimSpace(body) + if !strings.HasPrefix(body, prefix) { + return "", "", false + } + rest := strings.TrimSpace(body[len(prefix):]) + if rest == "" { + return "", "", false + } + parts := strings.SplitN(rest, " ", 2) + cmd = strings.ToLower(parts[0]) + if len(parts) == 2 { + query = strings.TrimSpace(parts[1]) + } + return cmd, query, true +} + +func (d *Dispatcher) dispatch(ctx context.Context, cmd, query string) (plain, htmlBody string) { + switch cmd { + case "help": + return d.help() + case "movie": + return d.run(ctx, "movie", "Radarr", d.services.Radarr, query) + case "tv": + return d.run(ctx, "tv", "Sonarr", d.services.Sonarr, query) + case "music": + return d.run(ctx, "music", "Lidarr", d.services.Lidarr, query) + default: + msg := fmt.Sprintf("Unknown command %q. Try %shelp.", cmd, d.prefix) + return msg, html.EscapeString(msg) + } +} + +func (d *Dispatcher) run(ctx context.Context, cmd, service string, client arr.Client, query string) (plain, htmlBody string) { + if client == nil { + msg := fmt.Sprintf("%s is not configured.", service) + return msg, html.EscapeString(msg) + } + if query == "" { + msg := fmt.Sprintf("Usage: %s%s ", d.prefix, cmd) + return msg, html.EscapeString(msg) + } + + results, err := client.Search(ctx, query) + if err != nil { + slog.Error("bot: search failed", "service", service, "query", query, "err", err) + msg := fmt.Sprintf("%s search failed: %v", service, err) + return msg, html.EscapeString(msg) + } + if len(results) == 0 { + msg := fmt.Sprintf("No %s results for %q.", service, query) + return msg, html.EscapeString(msg) + } + + top := results[0] + if err := client.Add(ctx, top); err != nil { + slog.Error("bot: add failed", "service", service, "title", top.Title, "err", err) + msg := fmt.Sprintf("%s: failed to add %s: %v", service, formatTitle(top), err) + return msg, html.EscapeString(msg) + } + + plain = fmt.Sprintf("Added to %s: %s", service, formatTitle(top)) + htmlBody = fmt.Sprintf("Added to %s: %s", html.EscapeString(service), html.EscapeString(formatTitle(top))) + return plain, htmlBody +} + +func (d *Dispatcher) help() (string, string) { + lines := []string{ + "Bellhop commands:", + fmt.Sprintf(" %smovie — add the top Radarr hit", d.prefix), + fmt.Sprintf(" %stv — add the top Sonarr hit", d.prefix), + fmt.Sprintf(" %smusic — add the top Lidarr hit", d.prefix), + fmt.Sprintf(" %shelp — show this message", d.prefix), + } + plain := strings.Join(lines, "\n") + htmlBody := "
" + html.EscapeString(plain) + "
" + return plain, htmlBody +} + +func formatTitle(r arr.Result) string { + if r.Year > 0 { + return fmt.Sprintf("%s (%d)", r.Title, r.Year) + } + return r.Title +} diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go new file mode 100644 index 0000000..5168c91 --- /dev/null +++ b/internal/bot/bot_test.go @@ -0,0 +1,218 @@ +package bot + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "bellhop/internal/arr" + + "maunium.net/go/mautrix/id" +) + +type stubClient struct { + results []arr.Result + addErr error + srchErr error + + searched string + added []arr.Result +} + +func (s *stubClient) Search(_ context.Context, term string) ([]arr.Result, error) { + s.searched = term + if s.srchErr != nil { + return nil, s.srchErr + } + return s.results, nil +} + +func (s *stubClient) Add(_ context.Context, r arr.Result) error { + if s.addErr != nil { + return s.addErr + } + s.added = append(s.added, r) + return nil +} + +type stubReplier struct { + mu sync.Mutex + plain string + htmlBody string + calls int +} + +func (r *stubReplier) PostThreadedReply(_ id.RoomID, _ id.EventID, plain, htmlBody string) error { + r.mu.Lock() + defer r.mu.Unlock() + r.calls++ + r.plain = plain + r.htmlBody = htmlBody + return nil +} + +const ( + room = id.RoomID("!room:example.org") + event = id.EventID("$evt") + sender = id.UserID("@alice:example.org") +) + +func TestParseCommand(t *testing.T) { + cases := []struct { + body string + wantCmd, wantQ string + wantOk bool + }{ + {"!movie dune", "movie", "dune", true}, + {" !movie dune part two ", "movie", "dune part two", true}, + {"!HELP", "help", "", true}, + {"hello world", "", "", false}, + {"!", "", "", false}, + {"! ", "", "", false}, + } + for _, c := range cases { + cmd, q, ok := parseCommand("!", c.body) + if cmd != c.wantCmd || q != c.wantQ || ok != c.wantOk { + t.Errorf("parseCommand(%q) = (%q,%q,%v); want (%q,%q,%v)", + c.body, cmd, q, ok, c.wantCmd, c.wantQ, c.wantOk) + } + } +} + +func TestDispatchMovieTopHit(t *testing.T) { + radarr := &stubClient{results: []arr.Result{ + {Title: "Dune", Year: 2021}, + {Title: "Dune", Year: 1984}, + }} + rep := &stubReplier{} + d := New("!", Services{Radarr: radarr}, rep) + + d.Handle(room, event, sender, "!movie dune") + + if radarr.searched != "dune" { + t.Fatalf("search term = %q", radarr.searched) + } + if len(radarr.added) != 1 || radarr.added[0].Year != 2021 { + t.Fatalf("expected top hit added, got %+v", radarr.added) + } + if !strings.Contains(rep.plain, "Dune (2021)") || !strings.Contains(rep.plain, "Radarr") { + t.Fatalf("reply missing expected content: %q", rep.plain) + } +} + +func TestDispatchNoResults(t *testing.T) { + sonarr := &stubClient{} + rep := &stubReplier{} + d := New("!", Services{Sonarr: sonarr}, rep) + + d.Handle(room, event, sender, "!tv obscure show") + + if len(sonarr.added) != 0 { + t.Fatal("should not add when no results") + } + if !strings.Contains(rep.plain, "No Sonarr results") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestDispatchUnconfigured(t *testing.T) { + rep := &stubReplier{} + d := New("!", Services{}, rep) + + d.Handle(room, event, sender, "!music radiohead") + + if !strings.Contains(rep.plain, "Lidarr is not configured") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestDispatchEmptyQuery(t *testing.T) { + radarr := &stubClient{} + rep := &stubReplier{} + d := New("!", Services{Radarr: radarr}, rep) + + d.Handle(room, event, sender, "!movie") + + if radarr.searched != "" { + t.Fatal("should not search with empty query") + } + if !strings.Contains(rep.plain, "Usage:") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestDispatchAddError(t *testing.T) { + radarr := &stubClient{ + results: []arr.Result{{Title: "Dune", Year: 2021}}, + addErr: errors.New("boom"), + } + rep := &stubReplier{} + d := New("!", Services{Radarr: radarr}, rep) + + d.Handle(room, event, sender, "!movie dune") + + if !strings.Contains(rep.plain, "failed to add") || !strings.Contains(rep.plain, "boom") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestDispatchSearchError(t *testing.T) { + radarr := &stubClient{srchErr: errors.New("network down")} + rep := &stubReplier{} + d := New("!", Services{Radarr: radarr}, rep) + + d.Handle(room, event, sender, "!movie dune") + + if !strings.Contains(rep.plain, "search failed") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestDispatchHelp(t *testing.T) { + rep := &stubReplier{} + d := New("!", Services{}, rep) + + d.Handle(room, event, sender, "!help") + + for _, want := range []string{"!movie", "!tv", "!music", "!help"} { + if !strings.Contains(rep.plain, want) { + t.Errorf("help missing %q: %q", want, rep.plain) + } + } +} + +func TestDispatchUnknown(t *testing.T) { + rep := &stubReplier{} + d := New("!", Services{}, rep) + + d.Handle(room, event, sender, "!frobnicate stuff") + + if !strings.Contains(rep.plain, "Unknown command") { + t.Fatalf("unexpected reply: %q", rep.plain) + } +} + +func TestNonCommandIgnored(t *testing.T) { + rep := &stubReplier{} + d := New("!", Services{}, rep) + + d.Handle(room, event, sender, "just chatting") + + if rep.calls != 0 { + t.Fatalf("expected no reply, got %d", rep.calls) + } +} + +func TestCustomPrefix(t *testing.T) { + radarr := &stubClient{results: []arr.Result{{Title: "Dune", Year: 2021}}} + rep := &stubReplier{} + d := New(".bh ", Services{Radarr: radarr}, rep) + + d.Handle(room, event, sender, ".bh movie dune") + + if len(radarr.added) != 1 { + t.Fatalf("expected add, got %d", len(radarr.added)) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b27817a --- /dev/null +++ b/main.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "bellhop/internal/arr" + "bellhop/internal/bot" + "bellhop/internal/config" + "bellhop/internal/matrix" +) + +func main() { + configPath := flag.String("config", "config.yaml", "path to config file") + flag.Parse() + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))) + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("config load failed", "err", err) + os.Exit(1) + } + + services := bot.Services{} + if cfg.Services.Radarr != nil { + services.Radarr = arr.NewRadarr(cfg.Services.Radarr) + } + if cfg.Services.Sonarr != nil { + services.Sonarr = arr.NewSonarr(cfg.Services.Sonarr) + } + if cfg.Services.Lidarr != nil { + services.Lidarr = arr.NewLidarr(cfg.Services.Lidarr) + } + + mx, err := matrix.New(cfg.Matrix) + if err != nil { + slog.Error("matrix init failed", "err", err) + os.Exit(1) + } + + dispatcher := bot.New(cfg.Matrix.CommandPrefix, services, mx) + mx.SetMessageHandler(dispatcher.Handle) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mx.Start(ctx) + slog.Info("bellhop started", "allowed_rooms", len(cfg.Matrix.AllowedRooms)) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + slog.Info("shutting down") + cancel() + mx.Stop() +}