189 lines
6.7 KiB
Go
189 lines
6.7 KiB
Go
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 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
|
|
}
|
|
|
|
// 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 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
|
|
}
|