diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f37e2c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Secrets and local state — never bake into the build context/image. +.env +rota.json + +# VCS and tooling. +.git +.gitignore +.golangci.yml +.markdownlint-cli2.yaml +.pre-commit-config.yaml +.claude + +# Docs and the Dockerfile itself. +docs +README.md +Dockerfile +.dockerignore diff --git a/Dockerfile b/Dockerfile index e69de29..166621b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 + +# --- Build stage ----------------------------------------------------------- +FROM golang:1.26-alpine AS build +WORKDIR /src + +# Download modules first so the layer caches unless go.mod/go.sum change. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Static, stripped binary. CGO is off so it runs on a scratch/distroless base. +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dyzur-bot ./cmd/dyzur-bot + +# Writable cache dir, created here so it can be copied in with nonroot ownership. +RUN mkdir /data + +# --- Runtime stage --------------------------------------------------------- +# distroless static includes CA certificates (needed for HTTPS to Google) and +# runs as the unprivileged "nonroot" user by default. Timezone data is embedded +# in the binary via the time/tzdata import, so no system zoneinfo is required. +FROM gcr.io/distroless/static:nonroot +COPY --from=build /out/dyzur-bot /usr/local/bin/dyzur-bot +# 65532 is the distroless "nonroot" uid/gid; use the numeric form so it resolves +# without depending on /etc/passwd name lookup in the target image. +COPY --from=build --chown=65532:65532 /data /data + +# Persist the rota cache to /data — mount a volume here to survive restarts. +ENV ROTA_CACHE_PATH=/data/rota.json +WORKDIR /data + +# Long polling: run EXACTLY ONE instance per bot token. Two concurrent pollers +# get HTTP 409 Conflict from Telegram and drop updates. +ENTRYPOINT ["/usr/local/bin/dyzur-bot"] diff --git a/README.md b/README.md index 5506c78..f4129c9 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,23 @@ The service loads the cached rota, syncs from Google Sheets in the background on `SYNC_INTERVAL` (default 30m), and runs the Telegram bot via long polling until interrupted (Ctrl-C / SIGTERM). +> **Run exactly one instance per bot token.** The bot uses long polling +> (`getUpdates`); two concurrent pollers on the same token make Telegram return +> HTTP 409 Conflict and updates get dropped. Do not scale this to multiple +> replicas. + +### Docker + +```sh +docker build -t dyzur-bot . +docker run --rm --env-file .env -v dyzur-data:/data dyzur-bot +``` + +The image is a static binary on `distroless` (timezone data is embedded, so +reminders work without system `tzdata`). The rota cache lives at +`/data/rota.json` (`ROTA_CACHE_PATH`); mount a volume at `/data` to persist it +across restarts. + ### Commands - `/kto_sprzata` — who is on cleaning duty this week. diff --git a/cmd/dyzur-bot/main.go b/cmd/dyzur-bot/main.go new file mode 100644 index 0000000..9440dc7 --- /dev/null +++ b/cmd/dyzur-bot/main.go @@ -0,0 +1,82 @@ +// Command dyzur-bot runs the duty-rota service: it boots the local cache and +// the background Google Sheets syncer, then stays up until interrupted. The +// Telegram/reminder layer plugs in here once it exists. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + // Embed the IANA timezone database so time.LoadLocation("Europe/Warsaw") + // (used by the reminder scheduler) works in minimal containers that ship + // no system zoneinfo (scratch / distroless static). + _ "time/tzdata" + + dyzurbot "git.kambr.pl/kamash/dyzur-bot.git" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil))) + if err := run(); err != nil { + slog.Error("dyzur-bot fatal", "err", err) + os.Exit(1) + } +} + +// run holds the fallible startup logic so main stays a thin wrapper. Funneling +// every exit through here keeps deferred cleanup (e.g. stop) honest: main calls +// os.Exit only after run has returned, so run's deferreds always execute. +func run() error { + // One cancellation source for the whole tree: this ctx is threaded through + // Service.Start, Syncer.Run, and every ReadSheet call, so a single signal + // unwinds all background work. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + cfg, err := dyzurbot.LoadConfig() + if err != nil { + return err + } + + svc := dyzurbot.NewService(cfg) + if err := svc.Start(ctx); err != nil { + return err + } + + token := os.Getenv("BOT_TOKEN") + if token == "" { + return errors.New("missing BOT_TOKEN in environment/.env") + } + b, err := dyzurbot.NewBot(token, svc) + if err != nil { + return fmt.Errorf("init telegram bot: %w", err) + } + + // Best-effort: publishing the command menu is a nicety, not a precondition + // for serving commands, so a failure is logged rather than fatal. + if err := dyzurbot.PublishCommands(ctx, b); err != nil { + slog.Warn("could not publish command list", "err", err) + } + + if r := cfg.Reminder; r != nil { + send := dyzurbot.BotSendFunc(b, r.ChatID) + go dyzurbot.NewReminder(svc.Store, send, *r).Run(ctx) + slog.Info("reminders on", + "weekday", r.Weekday, "hour", r.Hour, "tz", r.Loc.String(), + "days_ahead", r.AheadDays, "chat", r.ChatID) + } else { + slog.Info("reminders disabled (no GROUP_CHAT_ID)") + } + + slog.Info("up, long polling", "spreadsheet", cfg.SpreadsheetID, "sync_interval", cfg.SyncInterval) + + // Start long-polls (getUpdates) and blocks until ctx is canceled by a signal. + b.Start(ctx) + slog.Info("signal received, shutting down") + return nil +} diff --git a/main.go b/doc.go similarity index 100% rename from main.go rename to doc.go diff --git a/live_test.go b/live_test.go deleted file mode 100644 index 8c2a209..0000000 --- a/live_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package dyzurbot - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -// TestLiveFetch exercises the full production path — LoadConfig reads the -// environment/.env, NewService wires the Store + Syncer, and Syncer.Sync hits -// the live Google Sheets API, parses the result, and persists it to the cache -// file via Store.Replace. The resulting JSON is left on disk (CachePath, -// default rota.json) for inspection. -// -// It is skipped unless RUN_LIVE=1, so the normal `go test ./...` run stays -// offline and fast. -// -// RUN_LIVE=1 go test -run TestLiveFetch -v . -// -// Requires GOOGLE_API_KEY and GOOGLE_SHEETS_ID in the environment/.env. -func TestLiveFetch(t *testing.T) { - if os.Getenv("RUN_LIVE") != "1" { - t.Skip("set RUN_LIVE=1 to run the live Google Sheets fetch") - } - - cfg, err := LoadConfig() - if err != nil { - t.Fatalf("LoadConfig: %v", err) - } - t.Logf("config: spreadsheet=%s range=%q cache=%s", - cfg.SpreadsheetID, cfg.SheetRange, cfg.CachePath) - - svc := NewService(cfg) - if err := svc.Syncer.Sync(context.Background()); err != nil { - t.Fatalf("sync: %v", err) - } - - rota, lastSynced := svc.Store.Snapshot() - t.Logf("synced %d weeks, %d people at %s", - len(rota.Weeks), len(rota.People), lastSynced.Format("2006-01-02 15:04:05")) - - // Confirm the JSON actually landed on disk and show where. - written, err := os.ReadFile(cfg.CachePath) - if err != nil { - t.Fatalf("read cache %q: %v", cfg.CachePath, err) - } - abs, _ := filepath.Abs(cfg.CachePath) - t.Logf("wrote cache JSON (%d bytes) to %s", len(written), abs) -} diff --git a/scheduler.go b/scheduler.go index 7cec432..1943268 100644 --- a/scheduler.go +++ b/scheduler.go @@ -3,7 +3,7 @@ package dyzurbot import ( "context" "fmt" - "log" + "log/slog" "strings" "time" ) @@ -62,11 +62,11 @@ func (r *Reminder) fire(ctx context.Context) { target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays) text, ok := reminderText(rota, target) if !ok { - log.Printf("reminder: no duty week for %s, skipping", target.Format("2006-01-02")) + slog.Info("reminder: no duty week, skipping", "target", target.Format("2006-01-02")) return } if err := r.send(ctx, text); err != nil { - log.Printf("reminder: send failed: %v", err) + slog.Error("reminder send failed", "err", err) } } diff --git a/service.go b/service.go index f2c8a76..eb3b7e9 100644 --- a/service.go +++ b/service.go @@ -3,7 +3,7 @@ package dyzurbot import ( "context" "fmt" - "log" + "log/slog" "os" "strconv" "time" @@ -140,7 +140,7 @@ func NewSyncer(store *Store, fetch FetchFunc) *Syncer { func (sy *Syncer) Sync(ctx context.Context) error { rota, err := sy.fetch(ctx) if err != nil { - log.Printf("sync: fetch failed, keeping cached data: %v", err) + slog.Warn("sync fetch failed, keeping cached data", "err", err) return err } return sy.store.Replace(rota, sy.now()) @@ -160,9 +160,16 @@ func (sy *Syncer) Run(ctx context.Context, interval time.Duration) { } } +// fetchTimeout bounds a single Sheets fetch so a hung HTTP call can't block a +// caller (e.g. a /sync command) indefinitely — it would otherwise wait until +// the parent context is canceled at shutdown. +const fetchTimeout = 30 * time.Second + // SheetFetcher builds a FetchFunc that reads the rota from Google Sheets. func SheetFetcher(spreadsheetID, readRange string) FetchFunc { return func(ctx context.Context) (Rota, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() rows, err := ReadSheet(ctx, spreadsheetID, readRange) if err != nil { return Rota{}, err diff --git a/sheet.go b/sheet.go new file mode 100644 index 0000000..b7e701c --- /dev/null +++ b/sheet.go @@ -0,0 +1,133 @@ +package dyzurbot + +import ( + "context" + "fmt" + "os" + "time" + + "google.golang.org/api/option" + "google.golang.org/api/sheets/v4" +) + +// Column indices in the "Dyżury" sheet (see the header in row 0). +const ( + colWeek = 0 // "Tydzień zaczynający się od" — week start date (serial) + colPerson1 = 1 // "Osoba 1" + colPerson2 = 2 // "Osoba 2" + colName = 4 // "Lista osób" + colCount = 5 // "Liczba dyżurów" +) + +// sheetEpoch is the date corresponding to serial number 0 in Google Sheets. +var sheetEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC) + +// DutyWeek is a single week of the duty rota (columns A–C). +type DutyWeek struct { + WeekStart time.Time // Monday that starts the week + Person1 string // Osoba 1 — "" if unassigned + Person2 string // Osoba 2 — "" if unassigned +} + +// Person is a single entry from the people list (columns E–F). +type Person struct { + Name string // Lista osób + DutyCount int // Liczba dyżurów +} + +// Rota is the parsed sheet contents: the week schedule and the people list. +type Rota struct { + Weeks []DutyWeek + 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") + } + + srv, err := sheets.NewService(ctx, option.WithAPIKey(apiKey)) + if err != nil { + return nil, fmt.Errorf("init sheets service: %w", err) + } + + resp, err := srv.Spreadsheets.Values. + Get(spreadsheetID, readRange). + ValueRenderOption("UNFORMATTED_VALUE"). // raw values, without cell formatting + Context(ctx). + Do() + if err != nil { + return nil, fmt.Errorf("get values %q: %w", readRange, err) + } + + return resp.Values, nil +} + +// ParseRota interprets the raw rows from ReadSheet as two independent tables: +// the duty schedule (columns A–C) and the people list (columns E–F). +// The header row is skipped; malformed cells are ignored, not reported. +func ParseRota(rows [][]interface{}) Rota { + var rota Rota + + for i, row := range rows { + if i == 0 { + continue // header + } + + // Schedule: a row belongs to the weeks table if column A holds a + // date serial number. + if serial, ok := cellFloat(row, colWeek); ok { + rota.Weeks = append(rota.Weeks, DutyWeek{ + WeekStart: serialToDate(serial), + Person1: cellString(row, colPerson1), + Person2: cellString(row, colPerson2), + }) + } + + // People list: add only non-empty entries (empty roster slots are + // skipped). + if name := cellString(row, colName); name != "" { + count, _ := cellFloat(row, colCount) + rota.People = append(rota.People, Person{ + Name: name, + DutyCount: int(count), + }) + } + } + + return rota +} + +// serialToDate converts a Google Sheets serial number to a date. +func serialToDate(serial float64) time.Time { + return sheetEpoch.AddDate(0, 0, int(serial)) +} + +// cellString returns the cell value as text, accounting for trailing empty +// cells that the API truncates. A missing cell yields "". +func cellString(row []interface{}, i int) string { + if i >= len(row) { + return "" + } + if s, ok := row[i].(string); ok { + return s + } + return "" +} + +// cellFloat returns the numeric value of a cell. ok=false when the cell is +// missing or not a number (e.g. an empty string). +func cellFloat(row []interface{}, i int) (float64, bool) { + if i >= len(row) { + return 0, false + } + if f, ok := row[i].(float64); ok { + return f, true + } + return 0, false +} diff --git a/store.go b/store.go index 88b14bd..40b1f94 100644 --- a/store.go +++ b/store.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "log" + "log/slog" "os" "path/filepath" "sync" @@ -45,7 +45,7 @@ func (s *Store) Load() error { var p persisted if err := json.Unmarshal(data, &p); err != nil { - log.Printf("load cache %q: corrupt, ignoring: %v", s.path, err) + slog.Warn("cache corrupt, ignoring", "path", s.path, "err", err) return nil } diff --git a/telegram.go b/telegram.go new file mode 100644 index 0000000..18ad028 --- /dev/null +++ b/telegram.go @@ -0,0 +1,155 @@ +package dyzurbot + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// Command names, the single source of truth shared by the handler registration +// and the published command list. Underscore, not hyphen: Telegram command +// names are [a-zA-Z0-9_] only, and MatchTypeCommand matches the parsed +// bot_command entity, which would stop at a hyphen (/kto-sprzata -> entity +// "/kto") and never match. +const ( + cmdKtoSprzata = "kto_sprzata" + cmdSync = "sync" +) + +// botCommands is published to Telegram so the commands appear in the client's +// command menu / autocomplete. Descriptions are in Polish to match the replies. +var botCommands = []models.BotCommand{ + {Command: cmdKtoSprzata, Description: "Kto sprząta w tym tygodniu"}, + {Command: cmdSync, Description: "Odśwież grafik z Google Sheets"}, +} + +// NewBot builds the Telegram bot wired to the rota service. Handlers read the +// cache (Store.Snapshot) and trigger syncs (Syncer.Sync). bot.New validates +// the token with a getMe call, so an invalid/missing token fails here rather +// than silently 401-ing inside the poll loop. Long polling starts on Start(ctx). +func NewBot(token string, svc *Service) (*bot.Bot, error) { + h := &botHandlers{svc: svc, now: time.Now} + opts := []bot.Option{ + bot.WithDefaultHandler(h.unknown), + bot.WithMessageTextHandler(cmdKtoSprzata, bot.MatchTypeCommand, h.ktoSprzata), + bot.WithMessageTextHandler(cmdSync, bot.MatchTypeCommand, h.sync), + } + return bot.New(token, opts...) +} + +// BotSendFunc builds a SendFunc that posts to a fixed chat via the bot, so the +// reminder scheduler can send without importing the telegram package directly. +func BotSendFunc(b *bot.Bot, chatID int64) SendFunc { + return func(ctx context.Context, text string) error { + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ChatID: chatID, Text: text}) + return err + } +} + +// PublishCommands registers the command list with Telegram so the commands show +// up in the client's command menu and autocomplete. Best-effort: the caller +// should log a failure rather than treat it as fatal — the bot still answers +// commands typed manually without a published menu. +func PublishCommands(ctx context.Context, b *bot.Bot) error { + _, err := b.SetMyCommands(ctx, &bot.SetMyCommandsParams{Commands: botCommands}) + return err +} + +// botHandlers holds the dependencies the command handlers close over. +type botHandlers struct { + svc *Service + now func() time.Time +} + +// ktoSprzata answers /kto_sprzata with this week's duty from the cache. +func (h *botHandlers) ktoSprzata(ctx context.Context, b *bot.Bot, update *models.Update) { + rota, _ := h.svc.Store.Snapshot() + week, ok := currentDuty(rota, h.now()) + h.reply(ctx, b, update, formatDuty(week, ok)) +} + +// sync answers /sync by triggering a fetch and reporting the outcome. Sync +// keeps the last-good cache on failure, so an error here is informational. +func (h *botHandlers) sync(ctx context.Context, b *bot.Bot, update *models.Update) { + if err := h.svc.Syncer.Sync(ctx); err != nil { + // Log the detailed error; show the chat a generic message so internal + // details (API endpoints, keys in error strings) don't leak. + slog.Error("manual sync failed", "err", err) + h.reply(ctx, b, update, "Synchronizacja nie powiodła się. Spróbuj ponownie później.") + return + } + rota, _ := h.svc.Store.Snapshot() + h.reply(ctx, b, update, fmt.Sprintf( + "Zsynchronizowano: %d tygodni, %d osób.", len(rota.Weeks), len(rota.People))) +} + +// unknown is the fallback for any text the bot does not recognize. +func (h *botHandlers) unknown(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.Message == nil { + return + } + h.reply(ctx, b, update, "Nieznana komenda. Użyj /kto_sprzata lub /sync.") +} + +// reply sends text back to the originating chat, logging send failures. +func (h *botHandlers) reply(ctx context.Context, b *bot.Bot, update *models.Update, text string) { + if update.Message == nil { + return + } + if _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: text, + }); err != nil { + slog.Error("telegram send failed", "err", err) + } +} + +// currentDuty returns the duty week containing today, matched on the local +// calendar date: WeekStart <= today < WeekStart+7d. Both sides are reduced to +// their date (sheet dates are UTC midnight; today is typically local), so the +// comparison is timezone-agnostic and won't slip a day at midnight. +func currentDuty(rota Rota, today time.Time) (DutyWeek, bool) { + d := dateOnly(today) + for _, w := range rota.Weeks { + start := dateOnly(w.WeekStart) + end := start.AddDate(0, 0, 7) + if !d.Before(start) && d.Before(end) { + return w, true + } + } + return DutyWeek{}, false +} + +// dateOnly drops the clock time, keeping the calendar date from t's own +// location as a UTC-anchored day so dates can be compared directly. +func dateOnly(t time.Time) time.Time { + y, m, d := t.Date() + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +// formatDuty renders the Polish reply for /kto_sprzata. +func formatDuty(week DutyWeek, ok bool) string { + if !ok { + return "Nie znalazłem dyżuru na ten tydzień." + } + + var people []string + if week.Person1 != "" { + people = append(people, week.Person1) + } + if week.Person2 != "" { + people = append(people, week.Person2) + } + who := "brak przypisania" + if len(people) > 0 { + who = strings.Join(people, " i ") + } + + return "Dyżur w tym tygodniu (od " + + week.WeekStart.Format("2006-01-02") + "): " + who +}