package dyzurbot import ( "context" "fmt" "log" "os" "strconv" "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 // Reminder is nil when GROUP_CHAT_ID is unset (reminders disabled). Reminder *ReminderConfig } // reminderTimezone is the fixed zone the reminder schedule is expressed in, so // REMINDER_HOUR means Polish local time regardless of the host/container TZ. const reminderTimezone = "Europe/Warsaw" // 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 reminder, err := loadReminderConfig() if err != nil { return Config{}, err } cfg.Reminder = reminder return cfg, nil } // loadReminderConfig parses the reminder schedule from the environment. It // returns nil (reminders disabled) when GROUP_CHAT_ID is unset, and an error // for any malformed value when it is set. func loadReminderConfig() (*ReminderConfig, error) { raw := os.Getenv("GROUP_CHAT_ID") if raw == "" { return nil, nil } chatID, err := strconv.ParseInt(raw, 10, 64) if err != nil { return nil, fmt.Errorf("invalid GROUP_CHAT_ID %q: %w", raw, err) } weekday, err := parseWeekday(getenvDefault("REMINDER_WEEKDAY", "Monday")) if err != nil { return nil, fmt.Errorf("invalid REMINDER_WEEKDAY: %w", err) } hourRaw := getenvDefault("REMINDER_HOUR", "9") hour, err := strconv.Atoi(hourRaw) if err != nil { return nil, fmt.Errorf("invalid REMINDER_HOUR %q: %w", hourRaw, err) } if hour < 0 || hour > 23 { return nil, fmt.Errorf("REMINDER_HOUR must be 0–23, got %d", hour) } aheadRaw := getenvDefault("DUTY_AHEAD_DAYS", "14") ahead, err := strconv.Atoi(aheadRaw) if err != nil { return nil, fmt.Errorf("invalid DUTY_AHEAD_DAYS %q: %w", aheadRaw, err) } if ahead < 0 { return nil, fmt.Errorf("DUTY_AHEAD_DAYS must be >= 0, got %d", ahead) } loc, err := time.LoadLocation(reminderTimezone) if err != nil { return nil, fmt.Errorf("load timezone %q: %w", reminderTimezone, err) } return &ReminderConfig{ ChatID: chatID, Weekday: weekday, Hour: hour, AheadDays: ahead, Loc: loc, }, 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 }