package handler import ( "context" "fmt" "log/slog" "strings" "sync" "time" tgbot "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" "worktimeBot/internal/domain" "worktimeBot/internal/repo" ) const rateLimitInterval = 1 * time.Second type PendingKind string const ( PendingNote PendingKind = "note" PendingRate PendingKind = "rate" PendingCurrency PendingKind = "currency" PendingTimezone PendingKind = "timezone" PendingBreak PendingKind = "break_threshold" PendingUsername PendingKind = "username" PendingReportTime PendingKind = "report_time" ) type PendingInput struct { Kind PendingKind UserID int64 CreatedAt time.Time MsgID int Data map[string]string } type Handler struct { Bot *tgbot.Bot Repo repo.Repository AllowedUsers map[int64]bool lastMsgTime map[int64]time.Time pendingInput map[int64]*PendingInput mu sync.Mutex } func NewHandler(bot *tgbot.Bot, store repo.Repository, allowed map[int64]bool) *Handler { h := &Handler{ Bot: bot, Repo: store, AllowedUsers: allowed, lastMsgTime: make(map[int64]time.Time), pendingInput: make(map[int64]*PendingInput), } go h.periodicCleanup() return h } func (h *Handler) periodicCleanup() { for { time.Sleep(10 * time.Second) h.mu.Lock() cutoff := time.Now().Add(-rateLimitInterval * 2) for uid, t := range h.lastMsgTime { if t.Before(cutoff) { delete(h.lastMsgTime, uid) } } pendingCutoff := time.Now().Add(-5 * time.Minute) for chatID, pi := range h.pendingInput { if pi.CreatedAt.Before(pendingCutoff) { delete(h.pendingInput, chatID) } } h.mu.Unlock() } } func (h *Handler) isRateLimited(userID int64) bool { h.mu.Lock() defer h.mu.Unlock() now := time.Now() if last, ok := h.lastMsgTime[userID]; ok && now.Sub(last) < rateLimitInterval { return true } h.lastMsgTime[userID] = now return false } func (h *Handler) getOrCreateUser(chatID int64) (*domain.User, error) { return h.Repo.GetOrCreateUser(chatID) } func (h *Handler) loadLocation(tz string) *time.Location { loc, err := time.LoadLocation(tz) if err != nil { return time.UTC } return loc } func (h *Handler) HandleMessage(ctx context.Context, msg *models.Message) { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { return } if msg.Text == "" { return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { return } if h.isRateLimited(user.ID) { return } slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) if msg.Text[0] == '/' { h.mu.Lock() delete(h.pendingInput, msg.Chat.ID) h.mu.Unlock() h.routeCommand(ctx, msg, user) return } h.mu.Lock() pi, hasPI := h.pendingInput[msg.Chat.ID] if hasPI { delete(h.pendingInput, msg.Chat.ID) } h.mu.Unlock() if hasPI { h.processPendingInput(ctx, msg, user, pi) return } h.sendText(ctx, msg.Chat.ID, "Unknown command") } func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, user *domain.User) { cmd := msg.Text if i := strings.IndexByte(msg.Text, ' '); i >= 0 { cmd = msg.Text[:i] } switch cmd { case "/start": h.handleStart(ctx, msg, user) case "/help": h.handleHelp(ctx, msg) case "/clockin": h.handleClockIn(ctx, msg, user) case "/clockout": h.handleClockOut(ctx, msg, user) case "/worktype": h.handleWorkType(ctx, msg, user) case "/report": h.handleReport(ctx, msg, user) case "/export": h.handleExport(ctx, msg, user) case "/dayoff": h.handleDayOff(ctx, msg, user) case "/history": h.handleHistory(ctx, msg, user) case "/edit": h.handleEdit(ctx, msg, user) case "/reporttoggle": h.handleReportToggle(ctx, msg, user) case "/setreporttime": h.handleSetReportTime(ctx, msg, user) case "/setaccent": h.handleSetAccent(ctx, msg, user) case "/settimezone": h.handleSetTimezone(ctx, msg, user) case "/note": h.handleNote(ctx, msg, user) case "/summary": h.handleSummary(ctx, msg, user) case "/setbreak": h.handleSetBreak(ctx, msg, user) case "/rpg": h.handleRPG(ctx, msg, user) case "/achievements": h.handleAchievements(ctx, msg, user) case "/salary": h.handleSalary(ctx, msg, user) case "/burnout": h.handleBurnout(ctx, msg, user) case "/league": h.handleLeague(ctx, msg, user) case "/setrate": h.handleSetRate(ctx, msg, user) case "/setcurrency": h.handleSetCurrency(ctx, msg, user) case "/rpgtoggle": h.handleRPGSettings(ctx, msg, user) case "/leaguetoggle": h.handleLeagueToggle(ctx, msg, user) case "/setusername": h.handleSetUsername(ctx, msg, user) default: h.sendText(ctx, msg.Chat.ID, "Unknown command") } } func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery) { chatID := cb.Message.Message.Chat.ID if len(h.AllowedUsers) > 0 && !h.AllowedUsers[chatID] { h.answerCbWithText(ctx, cb.ID, "Unauthorized") return } user, err := h.getOrCreateUser(chatID) if err != nil { h.answerCb(ctx, cb.ID) return } if h.isRateLimited(user.ID) { h.answerCb(ctx, cb.ID) return } slog.Info("callback", "chat_id", chatID, "data", cb.Data) h.mu.Lock() delete(h.pendingInput, chatID) h.mu.Unlock() msgID := cb.Message.Message.ID data := cb.Data switch { case data == "pending_cancel": h.backToMenu(ctx, chatID, msgID, cb.ID) case data == "clockin": h.clockInCallback(ctx, chatID, msgID, cb.ID, user) case data == "clockout": h.clockOutCallback(ctx, chatID, msgID, cb.ID, user) case data == "dayoff": h.dayOffCallback(ctx, chatID, msgID, cb.ID, user) case data == "report": h.reportCallback(ctx, chatID, msgID, cb.ID, user) case data == "worktype": h.workTypeCallback(ctx, chatID, msgID, cb.ID, user) case data == "export": h.exportCallback(ctx, chatID, msgID, cb.ID, user) case data == "settings": h.settingsCallback(ctx, chatID, msgID, cb.ID, user) case data == "history": h.historyCallback(ctx, chatID, msgID, cb.ID, user) case data == "noop": h.answerCb(ctx, cb.ID) case data == "rpg": h.rpgCallback(ctx, chatID, msgID, cb.ID, user) case data == "achievements": h.achievementsCallback(ctx, chatID, msgID, cb.ID, user) case data == "salary": h.salaryCallback(ctx, chatID, msgID, cb.ID, user) case data == "burnout": h.burnoutCallback(ctx, chatID, msgID, cb.ID, user) case data == "league": h.leagueCallback(ctx, chatID, msgID, cb.ID, user, string(domain.LeagueSortXP)) case data == "back_menu": h.backToMenu(ctx, chatID, msgID, cb.ID) default: h.routePrefixedCallback(ctx, chatID, msgID, cb.ID, user, data) } } func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, data string) { switch { case strings.HasPrefix(data, "worktype_"): h.selectWorkType(ctx, chatID, msgID, cbID, user, data[9:]) case strings.HasPrefix(data, "accent_"): h.selectAccent(ctx, chatID, msgID, cbID, user, data[7:]) case strings.HasPrefix(data, "caltype_"): h.selectCalendar(ctx, chatID, msgID, cbID, user, data[8:]) case strings.HasPrefix(data, "exp_"): h.handleExportCallback(ctx, chatID, msgID, cbID, user, data) case strings.HasPrefix(data, "breakthreshold_"): h.selectBreakThreshold(ctx, chatID, msgID, cbID, user, data[15:]) case strings.HasPrefix(data, "currency_"): h.selectCurrency(ctx, chatID, msgID, cbID, user, data[9:]) case data == "rpg_enable": h.setRPGCallback(ctx, chatID, msgID, cbID, user, true) case data == "rpg_disable": h.setRPGCallback(ctx, chatID, msgID, cbID, user, false) case data == "league_enable": h.setLeagueCallback(ctx, chatID, msgID, cbID, user, true) case data == "league_disable": h.setLeagueCallback(ctx, chatID, msgID, cbID, user, false) case data == "report_enable": h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, true) case data == "report_disable": h.setReportToggleCallback(ctx, chatID, msgID, cbID, user, false) case data == "back_settings": h.backToSettings(ctx, chatID, msgID, cbID, user) case data == "rpgtoggle": h.rpgToggleCallback(ctx, chatID, msgID, cbID, user) case data == "leaguetoggle": h.leagueToggleCallback(ctx, chatID, msgID, cbID, user) case data == "reporttoggle": h.reportToggleCallback(ctx, chatID, msgID, cbID, user) case data == "rpg_achievements": h.rpgAchievementsCallback(ctx, chatID, msgID, cbID, user) case data == "rpg_back": h.rpgBackCallback(ctx, chatID, msgID, cbID, user) case data == "username": h.usernameCallback(ctx, chatID, msgID, cbID, user) case data == "currency": h.currencyCallback(ctx, chatID, msgID, cbID, user) case data == "setrate": h.rateCallback(ctx, chatID, msgID, cbID, user) case data == "timezone": h.timezoneCallback(ctx, chatID, msgID, cbID, user) case data == "caltype": h.calTypeCallback(ctx, chatID, msgID, cbID, user) case data == "accent": h.accentCallback(ctx, chatID, msgID, cbID, user) case data == "reporttime": h.reportTimeCallback(ctx, chatID, msgID, cbID, user) case data == "breakthreshold": h.breakThresholdCallback(ctx, chatID, msgID, cbID, user) case strings.HasPrefix(data, "league_"): sortBy := data[7:] h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy) default: h.routeHistoryCallback(ctx, chatID, msgID, cbID, user, data) } } func (h *Handler) setPending(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, data map[string]string) { h.mu.Lock() h.pendingInput[chatID] = &PendingInput{ Kind: kind, UserID: userID, CreatedAt: time.Now(), MsgID: msgID, Data: data, } h.mu.Unlock() } func (h *Handler) processPendingInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput) { text := strings.TrimSpace(msg.Text) switch pi.Kind { case PendingNote: h.processNoteInput(ctx, msg, user, pi, text) case PendingRate: h.processRateInput(ctx, msg, user, pi, text) case PendingCurrency: h.processCurrencyInput(ctx, msg, user, pi, text) case PendingTimezone: h.processTimezoneInput(ctx, msg, user, pi, text) case PendingBreak: h.processBreakInput(ctx, msg, user, pi, text) case PendingUsername: h.processUsernameInput(ctx, msg, user, pi, text) case PendingReportTime: h.processReportTimeInput(ctx, msg, user, pi, text) default: h.sendText(ctx, msg.Chat.ID, "Unknown input type") } } func (h *Handler) promptForInput(ctx context.Context, chatID int64, msgID int, userID int64, kind PendingKind, prompt string, data map[string]string) { kb := models.InlineKeyboardMarkup{ InlineKeyboard: [][]models.InlineKeyboardButton{ {{Text: "Cancel", CallbackData: "pending_cancel"}}, }, } if msgID == 0 { msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ ChatID: chatID, Text: prompt, ReplyMarkup: kb, }) if err != nil { slog.Warn("send prompt failed", "chat_id", chatID, "error", err) return } h.setPending(ctx, chatID, msg.ID, userID, kind, data) } else { h.editText(ctx, chatID, msgID, prompt, &kb) h.setPending(ctx, chatID, msgID, userID, kind, data) } } // sendOrEdit sends a new message or edits an existing one depending on msgID. func (h *Handler) sendOrEdit(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { if msgID == 0 { if kb != nil { h.sendWithKB(ctx, chatID, text, *kb) } else { h.sendText(ctx, chatID, text) } } else { h.editText(ctx, chatID, msgID, text, kb) } } // -- helpers -- func (h *Handler) sendText(ctx context.Context, chatID int64, text string) { if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}); err != nil { slog.Warn("send text failed", "chat_id", chatID, "error", err) } } func (h *Handler) editText(ctx context.Context, chatID int64, msgID int, text string, kb *models.InlineKeyboardMarkup) { p := &tgbot.EditMessageTextParams{ChatID: chatID, MessageID: msgID, Text: text} if kb != nil { p.ReplyMarkup = kb } if _, err := h.Bot.EditMessageText(ctx, p); err != nil { slog.Warn("edit text failed", "chat_id", chatID, "msg_id", msgID, "error", err) } } func (h *Handler) sendWithKB(ctx context.Context, chatID int64, text string, kb models.InlineKeyboardMarkup) { if _, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text, ReplyMarkup: kb}); err != nil { slog.Warn("send with keyboard failed", "chat_id", chatID, "error", err) } } func (h *Handler) answerCb(ctx context.Context, callbackID string) { if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID}); err != nil { slog.Debug("answer callback failed", "error", err) } } func (h *Handler) answerCbWithText(ctx context.Context, callbackID, text string) { if _, err := h.Bot.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{CallbackQueryID: callbackID, Text: text}); err != nil { slog.Debug("answer callback with text failed", "error", err) } } func (h *Handler) tryUnlockAchievement(userID int64, code string) { a, err := h.Repo.GetAchievementByCode(code) if err != nil { return } if _, err := h.Repo.UnlockAchievement(userID, a.ID); err != nil { slog.Error("unlock achievement", "code", code, "error", err) } } func formatDuration(seconds int64) string { hours := seconds / domain.SecondsPerHour mins := (seconds % domain.SecondsPerHour) / 60 if hours > 0 { return fmt.Sprintf("%dh %dm", hours, mins) } return fmt.Sprintf("%dm", mins) }