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 }