206 lines
5.8 KiB
Go
206 lines
5.8 KiB
Go
package dyzurbot
|
||
|
||
import (
|
||
"context"
|
||
"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()
|
||
|
||
// 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:G100", 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 {
|
||
slog.Warn("sync fetch failed, keeping cached data", "err", 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
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, 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
|
||
}
|