280 lines
9.6 KiB
Go
280 lines
9.6 KiB
Go
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 (0–23) 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 across three horizons: it reminds about the
|
||
// current week, auto-assigns the week one week out when nobody volunteered, and
|
||
// warns two weeks out while there is still time to volunteer. 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)
|
||
r.handleWeekAhead(ctx, rota, now, handles)
|
||
r.handleTwoWeeksAhead(ctx, rota, now)
|
||
}
|
||
|
||
// handleThisWeek sends the reminder for the current duty week to whoever is
|
||
// assigned. It never auto-assigns: assignment happens a week earlier (see
|
||
// handleWeekAhead), so a still-empty current week just renders reminderText's
|
||
// "nieobsadzony" branch rather than being filled at the last moment.
|
||
func (r *Reminder) handleThisWeek(ctx context.Context, rota Rota, now time.Time) {
|
||
if text, ok := reminderText(rota, now); ok {
|
||
r.sendLogged(ctx, text)
|
||
}
|
||
}
|
||
|
||
// handleWeekAhead auto-assigns the week one week out when it is still completely
|
||
// unassigned: it picks the two people with the fewest duties, writes them into
|
||
// the sheet, and announces the assignment. A week with either slot filled is
|
||
// left alone.
|
||
func (r *Reminder) handleWeekAhead(ctx context.Context, rota Rota, now time.Time, handles map[string]string) {
|
||
week, ok := currentDuty(rota, now.AddDate(0, 0, 7))
|
||
if !ok || week.Person1 != "" || week.Person2 != "" {
|
||
return
|
||
}
|
||
|
||
p1, p2, ok := chooseTwo(rota.People, r.rng)
|
||
if !ok {
|
||
slog.Info("reminder: week ahead empty but fewer than 2 people, skipping assignment")
|
||
return
|
||
}
|
||
if week.Row == 0 {
|
||
slog.Warn("reminder: week ahead 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))
|
||
}
|
||
|
||
// handleTwoWeeksAhead warns when the week two weeks out is still completely
|
||
// unassigned, giving people a week to volunteer before it is auto-assigned (see
|
||
// handleWeekAhead).
|
||
func (r *Reminder) handleTwoWeeksAhead(ctx context.Context, rota Rota, now time.Time) {
|
||
week, ok := currentDuty(rota, now.AddDate(0, 0, 14))
|
||
if !ok || week.Person1 != "" || week.Person2 != "" {
|
||
return
|
||
}
|
||
r.sendLogged(ctx, weekWarningText(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).
|
||
// The week is stated by its start date, not "this week", because assignment now
|
||
// happens a week before the duty week begins.
|
||
func assignedText(week DutyWeek, p1, p2 string, handles map[string]string) string {
|
||
return "Nikt nie zgłosił się na dyżur w 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!"
|
||
}
|
||
|
||
// weekWarningText asks people to volunteer for an as-yet-unassigned week before
|
||
// it is filled automatically. The week is identified by its start date.
|
||
func weekWarningText(week DutyWeek) string {
|
||
return "Dyżur w tygodniu 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)
|
||
}
|