263 lines
8.5 KiB
Go
263 lines
8.5 KiB
Go
package dyzurbot
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"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()
|
||
|
||
// 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")
|
||
}
|
||
|
||
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)
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|