dyzur-bot/telegram.go

224 lines
8.1 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 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 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
}