dyzur-bot/service.go

199 lines
5.5 KiB
Go
Raw 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"
"fmt"
"log"
"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:F100", 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 023, 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 {
log.Printf("sync: fetch failed, keeping cached data: %v", 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)
}
}
}
// SheetFetcher builds a FetchFunc that reads the rota from Google Sheets.
func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
return func(ctx context.Context) (Rota, error) {
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
}