Files
worktimeBot/internal/handler/handler.go
db123 d15ed46066 fix: resolve go vet error and dayStart logic in ComputeDailyTotals
- Fix 'string(d)' int-to-rune conversion in rpg_test.go by using fmt.Sprintf
- Fix ComputeDailyTotals dayStart logic: initial state should be StateWorking
  when dayStart > 0, correctly counting work from dayStart to first out event
- Update README project structure to reflect new domain/handler/repo layout
- Implement missing achievements: rpg_pioneer (on RPG enable) and
  salary_setter (on first rate set)
- Add tryUnlockAchievement helper to Handler
2026-06-25 09:54:22 +03:30

454 lines
13 KiB
Go

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)
}