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 = ". Z góry dziękujemy!" } 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) }