package dyzurbot import ( "context" "errors" "fmt" "log/slog" "os" "strconv" "strings" "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() // SheetRange is left empty when unset so the fetcher can resolve the // current-year tab at fetch time (see resolveRange). Freezing the default // here would pin a long-running process to its start year and silently miss // the next year's tab after the New Year rollover. cfg := Config{ SpreadsheetID: os.Getenv("GOOGLE_SHEETS_ID"), SheetRange: os.Getenv("SHEET_RANGE"), CachePath: getenvDefault("ROTA_CACHE_PATH", "rota.json"), } if cfg.SpreadsheetID == "" { return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_ID in environment/.env") } // Fail fast at startup if the service-account key is missing or unreadable, // rather than on the first sheet fetch minutes later. ReadSheet consumes the // path directly from the environment. credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE") if credPath == "" { return Config{}, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env") } if _, err := os.Stat(credPath); err != nil { // #nosec G703 -- credPath is operator-controlled config (env/.env) return Config{}, fmt.Errorf("GOOGLE_CREDENTIALS_FILE %q: %w", credPath, err) } 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) } 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, 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) // errEmptyRota signals that a fetch succeeded but returned no duty weeks, so the // cache was deliberately left untouched rather than clobbered with empty data. var errEmptyRota = errors.New("fetched rota has no duty weeks; keeping cached data") // defaultStaleAfter is how long the cache may go without a successful sync // before Sync escalates from a per-failure warning to a stale-cache error. At // the default 30m sync interval this is ~12 consecutive failures. const defaultStaleAfter = 6 * time.Hour // Syncer refreshes a Store from an upstream fetch. type Syncer struct { store *Store fetch FetchFunc now func() time.Time staleAfter time.Duration } // NewSyncer wires a Store to a fetch function. func NewSyncer(store *Store, fetch FetchFunc) *Syncer { return &Syncer{store: store, fetch: fetch, now: time.Now, staleAfter: defaultStaleAfter} } // Sync fetches once. On success with a non-empty rota it replaces the cache. // When the fetch fails — or succeeds but returns zero weeks (e.g. a freshly // created, not-yet-filled year tab) — it leaves the last-good cache in place, // warns, and returns an error so a caller (e.g. a manual /sync) can report it. // A guard against the empty case matters because the range targets the current // year's tab, which around New Year may exist but be empty; replacing the cache // with it would silently wipe the schedule. func (sy *Syncer) Sync(ctx context.Context) error { rota, err := sy.fetch(ctx) if err != nil { slog.Warn("sync fetch failed, keeping cached data", "err", err) sy.warnIfStale("fetch error") return err } if len(rota.Weeks) == 0 { slog.Warn("sync returned no duty weeks, keeping cached data") sy.warnIfStale("empty rota") return errEmptyRota } return sy.store.Replace(rota, sy.now()) } // warnIfStale emits a loud error when the cache has gone too long without a // successful sync, turning a silently-stale cache (which is not a crash, so // process-level supervision won't catch it) into a visible log signal. func (sy *Syncer) warnIfStale(reason string) { last := sy.store.LastSynced() if age, stale := staleAge(last, sy.now(), sy.staleAfter); stale { slog.Error("rota cache is stale; sync has not succeeded recently", "age", age.Round(time.Minute), "last_synced", last.Format(time.RFC3339), "reason", reason) } } // staleAge reports the cache's age and whether it exceeds threshold. A // never-synced cache (zero last) is not "stale" — there is no good data to be // behind — so it reports false. func staleAge(last, now time.Time, threshold time.Duration) (time.Duration, bool) { if last.IsZero() { return 0, false } age := now.Sub(last) return age, age >= threshold } // 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) } } } // fetchTimeout bounds a single Sheets fetch so a hung HTTP call can't block a // caller (e.g. a /sync command) indefinitely — it would otherwise wait until // the parent context is canceled at shutdown. const fetchTimeout = 30 * time.Second // SheetFetcher builds a FetchFunc that reads the rota from Google Sheets. When // readRange is empty the range is resolved per fetch (see resolveRange), so a // long-running process follows the current-year tab across a New Year rollover // instead of pinning the year it started in. func SheetFetcher(spreadsheetID, readRange string) FetchFunc { return func(ctx context.Context) (Rota, error) { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() rows, err := ReadSheet(ctx, spreadsheetID, resolveRange(readRange, time.Now())) if err != nil { return Rota{}, err } return ParseRota(rows), nil } } // SheetAssigner returns an AssignFunc that writes a week's two names into its // B:C cells on the same tab the rota is read from. sheetRange mirrors the // fetcher's range so the tab (year or explicit) matches. func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc { return func(ctx context.Context, week DutyWeek, p1, p2 string) error { ctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() rng := assignRange(sheetRange, time.Now(), week.Row) return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}}) } } // assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are // columns B and C). It reuses the tab from resolveRange so the year rollover and // any explicit range are honored identically to reads. func assignRange(sheetRange string, now time.Time, row int) string { tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!") return fmt.Sprintf("%s!B%d:C%d", tab, row, row) } // resolveRange returns the explicit range when set, otherwise the default that // targets the current year's tab (sheets are named per year, e.g. "2026"). // Resolving against now (not a startup-captured value) is what lets the range // advance to the next year's tab automatically. func resolveRange(explicit string, now time.Time) string { if explicit != "" { return explicit } return fmt.Sprintf("%d!A1:G100", now.Year()) } // 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 }