308 lines
11 KiB
Go
308 lines
11 KiB
Go
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()
|
||
rng := resolveRange(readRange, time.Now())
|
||
rows, err := ReadSheet(ctx, spreadsheetID, rng)
|
||
if err != nil {
|
||
return Rota{}, err
|
||
}
|
||
return stampTabs(ParseRota(rows), rng), nil
|
||
}
|
||
}
|
||
|
||
// stampTabs records on each week the tab its rows were fetched from, so an
|
||
// assignment written later (possibly after the clock-resolved tab has rolled to
|
||
// the next year while the cache still holds this fetch) targets the tab the
|
||
// week's Row is valid on.
|
||
func stampTabs(rota Rota, resolvedRange string) Rota {
|
||
tab, _, _ := strings.Cut(resolvedRange, "!")
|
||
for i := range rota.Weeks {
|
||
rota.Weeks[i].Tab = tab
|
||
}
|
||
return rota
|
||
}
|
||
|
||
// 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 := assignRangeFor(week, sheetRange, time.Now())
|
||
return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}})
|
||
}
|
||
}
|
||
|
||
// assignRangeFor builds the A1 range for a week's assignment cells (Osoba 1/2
|
||
// are columns B and C). An explicit sheetRange keeps its tab verbatim, matching
|
||
// reads. Otherwise the week's fetch-time tab is preferred over clock resolution:
|
||
// around New Year the cache can hold the old tab's rows (the empty-rota guard
|
||
// keeps them while the next year's tab is unfilled) after resolveRange has moved
|
||
// to the new tab, and writing an old-tab Row onto the new tab would corrupt it.
|
||
// A week without a recorded tab (cache from an older version) falls back to the
|
||
// clock, preserving prior behavior.
|
||
func assignRangeFor(week DutyWeek, sheetRange string, now time.Time) string {
|
||
tab := week.Tab
|
||
if sheetRange != "" || tab == "" {
|
||
tab, _, _ = strings.Cut(resolveRange(sheetRange, now), "!")
|
||
}
|
||
return fmt.Sprintf("%s!B%d:C%d", tab, week.Row, week.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. The 200-row cap bounds the
|
||
// fetch; rows past it are silently dropped, so it must stay comfortably above
|
||
// one year of weekly rows (~53) plus roster entries.
|
||
func resolveRange(explicit string, now time.Time) string {
|
||
if explicit != "" {
|
||
return explicit
|
||
}
|
||
return fmt.Sprintf("%d!A1:G200", 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
|
||
}
|