From 6573144b149b84233b6fea80d699c164d2856aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20=27Kama=C5=9B=27=20Bruchal?= Date: Fri, 19 Jun 2026 03:48:59 +0200 Subject: [PATCH] feat: auto-assign empty duty weeks via service-account sheet writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate Sheets auth from an API key to a service-account credential (GOOGLE_CREDENTIALS_FILE) with the read+write spreadsheets scope, add a WriteSheet primitive, and use it to actively manage empty duty weeks in the weekly reminder. Each fire now handles a fixed this-week/next-week model (replacing the DUTY_AHEAD_DAYS announce): - this week empty -> pick the two people with the fewest duties (random tie-break), write their names into cols B/C, and announce - this week filled -> announce who is on duty - next week empty -> ask people to volunteer before it is auto-assigned Col G ("Liczba dyżurów") is a live formula, so names-only writes keep the fewest-duties selection fair across weeks. Selection, row tracking, range building, and every fire() branch are unit-tested; live tests for sheet read/write are env-gated. --- .dockerignore | 1 + .gitignore | 1 + README.md | 18 +++- cmd/dyzur-bot/main.go | 5 +- live_auth_check_test.go | 91 +++++++++++++++++ scheduler.go | 160 ++++++++++++++++++++++++----- scheduler_live_test.go | 59 +++++++++++ scheduler_test.go | 218 ++++++++++++++++++++++++++++++++++++++-- service.go | 50 ++++++--- service_test.go | 58 +++++++++-- sheet.go | 64 ++++++++++-- sheet_test.go | 13 +++ 12 files changed, 670 insertions(+), 68 deletions(-) create mode 100644 live_auth_check_test.go create mode 100644 scheduler_live_test.go diff --git a/.dockerignore b/.dockerignore index f37e2c0..2dd916a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ # Secrets and local state — never bake into the build context/image. .env rota.json +hackerspace-492302-a63993a1af9d.json # VCS and tooling. .git diff --git a/.gitignore b/.gitignore index 93adf98..e50c1d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .env rota.json +hackerspace-492302-a63993a1af9d.json diff --git a/README.md b/README.md index a20a11d..e03c4d3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Backend read the schedule from a Google sheet, store it in local JSON file. ## Running -Requires `GOOGLE_API_KEY`, `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the +Requires `GOOGLE_CREDENTIALS_FILE` (path to a Google service-account key, +shared as an editor on the sheet), `GOOGLE_SHEETS_ID`, and `BOT_TOKEN` in the environment or a `.env` file (see `service.go`/`sheet.go` for optional `SHEET_RANGE`, `ROTA_CACHE_PATH`, `SYNC_INTERVAL`). Then: @@ -52,7 +53,14 @@ across restarts. If `GROUP_CHAT_ID` is set, the bot posts a weekly duty reminder to that chat. Schedule (all optional, with defaults): `REMINDER_WEEKDAY` (default `Monday`), -`REMINDER_HOUR` (default `9`, 0–23), `DUTY_AHEAD_DAYS` (default `14`). The hour -is interpreted in Europe/Warsaw time. The reminder announces who is on duty -`DUTY_AHEAD_DAYS` from the fire time — e.g. every Monday 09:00, who cleans two -weeks out. Leave `GROUP_CHAT_ID` unset to disable reminders. +`REMINDER_HOUR` (default `9`, 0–23), interpreted in Europe/Warsaw time. + +On each fire the bot handles two weeks relative to the fire time: + +- **This week** — announces who is on duty. If both slots are empty, it picks + the two people with the fewest duties (random tie-break), writes their names + into the sheet, and announces the assignment. +- **Next week** — if both slots are still empty, it asks people to volunteer + before it gets assigned automatically the following week. + +Leave `GROUP_CHAT_ID` unset to disable reminders. diff --git a/cmd/dyzur-bot/main.go b/cmd/dyzur-bot/main.go index 9440dc7..bbff129 100644 --- a/cmd/dyzur-bot/main.go +++ b/cmd/dyzur-bot/main.go @@ -65,10 +65,11 @@ func run() error { if r := cfg.Reminder; r != nil { send := dyzurbot.BotSendFunc(b, r.ChatID) - go dyzurbot.NewReminder(svc.Store, send, *r).Run(ctx) + assign := dyzurbot.SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange) + go dyzurbot.NewReminder(svc.Store, send, assign, *r).Run(ctx) slog.Info("reminders on", "weekday", r.Weekday, "hour", r.Hour, "tz", r.Loc.String(), - "days_ahead", r.AheadDays, "chat", r.ChatID) + "chat", r.ChatID) } else { slog.Info("reminders disabled (no GROUP_CHAT_ID)") } diff --git a/live_auth_check_test.go b/live_auth_check_test.go new file mode 100644 index 0000000..02bd64c --- /dev/null +++ b/live_auth_check_test.go @@ -0,0 +1,91 @@ +package dyzurbot + +import ( + "context" + "os" + "strconv" + "testing" + "time" + + "github.com/joho/godotenv" + "google.golang.org/api/sheets/v4" +) + +// These are manual smoke tests that hit the real Google Sheet using the +// credential in .env (GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID). They are +// skipped unless their gate env var is set, so the normal `go test ./...` run +// stays offline and side-effect-free. + +// TestLiveAuthRead proves the service-account credential loads and the sheet is +// reachable. Read-only. Gate: DYZUR_LIVE_AUTH=1. +func TestLiveAuthRead(t *testing.T) { + if os.Getenv("DYZUR_LIVE_AUTH") != "1" { + t.Skip("set DYZUR_LIVE_AUTH=1 to run") + } + _ = godotenv.Load() + id := os.Getenv("GOOGLE_SHEETS_ID") + rows, err := ReadSheet(context.Background(), id, resolveRange("", time.Now())) + if err != nil { + t.Fatalf("ReadSheet: %v", err) + } + t.Logf("OK: fetched %d rows", len(rows)) +} + +// TestLiveWriteRoundTrip proves the spreadsheets (read+write) scope actually +// grants writes: it creates a throwaway tab, writes a marker via WriteSheet, +// reads it back, and deletes the tab — so no real rota data is touched. Gate: +// DYZUR_LIVE_WRITE=1. +func TestLiveWriteRoundTrip(t *testing.T) { + if os.Getenv("DYZUR_LIVE_WRITE") != "1" { + t.Skip("set DYZUR_LIVE_WRITE=1 to run") + } + _ = godotenv.Load() + ctx := context.Background() + id := os.Getenv("GOOGLE_SHEETS_ID") + + srv, err := newSheetsService(ctx) + if err != nil { + t.Fatalf("newSheetsService: %v", err) + } + + // Create an isolated throwaway tab; the timestamp keeps the title unique. + title := "scratch-" + strconv.FormatInt(time.Now().UnixNano(), 10) + addResp, err := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{ + AddSheet: &sheets.AddSheetRequest{ + Properties: &sheets.SheetProperties{Title: title}, + }, + }}, + }).Context(ctx).Do() + if err != nil { + t.Fatalf("create scratch tab: %v", err) + } + sheetID := addResp.Replies[0].AddSheet.Properties.SheetId + + // Always remove the throwaway tab, even if the assertions below fail. + defer func() { + _, derr := srv.Spreadsheets.BatchUpdate(id, &sheets.BatchUpdateSpreadsheetRequest{ + Requests: []*sheets.Request{{ + DeleteSheet: &sheets.DeleteSheetRequest{SheetId: sheetID}, + }}, + }).Context(ctx).Do() + if derr != nil { + t.Errorf("cleanup: delete scratch tab %q: %v", title, derr) + } + }() + + const want = "dyzur-write-probe" + writeRange := title + "!A1" + if err := WriteSheet(ctx, id, writeRange, [][]interface{}{{want}}); err != nil { + t.Fatalf("WriteSheet: %v", err) + } + + rows, err := ReadSheet(ctx, id, writeRange) + if err != nil { + t.Fatalf("ReadSheet back: %v", err) + } + if len(rows) == 0 || len(rows[0]) == 0 || rows[0][0] != want { + t.Fatalf("round-trip mismatch: got %v, want %q", rows, want) + } + t.Logf("OK: wrote and read back %q from throwaway tab %q", want, title) +} diff --git a/scheduler.go b/scheduler.go index 53c7a13..d458fa6 100644 --- a/scheduler.go +++ b/scheduler.go @@ -4,40 +4,57 @@ 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 - AheadDays int // announce the duty this many days from the fire time - Loc *time.Location // timezone the schedule is expressed in + 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 -// Reminder posts a duty reminder to a chat on a weekly schedule. +// 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 - cfg ReminderConfig - now func() time.Time + store *Store + send SendFunc + assign AssignFunc + cfg ReminderConfig + now func() time.Time + rng *rand.Rand } -// 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} +// 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 looks up the -// duty AheadDays out and posts it (skipping weeks with no data). Send failures -// are logged and the loop continues. +// 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() @@ -53,18 +70,66 @@ func (r *Reminder) Run(ctx context.Context) { } } -// fire performs one reminder: snapshot the cache, format, and send. +// 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() - // 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) + 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 duty week, skipping", "target", target.Format("2006-01-02")) + 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) } @@ -114,8 +179,28 @@ func reminderText(rota Rota, target time.Time) (string, bool) { 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 + ".", true + 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 @@ -140,6 +225,33 @@ func mention(name string, handles map[string]string) string { 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{ diff --git a/scheduler_live_test.go b/scheduler_live_test.go new file mode 100644 index 0000000..658fd98 --- /dev/null +++ b/scheduler_live_test.go @@ -0,0 +1,59 @@ +package dyzurbot + +import ( + "context" + "os" + "testing" + "time" +) + +// TestLiveReminderFire performs ONE real reminder fire against a live Telegram +// chat and the live Google Sheet, exercising the full scheduler send path: +// Syncer.Sync -> Store.Snapshot -> reminderText -> mention -> BotSendFunc. +// +// It is a manual smoke test, skipped unless DYZUR_LIVE_FIRE=1, and needs the same +// env the binary uses: BOT_TOKEN, GROUP_CHAT_ID, GOOGLE_CREDENTIALS_FILE, GOOGLE_SHEETS_ID +// (and ROTA_CACHE_PATH for the cache file). +// +// WARNING — this MUTATES the sheet. fire() announces the current week and, when +// that week is empty, auto-assigns two people and WRITES their names into the +// rota. Point GROUP_CHAT_ID at a SANDBOX chat AND GOOGLE_SHEETS_ID at a COPY of +// the sheet — otherwise this pings real members and edits the production rota. +func TestLiveReminderFire(t *testing.T) { + if os.Getenv("DYZUR_LIVE_FIRE") != "1" { + t.Skip("set DYZUR_LIVE_FIRE=1 to run the live reminder smoke test") + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.Reminder == nil { + t.Fatal("Reminder config is nil — set GROUP_CHAT_ID") + } + + // Synchronous sync so the store is populated before we fire (Service.Start + // would run the first sync in the background, racing the fire below). + svc := NewService(cfg) + if err := svc.Syncer.Sync(ctx); err != nil { + t.Fatalf("sheet sync: %v", err) + } + + token := os.Getenv("BOT_TOKEN") + if token == "" { + t.Fatal("missing BOT_TOKEN") + } + b, err := NewBot(token, svc) + if err != nil { + t.Fatalf("NewBot: %v", err) + } + + rc := *cfg.Reminder + assign := SheetAssigner(cfg.SpreadsheetID, cfg.SheetRange) + + NewReminder(svc.Store, BotSendFunc(b, rc.ChatID), assign, rc).fire(ctx) + t.Logf("fired reminder to chat %d for the current week — verify it landed in the chat", rc.ChatID) +} diff --git a/scheduler_test.go b/scheduler_test.go index 48581d4..2886bab 100644 --- a/scheduler_test.go +++ b/scheduler_test.go @@ -1,10 +1,216 @@ package dyzurbot import ( + "context" + "errors" + "math/rand" "testing" "time" ) +type assignCall struct { + week DutyWeek + p1, p2 string +} + +// fireHarness builds a Reminder over a fixed rota/now with capturing send and +// assign funcs, so fire()'s branches can be asserted without Telegram or Google. +type fireHarness struct { + sends []string + assigns []assignCall + assignFn AssignFunc +} + +func newFireHarness(t *testing.T, rota Rota, now time.Time, seed int64) (*Reminder, *fireHarness) { + t.Helper() + h := &fireHarness{} + send := func(_ context.Context, text string) error { + h.sends = append(h.sends, text) + return nil + } + assign := func(ctx context.Context, week DutyWeek, p1, p2 string) error { + h.assigns = append(h.assigns, assignCall{week, p1, p2}) + if h.assignFn != nil { + return h.assignFn(ctx, week, p1, p2) + } + return nil + } + r := &Reminder{ + store: &Store{rota: rota}, + send: send, + assign: assign, + cfg: ReminderConfig{Loc: warsaw(t)}, + now: func() time.Time { return now }, + rng: rand.New(rand.NewSource(seed)), + } + return r, h +} + +func TestFire(t *testing.T) { + loc := warsaw(t) + mon := time.Date(2026, 6, 15, 9, 0, 0, 0, loc) // Mon; this week starts 2026-06-15 + people := []Person{ + {Name: "Ala", DutyCount: 1}, + {Name: "Bartek", DutyCount: 2}, + {Name: "Cela", DutyCount: 3}, + } + + t.Run("this week empty -> assign two lowest and announce", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{ + {WeekStart: day(t, "2026-06-15"), Row: 5}, // this: empty + {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}, // next: filled + }, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 1 { + t.Fatalf("assigns = %d, want 1", len(h.assigns)) + } + a := h.assigns[0] + if a.p1 != "Ala" || a.p2 != "Bartek" || a.week.Row != 5 { + t.Errorf("assign = %+v, want Ala/Bartek on row 5", a) + } + want := "Nikt nie zgłosił się na dyżur w tym tygodniu (od 2026-06-15), więc został przydzielony losowo: Ala i Bartek. Z góry dziękujemy za sprzątanie!" + if len(h.sends) != 1 || h.sends[0] != want { + t.Errorf("sends = %q, want [%q]", h.sends, want) + } + }) + + t.Run("this week filled, next week empty -> reminder + warning", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{ + {WeekStart: day(t, "2026-06-15"), Person1: "Jan", Person2: "Anna", Row: 5}, + {WeekStart: day(t, "2026-06-22"), Row: 6}, // next: empty + }, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 0 { + t.Errorf("assigns = %d, want 0 (this week already filled)", len(h.assigns)) + } + wantReminder := "Przypomnienie: w tygodniu od 2026-06-15 dyżur sprzątają Jan i Anna. Proszę pamiętać o sprzątaniu." + wantWarning := "Dyżur na przyszły tydzień (od 2026-06-22) jest jeszcze nieobsadzony. Proszę się zgłaszać — w przeciwnym razie zostanie przydzielony losowo." + if len(h.sends) != 2 || h.sends[0] != wantReminder || h.sends[1] != wantWarning { + t.Errorf("sends = %q,\n want [%q, %q]", h.sends, wantReminder, wantWarning) + } + }) + + t.Run("this week empty but fewer than 2 people -> no assign, no send", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + People: []Person{{Name: "Ala", DutyCount: 1}}, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 0 || len(h.sends) != 0 { + t.Errorf("assigns=%d sends=%q, want 0 and none", len(h.assigns), h.sends) + } + }) + + t.Run("this week empty but row unknown -> no assign, no send", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 0}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + r.fire(context.Background()) + + if len(h.assigns) != 0 || len(h.sends) != 0 { + t.Errorf("assigns=%d sends=%q, want 0 and none (unwritable row)", len(h.assigns), h.sends) + } + }) + + t.Run("assign write fails -> no announcement", func(t *testing.T) { + rota := Rota{ + Weeks: []DutyWeek{{WeekStart: day(t, "2026-06-15"), Row: 5}, {WeekStart: day(t, "2026-06-22"), Person1: "X", Person2: "Y", Row: 6}}, + People: people, + } + r, h := newFireHarness(t, rota, mon, 1) + h.assignFn = func(context.Context, DutyWeek, string, string) error { return errors.New("boom") } + r.fire(context.Background()) + + if len(h.assigns) != 1 { + t.Errorf("assigns = %d, want 1 (attempted)", len(h.assigns)) + } + if len(h.sends) != 0 { + t.Errorf("sends = %q, want none (write failed)", h.sends) + } + }) +} + +func TestChooseTwo(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + + t.Run("picks two lowest counts, lowest first", func(t *testing.T) { + people := []Person{ + {Name: "Ala", DutyCount: 3}, + {Name: "Bartek", DutyCount: 1}, + {Name: "Cela", DutyCount: 2}, + } + p1, p2, ok := chooseTwo(people, rng) + if !ok { + t.Fatal("ok = false, want true") + } + if p1 != "Bartek" || p2 != "Cela" { + t.Errorf("got (%q, %q), want (Bartek, Cela)", p1, p2) + } + }) + + t.Run("ignores blank-name entries", func(t *testing.T) { + people := []Person{ + {Name: "", DutyCount: 0}, + {Name: "Ala", DutyCount: 5}, + {Name: "Bartek", DutyCount: 6}, + } + p1, p2, ok := chooseTwo(people, rng) + if !ok || p1 != "Ala" || p2 != "Bartek" { + t.Errorf("got (%q, %q, %v), want (Ala, Bartek, true)", p1, p2, ok) + } + }) + + t.Run("fewer than two eligible -> ok=false", func(t *testing.T) { + if _, _, ok := chooseTwo([]Person{{Name: "Ala"}}, rng); ok { + t.Error("one person: ok = true, want false") + } + if _, _, ok := chooseTwo(nil, rng); ok { + t.Error("no people: ok = true, want false") + } + if _, _, ok := chooseTwo([]Person{{Name: ""}, {Name: ""}}, rng); ok { + t.Error("only blank names: ok = true, want false") + } + }) + + t.Run("equal counts: random tie-break, distinct and deterministic per seed", func(t *testing.T) { + people := []Person{ + {Name: "Ala", DutyCount: 1}, + {Name: "Bartek", DutyCount: 1}, + {Name: "Cela", DutyCount: 1}, + } + p1, p2, ok := chooseTwo(people, rand.New(rand.NewSource(42))) + if !ok { + t.Fatal("ok = false, want true") + } + if p1 == p2 { + t.Errorf("chose the same person twice: %q", p1) + } + names := map[string]bool{"Ala": true, "Bartek": true, "Cela": true} + if !names[p1] || !names[p2] { + t.Errorf("got (%q, %q), both must be from the roster", p1, p2) + } + // Same seed reproduces the same choice. + q1, q2, _ := chooseTwo(people, rand.New(rand.NewSource(42))) + if q1 != p1 || q2 != p2 { + t.Errorf("not deterministic for a seed: (%q,%q) vs (%q,%q)", p1, p2, q1, q2) + } + }) +} + func warsaw(t *testing.T) *time.Location { t.Helper() loc, err := time.LoadLocation("Europe/Warsaw") @@ -99,12 +305,12 @@ func TestReminderText(t *testing.T) { { "both people", rota("Ala", "Bartek"), target, true, - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają Ala i Bartek. Proszę pamiętać o sprzątaniu.", }, { "one person", rota("Ala", ""), target, true, - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta Ala. Proszę pamiętać o sprzątaniu.", }, { "nobody assigned", @@ -149,22 +355,22 @@ func TestReminderTextMentions(t *testing.T) { { "both have handles", rota("Ala", "Bartek"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i @bart. Proszę pamiętać o sprzątaniu.", }, { "one handle, one missing -> mixed", rota("Ala", "Cela"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Cela. Proszę pamiętać o sprzątaniu.", }, { "name not in people list -> plain name", rota("Ala", "Zenon"), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprzątają @ala i Zenon. Proszę pamiętać o sprzątaniu.", }, { "single person with handle", rota("Bartek", ""), - "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart.", + "Przypomnienie: w tygodniu od 2026-06-29 dyżur sprząta @bart. Proszę pamiętać o sprzątaniu.", }, } for _, tc := range tests { diff --git a/service.go b/service.go index f9d05ba..3df4b5c 100644 --- a/service.go +++ b/service.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "strconv" + "strings" "time" "github.com/joho/godotenv" @@ -44,6 +45,17 @@ func LoadConfig() (Config, error) { return Config{}, fmt.Errorf("missing GOOGLE_SHEETS_ID in environment/.env") } + // Fail fast at startup if the service-account key is missing or unreadable, + // rather than on the first sheet fetch minutes later. ReadSheet consumes the + // path directly from the environment. + credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE") + if credPath == "" { + return Config{}, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env") + } + if _, err := os.Stat(credPath); err != nil { // #nosec G703 -- credPath is operator-controlled config (env/.env) + return Config{}, fmt.Errorf("GOOGLE_CREDENTIALS_FILE %q: %w", credPath, err) + } + raw := getenvDefault("SYNC_INTERVAL", "30m") interval, err := time.ParseDuration(raw) if err != nil { @@ -90,26 +102,16 @@ func loadReminderConfig() (*ReminderConfig, error) { return nil, fmt.Errorf("REMINDER_HOUR must be 0–23, got %d", hour) } - aheadRaw := getenvDefault("DUTY_AHEAD_DAYS", "14") - ahead, err := strconv.Atoi(aheadRaw) - if err != nil { - return nil, fmt.Errorf("invalid DUTY_AHEAD_DAYS %q: %w", aheadRaw, err) - } - if ahead < 0 { - return nil, fmt.Errorf("DUTY_AHEAD_DAYS must be >= 0, got %d", ahead) - } - loc, err := time.LoadLocation(reminderTimezone) if err != nil { return nil, fmt.Errorf("load timezone %q: %w", reminderTimezone, err) } return &ReminderConfig{ - ChatID: chatID, - Weekday: weekday, - Hour: hour, - AheadDays: ahead, - Loc: loc, + ChatID: chatID, + Weekday: weekday, + Hour: hour, + Loc: loc, }, nil } @@ -224,6 +226,26 @@ func SheetFetcher(spreadsheetID, readRange string) FetchFunc { } } +// SheetAssigner returns an AssignFunc that writes a week's two names into its +// B:C cells on the same tab the rota is read from. sheetRange mirrors the +// fetcher's range so the tab (year or explicit) matches. +func SheetAssigner(spreadsheetID, sheetRange string) AssignFunc { + return func(ctx context.Context, week DutyWeek, p1, p2 string) error { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + rng := assignRange(sheetRange, time.Now(), week.Row) + return WriteSheet(ctx, spreadsheetID, rng, [][]interface{}{{p1, p2}}) + } +} + +// assignRange builds the A1 range for a week's assignment cells (Osoba 1/2 are +// columns B and C). It reuses the tab from resolveRange so the year rollover and +// any explicit range are honored identically to reads. +func assignRange(sheetRange string, now time.Time, row int) string { + tab, _, _ := strings.Cut(resolveRange(sheetRange, now), "!") + return fmt.Sprintf("%s!B%d:C%d", tab, row, row) +} + // resolveRange returns the explicit range when set, otherwise the default that // targets the current year's tab (sheets are named per year, e.g. "2026"). // Resolving against now (not a startup-captured value) is what lets the range diff --git a/service_test.go b/service_test.go index 88335aa..1462c00 100644 --- a/service_test.go +++ b/service_test.go @@ -3,16 +3,31 @@ package dyzurbot import ( "context" "errors" + "os" "path/filepath" "testing" "time" ) +// setCredFile points GOOGLE_CREDENTIALS_FILE at an existing temp file so +// LoadConfig's startup validation passes without depending on the developer's +// local (gitignored) .env. The contents are irrelevant: LoadConfig only stats +// the path; the file is parsed later, by the Sheets client. +func setCredFile(t *testing.T) { + t.Helper() + path := filepath.Join(t.TempDir(), "sa.json") + if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil { + t.Fatalf("write temp cred file: %v", err) + } + t.Setenv("GOOGLE_CREDENTIALS_FILE", path) +} + func TestLoadConfigDefaults(t *testing.T) { t.Setenv("GOOGLE_SHEETS_ID", "sheet-123") t.Setenv("SHEET_RANGE", "") t.Setenv("ROTA_CACHE_PATH", "") t.Setenv("SYNC_INTERVAL", "") + setCredFile(t) cfg, err := LoadConfig() if err != nil { @@ -41,8 +56,25 @@ func TestLoadConfigRequiresSpreadsheetID(t *testing.T) { } } +func TestLoadConfigRequiresCredentialsFile(t *testing.T) { + t.Setenv("GOOGLE_SHEETS_ID", "sheet-123") + t.Setenv("GOOGLE_CREDENTIALS_FILE", "") + if _, err := LoadConfig(); err == nil { + t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE is missing") + } +} + +func TestLoadConfigRejectsMissingCredentialsFile(t *testing.T) { + t.Setenv("GOOGLE_SHEETS_ID", "sheet-123") + t.Setenv("GOOGLE_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "does-not-exist.json")) + if _, err := LoadConfig(); err == nil { + t.Fatal("expected error when GOOGLE_CREDENTIALS_FILE points to a nonexistent file") + } +} + func TestLoadConfigRejectsBadInterval(t *testing.T) { t.Setenv("GOOGLE_SHEETS_ID", "sheet-123") + setCredFile(t) t.Setenv("SYNC_INTERVAL", "not-a-duration") if _, err := LoadConfig(); err == nil { t.Fatal("expected error for unparseable SYNC_INTERVAL") @@ -51,6 +83,7 @@ func TestLoadConfigRejectsBadInterval(t *testing.T) { func TestLoadConfigRejectsNonPositiveInterval(t *testing.T) { t.Setenv("GOOGLE_SHEETS_ID", "sheet-123") + setCredFile(t) t.Setenv("SYNC_INTERVAL", "0s") if _, err := LoadConfig(); err == nil { t.Fatal("expected error for non-positive SYNC_INTERVAL") @@ -74,7 +107,6 @@ func TestLoadReminderConfigParses(t *testing.T) { t.Setenv("GROUP_CHAT_ID", "-5572351978") t.Setenv("REMINDER_WEEKDAY", "Friday") t.Setenv("REMINDER_HOUR", "18") - t.Setenv("DUTY_AHEAD_DAYS", "7") r, err := loadReminderConfig() if err != nil { @@ -86,8 +118,8 @@ func TestLoadReminderConfigParses(t *testing.T) { if r.ChatID != -5572351978 { t.Errorf("ChatID = %d, want -5572351978", r.ChatID) } - if r.Weekday != time.Friday || r.Hour != 18 || r.AheadDays != 7 { - t.Errorf("got %v %d ahead=%d, want Friday 18 ahead=7", r.Weekday, r.Hour, r.AheadDays) + if r.Weekday != time.Friday || r.Hour != 18 { + t.Errorf("got %v %d, want Friday 18", r.Weekday, r.Hour) } if r.Loc == nil || r.Loc.String() != "Europe/Warsaw" { t.Errorf("Loc = %v, want Europe/Warsaw", r.Loc) @@ -98,14 +130,13 @@ func TestLoadReminderConfigDefaults(t *testing.T) { t.Setenv("GROUP_CHAT_ID", "42") t.Setenv("REMINDER_WEEKDAY", "") t.Setenv("REMINDER_HOUR", "") - t.Setenv("DUTY_AHEAD_DAYS", "") r, err := loadReminderConfig() if err != nil { t.Fatalf("loadReminderConfig: %v", err) } - if r.Weekday != time.Monday || r.Hour != 9 || r.AheadDays != 14 { - t.Errorf("defaults: got %v %d ahead=%d, want Monday 9 ahead=14", r.Weekday, r.Hour, r.AheadDays) + if r.Weekday != time.Monday || r.Hour != 9 { + t.Errorf("defaults: got %v %d, want Monday 9", r.Weekday, r.Hour) } } @@ -118,7 +149,6 @@ func TestLoadReminderConfigRejects(t *testing.T) { {"bad chat id", "GROUP_CHAT_ID", "not-a-number"}, {"hour too high", "REMINDER_HOUR", "24"}, {"hour negative", "REMINDER_HOUR", "-1"}, - {"ahead negative", "DUTY_AHEAD_DAYS", "-1"}, {"bad weekday", "REMINDER_WEEKDAY", "Funday"}, } for _, tc := range tests { @@ -126,7 +156,6 @@ func TestLoadReminderConfigRejects(t *testing.T) { t.Setenv("GROUP_CHAT_ID", "42") // enabled by default; case may override t.Setenv("REMINDER_WEEKDAY", "Monday") t.Setenv("REMINDER_HOUR", "9") - t.Setenv("DUTY_AHEAD_DAYS", "14") t.Setenv(tc.key, tc.val) if _, err := loadReminderConfig(); err == nil { @@ -152,6 +181,19 @@ func TestSyncSuccessReplacesCache(t *testing.T) { } } +func TestAssignRange(t *testing.T) { + now := time.Date(2026, 6, 19, 9, 0, 0, 0, time.UTC) + + // Empty range -> current-year tab; the assignment targets cols B:C of the row. + if got, want := assignRange("", now, 5), "2026!B5:C5"; got != want { + t.Errorf("assignRange(\"\"): got %q, want %q", got, want) + } + // Explicit range -> its tab is reused verbatim. + if got, want := assignRange("Dyżury!A1:C30", now, 7), "Dyżury!B7:C7"; got != want { + t.Errorf("assignRange(explicit): got %q, want %q", got, want) + } +} + func TestResolveRange(t *testing.T) { now := time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) diff --git a/sheet.go b/sheet.go index 8b78bf0..1b2d066 100644 --- a/sheet.go +++ b/sheet.go @@ -28,6 +28,10 @@ type DutyWeek struct { WeekStart time.Time // Monday that starts the week Person1 string // Osoba 1 — "" if unassigned Person2 string // Osoba 2 — "" if unassigned + // Row is the 1-based sheet row this week occupies, used to address its + // cells when writing an assignment back. Valid only when the read range + // starts at A1 (as resolveRange always produces); 0 means unknown. + Row int } // Person is a single entry from the people list (columns E–G). @@ -43,20 +47,37 @@ type Rota struct { People []Person } -// ReadSheet reads the given range from the sheet and returns its rows. -// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30". -// The API key is read from GOOGLE_API_KEY (loaded into the environment by -// LoadConfig, which calls godotenv.Load at startup). -func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) { - apiKey := os.Getenv("GOOGLE_API_KEY") - if apiKey == "" { - return nil, fmt.Errorf("missing GOOGLE_API_KEY in environment/.env") +// newSheetsService builds an authenticated Sheets client using the +// service-account key file named by GOOGLE_CREDENTIALS_FILE (loaded into the +// environment by LoadConfig, which calls godotenv.Load at startup). The +// spreadsheets scope grants read+write; the sheet must be shared with the +// service account's client_email for either to work. +func newSheetsService(ctx context.Context) (*sheets.Service, error) { + credPath := os.Getenv("GOOGLE_CREDENTIALS_FILE") + if credPath == "" { + return nil, fmt.Errorf("missing GOOGLE_CREDENTIALS_FILE in environment/.env") } - srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey)) + // WithAuthCredentialsFile pins the credential type to ServiceAccount, which + // is the non-deprecated replacement for WithCredentialsFile: it refuses an + // unexpected credential type instead of loading it blindly. + srv, err := sheets.NewService(ctx, + option.WithAuthCredentialsFile(option.ServiceAccount, credPath), + option.WithScopes(sheets.SpreadsheetsScope), // read+write + ) if err != nil { return nil, fmt.Errorf("init sheets service: %w", err) } + return srv, nil +} + +// ReadSheet reads the given range from the sheet and returns its rows. +// spreadsheetID is the ID from the sheet URL, readRange e.g. "Dyżury!A1:C30". +func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interface{}, error) { + srv, err := newSheetsService(ctx) + if err != nil { + return nil, err + } resp, err := srv.Spreadsheets.Values. Get(spreadsheetID, readRange). @@ -70,6 +91,30 @@ func ReadSheet(ctx context.Context, spreadsheetID, readRange string) ([][]interf return resp.Values, nil } +// WriteSheet overwrites the cells starting at writeRange with values. writeRange +// anchors the top-left cell (e.g. "Dyżury!G2"); values is row-major, so each +// inner slice is one row written left-to-right. RAW input stores strings and +// numbers verbatim — a leading "=" is not interpreted as a formula. The +// service-account credential must have write access and the sheet be shared with +// it (see newSheetsService). +func WriteSheet(ctx context.Context, spreadsheetID, writeRange string, values [][]interface{}) error { + srv, err := newSheetsService(ctx) + if err != nil { + return err + } + + _, err = srv.Spreadsheets.Values. + Update(spreadsheetID, writeRange, &sheets.ValueRange{Values: values}). + ValueInputOption("RAW"). + Context(ctx). + Do() + if err != nil { + return fmt.Errorf("update values %q: %w", writeRange, err) + } + + return nil +} + // ParseRota interprets the raw rows from ReadSheet as two independent tables: // the duty schedule (columns A–C) and the people list (columns E–G). // The header row is skipped; malformed cells are ignored, not reported. @@ -88,6 +133,7 @@ func ParseRota(rows [][]interface{}) Rota { WeekStart: serialToDate(serial), Person1: cellString(row, colPerson1), Person2: cellString(row, colPerson2), + Row: i + 1, // slice index -> 1-based sheet row (range starts at A1) }) } diff --git a/sheet_test.go b/sheet_test.go index 4432dae..098d852 100644 --- a/sheet_test.go +++ b/sheet_test.go @@ -44,6 +44,19 @@ func TestParseRotaDecodesWeekStartToMonday(t *testing.T) { } } +func TestParseRotaTracksSheetRow(t *testing.T) { + rota := ParseRota(sampleRows()) + + // The read range starts at A1, so a week at slice index i sits on sheet row + // i+1. The four weeks come from indices 1..4 -> rows 2..5. + wantRows := []int{2, 3, 4, 5} + for i, want := range wantRows { + if got := rota.Weeks[i].Row; got != want { + t.Errorf("week %d: Row = %d, want %d", i, got, want) + } + } +} + func TestParseRotaWeekPersons(t *testing.T) { rota := ParseRota(sampleRows())