dyzur-bot/telegram.go

156 lines
5.4 KiB
Go

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
}