package dyzurbot import ( "context" "fmt" "log/slog" "strings" "sync" "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 the bot_command entity we match against stops // at a hyphen (/kto-sprzata -> entity "/kto"), so a hyphen would 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} b, err := bot.New(token, bot.WithDefaultHandler(h.unknown)) if err != nil { return nil, err } // Custom match funcs instead of bot.MatchTypeCommand: in group chats Telegram // appends the addressee (/sync@hs_dyzur_bot) inside the bot_command entity, // and the library's MatchTypeCommand compares the whole token verbatim, so it // never matches. matchCommandEntity strips the @suffix first. b.RegisterHandlerMatchFunc(h.matchCommand(cmdKtoSprzata), h.ktoSprzata) b.RegisterHandlerMatchFunc(h.matchCommand(cmdSync), h.sync) return b, nil } // matchCommand builds a MatchFunc that fires when the message leads with the // given bot command, tolerant of the @botusername suffix Telegram adds in groups. func (h *botHandlers) matchCommand(cmd string) bot.MatchFunc { return func(update *models.Update) bool { return update.Message != nil && matchCommandEntity(update.Message.Text, update.Message.Entities, cmd) } } // matchCommandEntity reports whether text carries a bot_command entity whose // name equals cmd. The name is taken from the entity span (dropping the leading // '/') with any @botusername suffix removed — Telegram includes that suffix in // the entity for group messages, but a bot still owns the bare command. Note: a // command explicitly addressed to another bot (/sync@otherbot) also matches; // acceptable for a single-bot group, the only place this bot runs. func matchCommandEntity(text string, entities []models.MessageEntity, cmd string) bool { for _, e := range entities { if e.Type != models.MessageEntityTypeBotCommand { continue } name := text[e.Offset+1 : e.Offset+e.Length] if at := strings.IndexByte(name, '@'); at >= 0 { name = name[:at] } if name == cmd { return true } } return false } // 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 } // syncCooldown is the minimum gap between manual /sync triggers. It throttles // spam — each sync is an outbound Google Sheets call — without getting in the // way of legitimate use (the background syncer already refreshes on its own // interval, so manual syncs are only for impatience). const syncCooldown = time.Minute // botHandlers holds the dependencies the command handlers close over. type botHandlers struct { svc *Service now func() time.Time // mu guards lastSync: Telegram dispatches each update in its own goroutine, // so concurrent /sync commands race on the cooldown check. mu sync.Mutex lastSync time.Time // zero until the first /sync attempt } // allowSync reports whether a /sync may run now, recording the attempt time // when it may. The clock is reset on every allowed attempt (not just successful // syncs) so a failing endpoint can't be hammered. When denied it returns how // long the caller must wait. func (h *botHandlers) allowSync(now time.Time) (bool, time.Duration) { h.mu.Lock() defer h.mu.Unlock() if !h.lastSync.IsZero() { if elapsed := now.Sub(h.lastSync); elapsed < syncCooldown { return false, syncCooldown - elapsed } } h.lastSync = now return true, 0 } // 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 ok, wait := h.allowSync(h.now()); !ok { h.reply(ctx, b, update, fmt.Sprintf( "Synchronizowano niedawno. Spróbuj ponownie za %ds.", int(wait.Seconds())+1)) return } 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 update the command matchers don't claim. // It never replies: the bot answers only its predefined commands and ignores // everything else — mistyped /commands, other bots' commands, plain chatter, // service messages (joins/pins), and media. Unrecognized commands are logged // at debug level so they stay visible without adding group noise. func (h *botHandlers) unknown(_ context.Context, _ *bot.Bot, update *models.Update) { if update.Message == nil || !hasCommandEntity(update.Message.Entities) { return } slog.Debug("ignoring unknown command", "text", update.Message.Text) } // hasCommandEntity reports whether the message carries any bot_command entity, // i.e. the user typed a /command (recognized or not) rather than plain text. func hasCommandEntity(entities []models.MessageEntity) bool { for _, e := range entities { if e.Type == models.MessageEntityTypeBotCommand { return true } } return false } // 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 }