diff --git a/main.go b/main.go index d131ba0..53d07e7 100644 --- a/main.go +++ b/main.go @@ -1 +1,5 @@ +// Package dyzurbot is a Telegram duty-rota bot. It serves the rota from a +// local cache (see Store) that is periodically actualized from Google Sheets +// (see Syncer/Service). Telegram handlers and reminders are built on top of +// Service and read via Store.Snapshot. package dyzurbot diff --git a/service.go b/service.go new file mode 100644 index 0000000..f611c02 --- /dev/null +++ b/service.go @@ -0,0 +1,75 @@ +package dyzurbot + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/joho/godotenv" +) + +// Config holds the cache/sync settings, sourced from the environment/.env. +type Config struct { + SpreadsheetID string + SheetRange string + CachePath string + SyncInterval time.Duration +} + +// LoadConfig reads configuration from the environment (and .env if present), +// applying defaults for everything except the required spreadsheet ID. +func LoadConfig() (Config, error) { + _ = godotenv.Load() + + cfg := Config{ + SpreadsheetID: os.Getenv("SPREADSHEET_ID"), + SheetRange: getenvDefault("SHEET_RANGE", "Dyżury!A1:F100"), + CachePath: getenvDefault("ROTA_CACHE_PATH", "rota.json"), + } + if cfg.SpreadsheetID == "" { + return Config{}, fmt.Errorf("missing SPREADSHEET_ID in environment/.env") + } + + raw := getenvDefault("SYNC_INTERVAL", "30m") + interval, err := time.ParseDuration(raw) + if err != nil { + return Config{}, fmt.Errorf("invalid SYNC_INTERVAL %q: %w", raw, err) + } + cfg.SyncInterval = interval + + return cfg, nil +} + +func getenvDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// Service ties the Store and Syncer together for the bot to consume. +type Service struct { + Store *Store + Syncer *Syncer + interval time.Duration +} + +// NewService builds the cache + syncer from config. +func NewService(cfg Config) *Service { + store := NewStore(cfg.CachePath) + syncer := NewSyncer(store, SheetFetcher(cfg.SpreadsheetID, cfg.SheetRange)) + return &Service{Store: store, Syncer: syncer, interval: cfg.SyncInterval} +} + +// Start loads the cached rota, runs one best-effort sync (a failure leaves the +// cached/empty data in place — offline-tolerant), then polls in the background +// until ctx is canceled. +func (s *Service) Start(ctx context.Context) error { + if err := s.Store.Load(); err != nil { + return err + } + _ = s.Syncer.Sync(ctx) + go s.Syncer.Run(ctx, s.interval) + return nil +} diff --git a/service_test.go b/service_test.go new file mode 100644 index 0000000..32df135 --- /dev/null +++ b/service_test.go @@ -0,0 +1,45 @@ +package dyzurbot + +import ( + "testing" + "time" +) + +func TestLoadConfigDefaults(t *testing.T) { + t.Setenv("SPREADSHEET_ID", "sheet-123") + t.Setenv("SHEET_RANGE", "") + t.Setenv("ROTA_CACHE_PATH", "") + t.Setenv("SYNC_INTERVAL", "") + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.SpreadsheetID != "sheet-123" { + t.Errorf("SpreadsheetID: got %q", cfg.SpreadsheetID) + } + if cfg.SheetRange != "Dyżury!A1:F100" { + t.Errorf("SheetRange default: got %q", cfg.SheetRange) + } + if cfg.CachePath != "rota.json" { + t.Errorf("CachePath default: got %q", cfg.CachePath) + } + if cfg.SyncInterval != 30*time.Minute { + t.Errorf("SyncInterval default: got %v", cfg.SyncInterval) + } +} + +func TestLoadConfigRequiresSpreadsheetID(t *testing.T) { + t.Setenv("SPREADSHEET_ID", "") + if _, err := LoadConfig(); err == nil { + t.Fatal("expected error when SPREADSHEET_ID is missing") + } +} + +func TestLoadConfigRejectsBadInterval(t *testing.T) { + t.Setenv("SPREADSHEET_ID", "sheet-123") + t.Setenv("SYNC_INTERVAL", "not-a-duration") + if _, err := LoadConfig(); err == nil { + t.Fatal("expected error for unparseable SYNC_INTERVAL") + } +}