From 881bd5be036d5e42c547aff00e8e059ab9ac5ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Thu, 18 Jun 2026 02:36:06 +0200 Subject: [PATCH] feat: add weekly duty reminder scheduler Add Reminder that posts a weekly cleaning-duty reminder to a Telegram group on a configurable schedule (REMINDER_WEEKDAY/REMINDER_HOUR), in Europe/Warsaw time, announcing the duty DUTY_AHEAD_DAYS out. Reminders are disabled unless GROUP_CHAT_ID is set. Wire ReminderConfig parsing into LoadConfig with validation, document the commands and reminder config in README, and cover config loading with tests. --- README.md | 23 +++++++-- scheduler.go | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ service.go | 63 +++++++++++++++++++++++ service_test.go | 79 ++++++++++++++++++++++++++++ 4 files changed, 294 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4562508..5506c78 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,29 @@ Backend read the schedule from a Google sheet, store it in local JSON file. ## Running -Requires `GOOGLE_API_KEY` and `GOOGLE_SHEETS_ID` in the environment or a `.env` -file (see `service.go`/`sheet.go` for optional `SHEET_RANGE`, `ROTA_CACHE_PATH`, -`SYNC_INTERVAL`). Then: +Requires `GOOGLE_API_KEY`, `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the +environment or a `.env` file (see `service.go`/`sheet.go` for optional +`SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then: ```sh go run ./cmd/dyzur-bot ``` The service loads the cached rota, syncs from Google Sheets in the background on -`SYNC_INTERVAL` (default 30m), and runs until interrupted (Ctrl-C / SIGTERM). +`SYNC_INTERVAL` (default 30m), and runs the Telegram bot via long polling until +interrupted (Ctrl-C / SIGTERM). + +### Commands + +- `/kto_sprzata` — who is on cleaning duty this week. +- `/sync` — refresh the rota from Google Sheets now. + +### Reminders + +If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat. +Schedule (all optional, with defaults): `REMINDER_WEEKDAY` (default `Monday`), +`REMINDER_HOUR` (default `9`, 0–23), `DUTY_AHEAD_DAYS` (default `14`). The hour +is interpreted in Europe/Warsaw time. The reminder announces who is on duty +`DUTY_AHEAD_DAYS` from the fire time — e.g. every Monday 09:00, who cleans two +weeks out. Leave `GROUP_CHAT_ID` unset to disable reminders. diff --git a/scheduler.go b/scheduler.go index d131ba0..7cec432 100644 --- a/scheduler.go +++ b/scheduler.go @@ -1 +1,134 @@ package dyzurbot + +import ( + "context" + "fmt" + "log" + "strings" + "time" +) + +// ReminderConfig is the resolved schedule for the weekly duty reminder. +type ReminderConfig struct { + ChatID int64 // Telegram chat to post to + Weekday time.Weekday // day of week to fire + Hour int // hour of day (0–23) to fire + AheadDays int // announce the duty this many days from the fire time + Loc *time.Location // timezone the schedule is expressed in +} + +// SendFunc sends a reminder message. It abstracts the Telegram send so Reminder +// can be tested without a live bot (see BotSendFunc for the real wiring). +type SendFunc func(ctx context.Context, text string) error + +// Reminder posts a duty reminder to a chat on a weekly schedule. +type Reminder struct { + store *Store + send SendFunc + cfg ReminderConfig + now func() time.Time +} + +// NewReminder wires a reminder to the rota cache and a send function. +func NewReminder(store *Store, send SendFunc, cfg ReminderConfig) *Reminder { + return &Reminder{store: store, send: send, cfg: cfg, now: time.Now} +} + +// Run fires the reminder on schedule until ctx is canceled. Each iteration +// recomputes the next fire time, sleeps until then, and on fire looks up the +// duty AheadDays out and posts it (skipping weeks with no data). Send failures +// are logged and the loop continues. +func (r *Reminder) Run(ctx context.Context) { + for { + now := r.now() + next := nextFire(now, r.cfg.Weekday, r.cfg.Hour, r.cfg.Loc) + timer := time.NewTimer(next.Sub(now)) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + r.fire(ctx) + } + } +} + +// fire performs one reminder: snapshot the cache, format, and send. +func (r *Reminder) fire(ctx context.Context) { + rota, _ := r.store.Snapshot() + // Compute the target in the schedule's zone so the calendar date matches + // the wall-clock fire day (consistent with nextFire); reading it in + // server-local time could land on the prior day on a UTC host. + target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays) + text, ok := reminderText(rota, target) + if !ok { + log.Printf("reminder: no duty week for %s, skipping", target.Format("2006-01-02")) + return + } + if err := r.send(ctx, text); err != nil { + log.Printf("reminder: send failed: %v", err) + } +} + +// nextFire returns the next instant strictly after now that falls on weekday at +// hour:00:00 in loc. It is recomputed from now on every scheduler iteration, so +// the wall-clock hour stays correct across DST transitions (the result is +// resolved in loc rather than derived by +7d UTC arithmetic) and a fire missed +// while the process was down is skipped rather than replayed. +func nextFire(now time.Time, weekday time.Weekday, hour int, loc *time.Location) time.Time { + n := now.In(loc) + daysAhead := (int(weekday) - int(n.Weekday()) + 7) % 7 + candidate := time.Date(n.Year(), n.Month(), n.Day(), hour, 0, 0, 0, loc). + AddDate(0, 0, daysAhead) + if !candidate.After(now) { + candidate = candidate.AddDate(0, 0, 7) + } + return candidate +} + +// reminderText renders the Polish reminder for the duty week containing target. +// ok is false when no week matches, signaling the caller to skip sending. +func reminderText(rota Rota, target time.Time) (string, bool) { + week, ok := currentDuty(rota, target) + if !ok { + return "", false + } + + var people []string + if week.Person1 != "" { + people = append(people, week.Person1) + } + if week.Person2 != "" { + people = append(people, week.Person2) + } + + var body string + switch len(people) { + case 0: + body = "dyżur jest nieobsadzony" + case 1: + body = "dyżur sprząta " + people[0] + default: + body = "dyżur sprzątają " + strings.Join(people, " i ") + } + + return "Przypomnienie: w tygodniu od " + + week.WeekStart.Format("2006-01-02") + " " + body + ".", true +} + +// parseWeekday maps an English weekday name (case-insensitive) to time.Weekday. +func parseWeekday(s string) (time.Weekday, error) { + days := map[string]time.Weekday{ + "sunday": time.Sunday, + "monday": time.Monday, + "tuesday": time.Tuesday, + "wednesday": time.Wednesday, + "thursday": time.Thursday, + "friday": time.Friday, + "saturday": time.Saturday, + } + if w, ok := days[strings.ToLower(s)]; ok { + return w, nil + } + return 0, fmt.Errorf("invalid weekday %q", s) +} diff --git a/service.go b/service.go index 25d92c4..f2c8a76 100644 --- a/service.go +++ b/service.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "strconv" "time" "github.com/joho/godotenv" @@ -16,8 +17,14 @@ type Config struct { 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) { @@ -46,9 +53,65 @@ func LoadConfig() (Config, error) { } 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 diff --git a/service_test.go b/service_test.go index e93d0d2..0443bbc 100644 --- a/service_test.go +++ b/service_test.go @@ -57,6 +57,85 @@ func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) { } } +func TestLoadReminderConfigDisabled(t *testing.T) { + t.Setenv("GROUP_CHAT_ID", "") + r, err := loadReminderConfig() + if err != nil { + t.Fatalf("loadReminderConfig: %v", err) + } + if r != nil { + t.Errorf("expected nil (disabled), got %+v", r) + } +} + +func TestLoadReminderConfigParses(t *testing.T) { + // Negative chat ID guards against ParseInt being swapped for ParseUint: + // Telegram group IDs are negative. + t.Setenv("GROUP_CHAT_ID", "-5572351978") + t.Setenv("REMINDER_WEEKDAY", "Friday") + t.Setenv("REMINDER_HOUR", "18") + t.Setenv("DUTY_AHEAD_DAYS", "7") + + r, err := loadReminderConfig() + if err != nil { + t.Fatalf("loadReminderConfig: %v", err) + } + if r == nil { + t.Fatal("expected a config, got nil") + } + if r.ChatID != -5572351978 { + t.Errorf("ChatID = %d, want -5572351978", r.ChatID) + } + if r.Weekday != time.Friday || r.Hour != 18 || r.AheadDays != 7 { + t.Errorf("got %v %d ahead=%d, want Friday 18 ahead=7", r.Weekday, r.Hour, r.AheadDays) + } + if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" { + t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc) + } +} + +func TestLoadReminderConfigDefaults(t *testing.T) { + t.Setenv("GROUP_CHAT_ID", "42") + t.Setenv("REMINDER_WEEKDAY", "") + t.Setenv("REMINDER_HOUR", "") + t.Setenv("DUTY_AHEAD_DAYS", "") + + r, err := loadReminderConfig() + if err != nil { + t.Fatalf("loadReminderConfig: %v", err) + } + if r.Weekday != time.Monday || r.Hour != 9 || r.AheadDays != 14 { + t.Errorf("defaults: got %v %d ahead=%d, want Monday 9 ahead=14", r.Weekday, r.Hour, r.AheadDays) + } +} + +func TestLoadReminderConfigRejects(t *testing.T) { + tests := []struct { + name string + key string + val string + }{ + {"bad chat id", "GROUP_CHAT_ID", "not-a-number"}, + {"hour too high", "REMINDER_HOUR", "24"}, + {"hour negative", "REMINDER_HOUR", "-1"}, + {"ahead negative", "DUTY_AHEAD_DAYS", "-1"}, + {"bad weekday", "REMINDER_WEEKDAY", "Funday"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override + t.Setenv("REMINDER_WEEKDAY", "Monday") + t.Setenv("REMINDER_HOUR", "9") + t.Setenv("DUTY_AHEAD_DAYS", "14") + t.Setenv(tc.key, tc.val) + + if _, err := loadReminderConfig(); err == nil { + t.Fatalf("expected error for %s=%q", tc.key, tc.val) + } + }) + } +} + func TestSyncSuccessReplacesCache(t *testing.T) { store := NewStore(filepath.Join(t.TempDir(), "rota.json")) fixed := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC)