dyzur-bot/scheduler.go

135 lines
4.2 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/slog"
"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 {
slog.Info("reminder: no duty week, skipping", "target", target.Format("2006-01-02"))
return
}
if err := r.send(ctx, text); err != nil {
slog.Error("reminder send failed", "err", 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)
}