dyzur-bot/service.go

308 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 023, 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
}