feat: auto-assign empty duty weeks via service-account sheet writes

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.
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-19 03:48:59 +02:00
parent 825331dce5
commit 6573144b14
12 changed files with 670 additions and 68 deletions

View File

@ -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

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
.env
rota.json
hackerspace-492302-a63993a1af9d.json

View File

@ -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`, 023), `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`, 023), 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.

View File

@ -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)")
}

91
live_auth_check_test.go Normal file
View File

@ -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)
}

View File

@ -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 (023) 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 (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
// 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{

59
scheduler_live_test.go Normal file
View File

@ -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)
}

View File

@ -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 {

View File

@ -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 023, 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

View File

@ -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)

View File

@ -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 EG).
@ -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 AC) and the people list (columns EG).
// 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)
})
}

View File

@ -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())