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:
parent
881bd5be03
commit
2e75d15f9d
|
|
@ -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
|
||||||
35
Dockerfile
35
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"]
|
||||||
17
README.md
17
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
|
`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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
50
live_test.go
50
live_test.go
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
service.go
11
service.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
4
store.go
4
store.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue