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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
11
service.go
11
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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