dyzur-bot/scheduler.go

271 lines
8.9 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"
"math/rand"
"sort"
"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
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
// AssignFunc persists a duty assignment: it writes p1/p2 into week's cells. It
// abstracts the sheet write so Reminder can be tested without Google (see
// SheetAssigner for the real wiring).
type AssignFunc func(ctx context.Context, week DutyWeek, p1, p2 string) error
// Reminder posts a duty reminder to a chat on a weekly schedule, and fills empty
// duty weeks by assigning people when nobody has volunteered.
type Reminder struct {
store *Store
send SendFunc
assign AssignFunc
cfg ReminderConfig
now func() time.Time
rng *rand.Rand
}
// NewReminder wires a reminder to the rota cache, a send function, and an
// assigner (used to persist auto-assigned duty weeks).
func NewReminder(store *Store, send SendFunc, assign AssignFunc, cfg ReminderConfig) *Reminder {
return &Reminder{
store: store,
send: send,
assign: assign,
cfg: cfg,
now: time.Now,
// #nosec G404 -- duty selection is fairness, not security; a weak RNG is fine.
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// Run fires the reminder on schedule until ctx is canceled. Each iteration
// recomputes the next fire time, sleeps until then, and on fire handles this and
// next week (see fire). 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 weekly run: it finalizes this week (announcing who is on
// duty, or auto-assigning when nobody volunteered) and warns when next week is
// still unassigned. Dates are computed in the schedule's zone so the calendar
// day matches the wall-clock fire day (consistent with nextFire); reading them
// in server-local time could land on the prior day on a UTC host.
func (r *Reminder) fire(ctx context.Context) {
rota, _ := r.store.Snapshot()
now := r.now().In(r.cfg.Loc)
handles := handlesByName(rota.People)
r.handleThisWeek(ctx, rota, now, handles)
r.handleNextWeek(ctx, rota, now)
}
// handleThisWeek announces the current duty, or auto-assigns it when both slots
// are empty.
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time, handles map[string]string) {
week, ok := currentDuty(rota, now)
if !ok {
slog.Info("reminder: no week row for this week, skipping", "date", now.Format("2006-01-02"))
return
}
if week.Person1 != "" || week.Person2 != "" {
if text, ok := reminderText(rota, now); ok {
r.sendLogged(ctx, text)
}
return
}
// Both slots empty: assign the two people with the fewest duties.
p1, p2, ok := chooseTwo(rota.People, r.rng)
if !ok {
slog.Info("reminder: this week empty but fewer than 2 people, skipping assignment")
return
}
if week.Row == 0 {
slog.Warn("reminder: this week empty but sheet row unknown, cannot assign",
"week", week.WeekStart.Format("2006-01-02"))
return
}
if err := r.assign(ctx, week, p1, p2); err != nil {
slog.Error("reminder: assign write failed", "err", err)
return
}
r.sendLogged(ctx, assignedText(week, p1, p2, handles))
}
// handleNextWeek warns when next week is still completely unassigned.
func (r *Reminder) handleNextWeek(ctx context.Context, rota Rota, now time.Time) {
week, ok := currentDuty(rota, now.AddDate(0, 0, 7))
if !ok || week.Person1 != "" || week.Person2 != "" {
return
}
r.sendLogged(ctx, nextWeekWarningText(week))
}
// sendLogged sends text, logging (not propagating) any failure so one bad send
// does not abort the rest of the run.
func (r *Reminder) sendLogged(ctx context.Context, text string) {
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
}
handles := handlesByName(rota.People)
var people []string
if week.Person1 != "" {
people = append(people, mention(week.Person1, handles))
}
if week.Person2 != "" {
people = append(people, mention(week.Person2, handles))
}
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 ")
}
tail := "."
if len(people) > 0 {
tail = ". Proszę pamiętać o sprzątaniu."
}
return "Przypomnienie: w tygodniu od " +
week.WeekStart.Format("2006-01-02") + " " + body + tail, true
}
// assignedText announces an auto-assigned duty week (both people just chosen).
func assignedText(week DutyWeek, p1, p2 string, handles map[string]string) string {
return "Nikt nie zgłosił się na dyżur w tym tygodniu (od " +
week.WeekStart.Format("2006-01-02") + "), więc został przydzielony losowo: " +
mention(p1, handles) + " i " + mention(p2, handles) +
". Z góry dziękujemy za sprzątanie!"
}
// nextWeekWarningText asks people to volunteer for an as-yet-unassigned next
// week before it is filled automatically.
func nextWeekWarningText(week DutyWeek) string {
return "Dyżur na przyszły tydzień (od " +
week.WeekStart.Format("2006-01-02") +
") jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo."
}
// handlesByName indexes the roster's Telegram handles by person name, so a
// duty week's names can be resolved to mentions.
func handlesByName(people []Person) map[string]string {
m := make(map[string]string, len(people))
for _, p := range people {
m[strings.TrimSpace(p.Name)] = p.Handle
}
return m
}
// mention renders a duty name as a Telegram mention (@handle) when a handle is
// known, falling back to the plain name otherwise. The handle is normalized to
// carry exactly one leading "@"; Telegram auto-links and notifies chat members
// whose public username matches.
func mention(name string, handles map[string]string) string {
h := strings.TrimSpace(handles[strings.TrimSpace(name)])
if h == "" {
return name
}
return "@" + strings.TrimPrefix(h, "@")
}
// chooseTwo picks the two people with the fewest recorded duties, returning the
// lower-count one first. Equal counts are broken randomly (via rng) so no one is
// systematically favored among ties. Entries with a blank name are ignored;
// ok is false when fewer than two eligible people remain.
func chooseTwo(people []Person, rng *rand.Rand) (p1, p2 string, ok bool) {
eligible := make([]Person, 0, len(people))
for _, p := range people {
if strings.TrimSpace(p.Name) != "" {
eligible = append(eligible, p)
}
}
if len(eligible) < 2 {
return "", "", false
}
// Shuffle first, then stable-sort by count: equal counts keep the shuffled
// (random) order, giving a fair tie-break.
rng.Shuffle(len(eligible), func(i, j int) {
eligible[i], eligible[j] = eligible[j], eligible[i]
})
sort.SliceStable(eligible, func(i, j int) bool {
return eligible[i].DutyCount < eligible[j].DutyCount
})
return eligible[0].Name, eligible[1].Name, 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)
}