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:
parent
03e029cd4d
commit
881bd5be03
23
README.md
23
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.
|
||||
|
||||
|
|
|
|||
133
scheduler.go
133
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)
|
||||
}
|
||||
|
|
|
|||
63
service.go
63
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue