135 lines
4.2 KiB
Go
135 lines
4.2 KiB
Go
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)
|
||
}
|