From 1a6aa73e484ac2e9dc1c9249339948e7a5c0b65f Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 12:30:29 +0200 Subject: [PATCH] feat: serve and tui commands with systemd units --- cmd/serve.go | 119 ++++++++++++++++++++++++++++++++++ cmd/tui.go | 42 ++++++++++++ systemd/reddit-reader.service | 12 ++++ systemd/reddit-reader.socket | 8 +++ 4 files changed, 181 insertions(+) create mode 100644 cmd/serve.go create mode 100644 cmd/tui.go create mode 100644 systemd/reddit-reader.service create mode 100644 systemd/reddit-reader.socket diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..c429cfb --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/spf13/cobra" + "google.golang.org/grpc" + + "somegit.dev/vikingowl/reddit-reader/internal/config" + "somegit.dev/vikingowl/reddit-reader/internal/domain" + grpcserver "somegit.dev/vikingowl/reddit-reader/internal/grpc/server" + "somegit.dev/vikingowl/reddit-reader/internal/llm" + "somegit.dev/vikingowl/reddit-reader/internal/monitor" + redditpkg "somegit.dev/vikingowl/reddit-reader/internal/reddit" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Start the monitor daemon and gRPC server", + RunE: runServe, +} + +func init() { + rootCmd.AddCommand(serveCmd) +} + +func runServe(_ *cobra.Command, _ []string) error { + cfg, err := config.LoadFromFile(config.DefaultPath()) + if err != nil { + return fmt.Errorf("load config (run 'reddit-reader setup' first): %w", err) + } + cfg.ApplyEnvOverrides() + + configDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("resolve config dir: %w", err) + } + dbPath := filepath.Join(configDir, "reddit-reader", "reddit-reader.db") + + st, err := store.Open(dbPath) + if err != nil { + return fmt.Errorf("open store: %w", err) + } + + redditClient, err := redditpkg.NewClient( + cfg.Reddit.ClientID, + cfg.Reddit.ClientSecret, + cfg.Reddit.Username, + cfg.Reddit.Password, + ) + if err != nil { + return fmt.Errorf("create reddit client: %w", err) + } + + var llmClient llm.Summarizer + if cfg.LLM.Backend == "mistral" { + llmClient = llm.NewMistralClient(cfg.LLM.APIKey, cfg.LLM.Model) + } else { + llmClient = llm.NewOpenAIClient(cfg.LLM.Endpoint, cfg.LLM.Model) + } + + socketPath := cfg.GRPC.Socket + if socketPath == "" { + socketPath = config.DefaultSocket() + } + + lis, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("listen on socket %s: %w", socketPath, err) + } + + grpcSrv := grpc.NewServer() + srv := grpcserver.Register(grpcSrv, st, time.Now()) + + go func() { + slog.Info("gRPC server listening", "socket", socketPath) + if serveErr := grpcSrv.Serve(lis); serveErr != nil { + slog.Error("gRPC serve error", "err", serveErr) + } + }() + + monitorCfg := monitor.Config{ + PollInterval: cfg.Monitor.PollInterval.Duration, + RelevanceThreshold: cfg.LLM.RelevanceThreshold, + MaxPostsPerPoll: cfg.Monitor.MaxPostsPerPoll, + Interests: domain.Interests{ + Description: cfg.Interests.Description, + }, + } + + mon := monitor.New(st, redditClient, llmClient, monitorCfg) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + slog.Info("monitor starting") + runErr := mon.Run(ctx, func(posts []domain.Post) { + srv.Notify(posts) + }) + + grpcSrv.GracefulStop() + if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { + slog.Warn("failed to remove socket", "path", socketPath, "err", removeErr) + } + + if runErr != nil && runErr != context.Canceled { + return runErr + } + return nil +} diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..8843ff9 --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "somegit.dev/vikingowl/reddit-reader/internal/config" + grpcclient "somegit.dev/vikingowl/reddit-reader/internal/grpc/client" + "somegit.dev/vikingowl/reddit-reader/internal/tui" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Connect to the daemon and launch the interactive interface", + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(_ *cobra.Command, _ []string) error { + cfg, err := config.LoadFromFile(config.DefaultPath()) + if err != nil { + return fmt.Errorf("load config (run 'reddit-reader setup' first): %w", err) + } + cfg.ApplyEnvOverrides() + + socketPath := cfg.GRPC.Socket + if socketPath == "" { + socketPath = config.DefaultSocket() + } + + client, err := grpcclient.Dial(socketPath) + if err != nil { + return fmt.Errorf("connect to daemon (is 'reddit-reader serve' running?): %w", err) + } + defer client.Close() + + return tui.Run(client) +} diff --git a/systemd/reddit-reader.service b/systemd/reddit-reader.service new file mode 100644 index 0000000..1bca919 --- /dev/null +++ b/systemd/reddit-reader.service @@ -0,0 +1,12 @@ +[Unit] +Description=Reddit Reader Monitor +After=network-online.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/reddit-reader serve +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/systemd/reddit-reader.socket b/systemd/reddit-reader.socket new file mode 100644 index 0000000..45c8bf3 --- /dev/null +++ b/systemd/reddit-reader.socket @@ -0,0 +1,8 @@ +[Unit] +Description=Reddit Reader Socket + +[Socket] +ListenStream=%t/reddit-reader.sock + +[Install] +WantedBy=sockets.target