From 6f6cc6f1844dbe34fe3194deec3d289430c2bba3 Mon Sep 17 00:00:00 2001 From: db123 Date: Wed, 24 Jun 2026 07:56:04 +0330 Subject: [PATCH] feat: midnight crossover, rate limit, smart keyboard, status menu, settings - Midnight crossover: auto-close previous day session on clock-in - Rate limiting: 2s cooldown per chat_id in both messages and callbacks - Smart reply keyboard: single button changes to Clock In/Out based on state - Status info on main menu shows: date, work type, working status, today's total - Settings sub-menu with inline buttons for accent, report toggle, report time - Accent selection inline keyboard with > indicator and back button - Notes displayed in daily report (e.g. 'auto midnight') - Back buttons on all sub-menus (work type, accent, settings) - Settings button added to main menu --- internal/bot/handlers.go | 292 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 273 insertions(+), 19 deletions(-) diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go index 3e20c90..fa1e8c2 100644 --- a/internal/bot/handlers.go +++ b/internal/bot/handlers.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strconv" + "sync" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" @@ -13,18 +14,38 @@ import ( "worktimeBot/internal/db" ) +const rateLimitInterval = 2 * time.Second + type Handler struct { Bot *tgbotapi.BotAPI DB *db.Store AllowedUsers map[int64]bool + lastMsgTime map[int64]time.Time + mu sync.Mutex } func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler { - return &Handler{Bot: bot, DB: store, AllowedUsers: allowed} + return &Handler{ + Bot: bot, + DB: store, + AllowedUsers: allowed, + lastMsgTime: make(map[int64]time.Time), + } } type actionFunc func(int64) (string, error) +func (h *Handler) isRateLimited(chatID int64) bool { + h.mu.Lock() + defer h.mu.Unlock() + now := time.Now() + if last, ok := h.lastMsgTime[chatID]; ok && now.Sub(last) < rateLimitInterval { + return true + } + h.lastMsgTime[chatID] = now + return false +} + func (h *Handler) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { text, err := fn(msg.Chat.ID) if err != nil { @@ -51,6 +72,9 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { if len(h.AllowedUsers) > 0 && !h.AllowedUsers[msg.Chat.ID] { return } + if h.isRateLimited(msg.Chat.ID) { + return + } slog.Info("message", "chat_id", msg.Chat.ID, "text", msg.Text) switch msg.Text { case "/start": @@ -71,6 +95,8 @@ func (h *Handler) HandleMessage(update tgbotapi.Update) { h.handleActionMsg(msg, h.toggleReport) case "/setreporttime": h.handleSetReportTime(msg) + case "/setaccent": + h.handleAccentMsg(msg) default: h.sendText(msg.Chat.ID, "Unknown command") } @@ -82,6 +108,10 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.Bot.Request(tgbotapi.NewCallback(cb.ID, "Unauthorized")) return } + if h.isRateLimited(cb.Message.Chat.ID) { + h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) + return + } slog.Info("callback", "chat_id", cb.Message.Chat.ID, "data", cb.Data) chatID := cb.Message.Chat.ID msgID := cb.Message.MessageID @@ -99,11 +129,26 @@ func (h *Handler) HandleCallback(update tgbotapi.Update) { h.exportCallback(chatID, msgID, cb.ID) case "dayoff": h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) + case "settings": + h.settingsCallback(chatID, msgID, cb.ID) + case "accent": + h.accentCallback(chatID, msgID, cb.ID) + case "reporttoggle": + h.handleActionCallback(chatID, msgID, cb.ID, h.toggleReport) + case "reporttime": + h.reportTimeCallback(chatID, msgID, cb.ID) case "back_menu": h.backToMenu(chatID, msgID, cb.ID) + case "back_settings": + h.backToSettings(chatID, msgID, cb.ID) default: if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { h.selectWorkType(chatID, msgID, cb.ID, cb.Data[9:]) + return + } + if len(cb.Data) > 7 && cb.Data[:7] == "accent_" { + h.selectAccent(chatID, msgID, cb.ID, cb.Data[7:]) + return } } } @@ -137,13 +182,33 @@ func (h *Handler) clockIn(chatID int64) (string, error) { if day.IsDayOff { return "", errors.New("Today is a day off. Use /dayoff to remove it first.") } + last, err := h.DB.GetLastEvent(user.ID) if err != nil && err != sql.ErrNoRows { return "", err } + if last != nil && last.EventType == "in" { - return "", errors.New("Already clocked in") + loc, _ := time.LoadLocation(user.Timezone) + lastTime := time.Unix(last.OccurredAt, 0).In(loc) + now := time.Now().In(loc) + lastDay := lastTime.Format("2006-01-02") + today := now.Format("2006-01-02") + + if lastDay == today { + return "", errors.New("Already clocked in") + } + + midnight := time.Date(lastTime.Year(), lastTime.Month(), lastTime.Day(), 23, 59, 59, 0, loc) + prevDay, err := h.DB.GetOrCreateDay(user.ID, lastDay) + if err != nil { + return "", err + } + if err := h.DB.CreateEvent(user.ID, prevDay.ID, "out", nil, midnight.Unix(), "auto midnight"); err != nil { + return "", err + } } + loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) wt := day.CurrentWorkTypeID @@ -257,6 +322,18 @@ func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { h.sendText(msg.Chat.ID, fmt.Sprintf("Report time set to %s", arg)) } +func (h *Handler) handleAccentMsg(msg *tgbotapi.Message) { + user, err := h.getOrCreateUser(msg.Chat.ID) + if err != nil { + h.sendText(msg.Chat.ID, "Error loading profile") + return + } + text, kb := h.buildAccentKeyboard(user) + reply := tgbotapi.NewMessage(msg.Chat.ID, text) + reply.ReplyMarkup = &kb + h.Bot.Send(reply) +} + func (h *Handler) handleStart(msg *tgbotapi.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { @@ -266,9 +343,9 @@ func (h *Handler) handleStart(msg *tgbotapi.Message) { } slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID) reply := tgbotapi.NewMessage(msg.Chat.ID, "We hope you have a happy day working.") - reply.ReplyMarkup = quickKeyboard() + reply.ReplyMarkup = h.smartKeyboard(msg.Chat.ID) h.Bot.Send(reply) - menu := tgbotapi.NewMessage(msg.Chat.ID, "Welcome. Choose an action:") + menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID)) menu.ReplyMarkup = mainKeyboard() h.Bot.Send(menu) } @@ -313,17 +390,14 @@ func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgb if wt.ID == day.CurrentWorkTypeID { label = "> " + wt.Name } - callbackData := fmt.Sprintf("worktype_%d", wt.ID) - row := tgbotapi.NewInlineKeyboardRow( - tgbotapi.NewInlineKeyboardButtonData(label, callbackData), - ) - rows = append(rows, row) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, fmt.Sprintf("worktype_%d", wt.ID)), + )) } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), )) - kb := tgbotapi.NewInlineKeyboardMarkup(rows...) - return text, kb + return text, tgbotapi.NewInlineKeyboardMarkup(rows...) } func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { @@ -351,6 +425,123 @@ func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr stri h.Bot.Send(edit) } +func (h *Handler) settingsCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildSettingsKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) buildSettingsKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + reportStatus := "disabled" + if user.ReportEnabled { + reportStatus = "enabled" + } + var rows [][]tgbotapi.InlineKeyboardButton + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), + )) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report: %s", reportStatus), "reporttoggle"), + )) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Report time: %s", user.ReportTime), "reporttime"), + )) + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), + )) + return "Settings:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +func (h *Handler) accentCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildAccentKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { + accents := []string{"ocean", "beach", "rose", "catppuccin"} + var rows [][]tgbotapi.InlineKeyboardButton + for _, a := range accents { + label := a + if a == user.ExportAccent { + label = "> " + a + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a), + )) + } + rows = append(rows, tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + )) + return "Select accent color:", tgbotapi.NewInlineKeyboardMarkup(rows...) +} + +func (h *Handler) selectAccent(chatID int64, msgID int, callbackID, name string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true} + if !valid[name] { + return + } + user.ExportAccent = name + if err := h.DB.UpdateUser(user); err != nil { + return + } + text := fmt.Sprintf("Accent set to: %s", name) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + ), + ) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) reportTimeCallback(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text := fmt.Sprintf("Current report time: %s\nUse /setreporttime HH:MM to change it.", user.ReportTime) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + kb := tgbotapi.NewInlineKeyboardMarkup( + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), + ), + ) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + +func (h *Handler) backToSettings(chatID int64, msgID int, callbackID string) { + defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) + user, err := h.getOrCreateUser(chatID) + if err != nil { + return + } + text, kb := h.buildSettingsKeyboard(user) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) + edit.ReplyMarkup = &kb + h.Bot.Send(edit) +} + func (h *Handler) handleExport(msg *tgbotapi.Message) { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { @@ -410,9 +601,47 @@ func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { } } +func (h *Handler) buildStatusText(chatID int64) string { + user, day, err := h.getUserToday(chatID) + if err != nil { + return "Welcome. Choose an action:" + } + + text := fmt.Sprintf("Today: %s", day.Date) + + if day.IsDayOff { + text += "\nDay Off" + return text + "\n\nChoose an action:" + } + + wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID) + if wt != nil { + text += fmt.Sprintf("\nWork type: %s", wt.Name) + } + + last, err := h.DB.GetLastEvent(user.ID) + if err == nil && last != nil && last.EventType == "in" { + loc, _ := time.LoadLocation(user.Timezone) + t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04") + text += fmt.Sprintf("\nStatus: working since %s", t) + } else { + text += "\nStatus: not working" + } + + events, err := h.DB.EventsForDayByDayID(day.ID) + if err == nil && len(events) > 0 { + totals := ComputeDailyTotals(events, day.MinBreakThreshold) + text += fmt.Sprintf("\nToday: %s worked", formatDuration(totals.TotalSeconds)) + } + + text += "\n\nChoose an action:" + return text +} + func (h *Handler) backToMenu(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) - edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome. Choose an action:") + text := h.buildStatusText(chatID) + edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := mainKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) @@ -444,13 +673,16 @@ func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event if e.EventType == "out" { label = "OUT" } - wt := "" + suffix := "" if e.WorkTypeID != nil { if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { - wt = " [" + wtObj.Name + "]" + suffix = " [" + wtObj.Name + "]" } } - lines += fmt.Sprintf("%s %s%s\n", label, t, wt) + if e.Note != "" { + suffix += " (" + e.Note + ")" + } + lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) } workStr := formatDuration(totals.TotalSeconds) @@ -485,16 +717,35 @@ func (h *Handler) SendDailyReport(userID int64, chatID int64) { } text := h.buildDailyReport(user, day, events) reply := tgbotapi.NewMessage(chatID, text) - reply.ReplyMarkup = quickKeyboard() + reply.ReplyMarkup = h.smartKeyboard(chatID) h.Bot.Send(reply) h.DB.MarkReportSent(user.ID, today) } -func quickKeyboard() tgbotapi.ReplyKeyboardMarkup { +func (h *Handler) smartKeyboard(chatID int64) tgbotapi.ReplyKeyboardMarkup { + user, err := h.getOrCreateUser(chatID) + if err != nil { + return defaultKeyboard() + } + last, err := h.DB.GetLastEvent(user.ID) + if err != nil || last == nil || last.EventType == "out" { + return tgbotapi.NewReplyKeyboard( + tgbotapi.NewKeyboardButtonRow( + tgbotapi.NewKeyboardButton("Clock In"), + ), + ) + } + return tgbotapi.NewReplyKeyboard( + tgbotapi.NewKeyboardButtonRow( + tgbotapi.NewKeyboardButton("Clock Out"), + ), + ) +} + +func defaultKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.NewReplyKeyboard( tgbotapi.NewKeyboardButtonRow( tgbotapi.NewKeyboardButton("Clock In"), - tgbotapi.NewKeyboardButton("Clock Out"), ), ) } @@ -513,6 +764,9 @@ func mainKeyboard() tgbotapi.InlineKeyboardMarkup { tgbotapi.NewInlineKeyboardButtonData("Report", "report"), tgbotapi.NewInlineKeyboardButtonData("Export", "export"), ), + tgbotapi.NewInlineKeyboardRow( + tgbotapi.NewInlineKeyboardButtonData("Settings", "settings"), + ), ) } @@ -535,7 +789,7 @@ func formatDuration(seconds int64) string { func (h *Handler) sendText(chatID int64, text string) { reply := tgbotapi.NewMessage(chatID, text) - reply.ReplyMarkup = quickKeyboard() + reply.ReplyMarkup = h.smartKeyboard(chatID) h.Bot.Send(reply) }