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.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-18 02:36:06 +02:00
parent 03e029cd4d
commit 881bd5be03
4 changed files with 294 additions and 4 deletions

View File

@ -6,14 +6,29 @@ Backend read the schedule from a Google sheet, store it in local JSON file.
## Running ## Running
Requires `GOOGLE_API_KEY` and `GOOGLE_SHEETS_ID` in the environment or a `.env` Requires `GOOGLE_API_KEY`, `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the
file (see `service.go`/`sheet.go` for optional `SHEET_RANGE`, `ROTA_CACHE_PATH`, environment or a `.env` file (see `service.go`/`sheet.go` for optional
`SYNC_INTERVAL`). Then: `SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then:
```sh ```sh
go run ./cmd/dyzur-bot go run ./cmd/dyzur-bot
``` ```
The service loads the cached rota, syncs from Google Sheets in the background on 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`, 023), `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.

View File

@ -1 +1,134 @@
package dyzurbot 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 (023) 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)
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strconv"
"time" "time"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@ -16,8 +17,14 @@ type Config struct {
SheetRange string SheetRange string
CachePath string CachePath string
SyncInterval time.Duration 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), // LoadConfig reads configuration from the environment (and .env if present),
// applying defaults for everything except the required spreadsheet ID. // applying defaults for everything except the required spreadsheet ID.
func LoadConfig() (Config, error) { func LoadConfig() (Config, error) {
@ -46,9 +53,65 @@ func LoadConfig() (Config, error) {
} }
cfg.SyncInterval = interval cfg.SyncInterval = interval
reminder, err := loadReminderConfig()
if err != nil {
return Config{}, err
}
cfg.Reminder = reminder
return cfg, nil 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 { func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" { if v := os.Getenv(key); v != "" {
return v return v

View File

@ -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) { func TestSyncSuccessReplacesCache(t *testing.T) {
store := NewStore(filepath.Join(t.TempDir(), "rota.json")) store := NewStore(filepath.Join(t.TempDir(), "rota.json"))
fixed := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC) fixed := time.Date(2026, 6, 17, 20, 0, 0, 0, time.UTC)