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
This commit is contained in:
453
internal/handler/handler.go
Normal file
453
internal/handler/handler.go
Normal file
@@ -0,0 +1,453 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user