package dyzurbot import ( "context" "fmt" "log" "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() // Default range targets the current year's tab (sheets are named per year, // e.g. "2026"), matching the spreadsheet layout. defaultRange := fmt.Sprintf("%d!A1:F100", time.Now().Year()) cfg := Config{ SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"), SheetRange: getenvDefault("SHEET_RANGE", defaultRange), CachePath: getenvDefault("ROTA_CACHE_PATH", "rota.json"), } if cfg.SpreadsheetID == "" { return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_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) } if interval <= 0 { return Config{}, fmt.Errorf("SYNC_INTERVAL must be positive, got %v", interval) } cfg.SyncInterval = interval return cfg, nil } func getenvDefault(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // FetchFunc retrieves and parses the current rota from upstream (the Sheet). type FetchFunc func(ctx context.Context) (Rota, error) // Syncer refreshes a Store from an upstream fetch. type Syncer struct { store *Store fetch FetchFunc now func() time.Time } // NewSyncer wires a Store to a fetch function. func NewSyncer(store *Store, fetch FetchFunc) *Syncer { return &Syncer{store: store, fetch: fetch, now: time.Now} } // Sync fetches once. On success it replaces the cache; on failure it logs a // warning, leaves the last good cache in place, and returns the error so a // caller (e.g. a manual /sync command) can report it. func (sy *Syncer) Sync(ctx context.Context) error { rota, err := sy.fetch(ctx) if err != nil { log.Printf("sync: fetch failed, keeping cached data: %v", err) return err } return sy.store.Replace(rota, sy.now()) } // Run polls Sync on the given interval until ctx is canceled. func (sy *Syncer) Run(ctx context.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: _ = sy.Sync(ctx) } } } // SheetFetcher builds a FetchFunc that reads the rota from Google Sheets. func SheetFetcher(spreadsheetID, readRange string) FetchFunc { return func(ctx context.Context) (Rota, error) { rows, err := ReadSheet(ctx, spreadsheetID, readRange) if err != nil { return Rota{}, err } return ParseRota(rows), nil } } // 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 in the background // (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 } go func() { _ = s.Syncer.Sync(ctx) }() go s.Syncer.Run(ctx, s.interval) return nil }