refactor: address review findings (slog, fetch timeout, Docker)

- add per-request timeout to the Sheets fetch so a hung call can't block /sync
- stop leaking raw sync errors to chat; log detail, reply generically
- migrate logging from log to structured log/slog
- drop duplicate godotenv.Load from ReadSheet (LoadConfig loads .env at startup)
- add multi-stage distroless Dockerfile + .dockerignore; embed tzdata via import
- document single-instance long-polling constraint in README
- translate sheet.go comments to English
- move package doc to doc.go; remove superseded root main.go and live_test.go
This commit is contained in:
Kamil 'Kamaś' Bruchal 2026-06-18 21:38:37 +02:00
parent 881bd5be03
commit 2e75d15f9d
11 changed files with 453 additions and 57 deletions

17
.dockerignore Normal file
View File

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

View File

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

View File

@ -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 `SYNC_INTERVAL` (default 30m), and runs the Telegram bot via long polling until
interrupted (Ctrl-C / SIGTERM). 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 ### Commands
- `/kto_sprzata` — who is on cleaning duty this week. - `/kto_sprzata` — who is on cleaning duty this week.

82
cmd/dyzur-bot/main.go Normal file
View File

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

View File

View File

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

View File

@ -3,7 +3,7 @@ package dyzurbot
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"strings" "strings"
"time" "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) target := r.now().In(r.cfg.Loc).AddDate(0, 0, r.cfg.AheadDays)
text, ok := reminderText(rota, target) text, ok := reminderText(rota, target)
if !ok { 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 return
} }
if err := r.send(ctx, text); err != nil { if err := r.send(ctx, text); err != nil {
log.Printf("reminder: send failed: %v", err) slog.Error("reminder send failed", "err", err)
} }
} }

View File

@ -3,7 +3,7 @@ package dyzurbot
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"strconv" "strconv"
"time" "time"
@ -140,7 +140,7 @@ func NewSyncer(store *Store, fetch FetchFunc) *Syncer {
func (sy *Syncer) Sync(ctx context.Context) error { func (sy *Syncer) Sync(ctx context.Context) error {
rota, err := sy.fetch(ctx) rota, err := sy.fetch(ctx)
if err != nil { 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 err
} }
return sy.store.Replace(rota, sy.now()) 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. // SheetFetcher builds a FetchFunc that reads the rota from Google Sheets.
func SheetFetcher(spreadsheetID, readRange string) FetchFunc { func SheetFetcher(spreadsheetID, readRange string) FetchFunc {
return func(ctx context.Context) (Rota, error) { return func(ctx context.Context) (Rota, error) {
ctx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
rows, err := ReadSheet(ctx, spreadsheetID, readRange) rows, err := ReadSheet(ctx, spreadsheetID, readRange)
if err != nil { if err != nil {
return Rota{}, err return Rota{}, err

133
sheet.go Normal file
View File

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

View File

@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@ -45,7 +45,7 @@ func (s *Store) Load() error {
var p persisted var p persisted
if err := json.Unmarshal(data, &p); err != nil { 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 return nil
} }

155
telegram.go Normal file
View File

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