package bot import ( "database/sql" "errors" "fmt" "log/slog" "strconv" "sync" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "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, 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 { h.sendText(msg.Chat.ID, err.Error()) } else { h.sendSuccessWithMenu(msg.Chat.ID, text) } } func (h *Handler) handleActionCallback(chatID int64, msgID int, callbackID string, fn actionFunc) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) text, err := fn(chatID) if err != nil { text = err.Error() } edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) HandleMessage(update tgbotapi.Update) { msg := update.Message 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": h.handleStart(msg) case "/clockin", "Clock In": h.handleActionMsg(msg, h.clockIn) case "/clockout", "Clock Out": h.handleActionMsg(msg, h.clockOut) case "/worktype": h.handleWorkTypeMsg(msg) case "/report": h.handleActionMsg(msg, h.report) case "/export": h.handleExport(msg) case "/dayoff": h.handleActionMsg(msg, h.dayOff) case "/reporttoggle": h.handleActionMsg(msg, h.toggleReport) case "/setreporttime": h.handleSetReportTime(msg) case "/setaccent": h.handleAccentMsg(msg) default: h.sendText(msg.Chat.ID, "Unknown command") } } func (h *Handler) HandleCallback(update tgbotapi.Update) { cb := update.CallbackQuery if len(h.AllowedUsers) > 0 && !h.AllowedUsers[cb.Message.Chat.ID] { 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 switch cb.Data { case "clockin": h.handleActionCallback(chatID, msgID, cb.ID, h.clockIn) case "clockout": h.handleActionCallback(chatID, msgID, cb.ID, h.clockOut) case "worktype": h.workTypeCallback(chatID, msgID, cb.ID) case "report": h.handleActionCallback(chatID, msgID, cb.ID, h.report) case "export": 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 } } } func (h *Handler) getOrCreateUser(chatID int64) (*db.User, error) { return h.DB.GetOrCreateUser(chatID) } func (h *Handler) getUserToday(chatID int64) (*db.User, *db.Day, error) { user, err := h.getOrCreateUser(chatID) if err != nil { return nil, nil, err } loc, err := time.LoadLocation(user.Timezone) if err != nil { loc = time.UTC } today := time.Now().In(loc).Format("2006-01-02") day, err := h.DB.GetOrCreateDay(user.ID, today) if err != nil { return nil, nil, err } return user, day, nil } func (h *Handler) clockIn(chatID int64) (string, error) { user, day, err := h.getUserToday(chatID) if err != nil { return "", err } 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" { 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 if err := h.DB.CreateEvent(user.ID, day.ID, "in", &wt, now.Unix(), ""); err != nil { return "", err } return fmt.Sprintf("Clocked in at %s", now.Format("15:04")), nil } func (h *Handler) clockOut(chatID int64) (string, error) { user, day, err := h.getUserToday(chatID) if err != nil { return "", err } 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 { return "", err } if last == nil { return "", errors.New("No clock-in found. Start with /clockin first") } if last.EventType == "out" { return "", errors.New("Already clocked out") } loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) if err := h.DB.CreateEvent(user.ID, day.ID, "out", nil, now.Unix(), ""); err != nil { return "", err } return fmt.Sprintf("Clocked out at %s", now.Format("15:04")), nil } func (h *Handler) dayOff(chatID int64) (string, error) { user, day, err := h.getUserToday(chatID) if err != nil { return "", err } if day.IsDayOff { if err := h.DB.RemoveDayOff(user.ID, day.Date); err != nil { return "", err } return "Day off removed", nil } if err := h.DB.SetDayOff(user.ID, day.Date, ""); err != nil { return "", err } return "Today marked as day off", nil } func (h *Handler) report(chatID int64) (string, error) { user, day, err := h.getUserToday(chatID) if err != nil { return "", err } events, err := h.DB.EventsForDayByDayID(day.ID) if err != nil { return "", err } return h.buildDailyReport(user, day, events), nil } func (h *Handler) toggleReport(chatID int64) (string, error) { user, err := h.getOrCreateUser(chatID) if err != nil { return "", err } user.ReportEnabled = !user.ReportEnabled if err := h.DB.UpdateUser(user); err != nil { return "", err } if user.ReportEnabled { return "Daily report enabled", nil } return "Daily report disabled", nil } func (h *Handler) handleSetReportTime(msg *tgbotapi.Message) { arg := msg.CommandArguments() if arg == "" { user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, "Error loading profile") return } h.sendText(msg.Chat.ID, fmt.Sprintf("Current report time: %s\nUsage: /setreporttime HH:MM (24-hour)", user.ReportTime)) return } if len(arg) != 5 || arg[2] != ':' { h.sendText(msg.Chat.ID, "Invalid format. Usage: /setreporttime HH:MM (24-hour)") return } hours, err1 := strconv.Atoi(arg[:2]) mins, err2 := strconv.Atoi(arg[3:]) if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 { h.sendText(msg.Chat.ID, "Invalid time. Usage: /setreporttime HH:MM (24-hour)") return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, "Error updating report time") return } user.ReportTime = arg if err := h.DB.UpdateUser(user); err != nil { h.sendText(msg.Chat.ID, "Error updating report time") return } 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 { slog.Error("start: create user", "error", err) h.sendText(msg.Chat.ID, "Error setting up your profile") return } 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 = h.smartKeyboard(msg.Chat.ID) h.Bot.Send(reply) menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID)) menu.ReplyMarkup = mainKeyboard() h.Bot.Send(menu) } func (h *Handler) handleWorkTypeMsg(msg *tgbotapi.Message) { user, day, err := h.getUserToday(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, "Error loading work types") return } text, kb := h.buildWorkTypeKeyboard(user, day) reply := tgbotapi.NewMessage(msg.Chat.ID, text) reply.ReplyMarkup = &kb h.Bot.Send(reply) } func (h *Handler) workTypeCallback(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) user, day, err := h.getUserToday(chatID) if err != nil { edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading work types") kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) return } text, kb := h.buildWorkTypeKeyboard(user, day) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) buildWorkTypeKeyboard(user *db.User, day *db.Day) (string, tgbotapi.InlineKeyboardMarkup) { wts, err := h.DB.GetWorkTypes() if err != nil || len(wts) == 0 { return "No work types available.", backKeyboard() } text := "Select work type:" var rows [][]tgbotapi.InlineKeyboardButton for _, wt := range wts { label := wt.Name if wt.ID == day.CurrentWorkTypeID { label = "> " + wt.Name } 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"), )) return text, tgbotapi.NewInlineKeyboardMarkup(rows...) } func (h *Handler) selectWorkType(chatID int64, msgID int, callbackID, idStr string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) _, day, err := h.getUserToday(chatID) if err != nil { return } var wtID int64 if _, err := fmt.Sscanf(idStr, "%d", &wtID); err != nil { return } wt, err := h.DB.GetWorkType(wtID) if err != nil { return } day.CurrentWorkTypeID = wtID if err := h.DB.UpdateDay(day); err != nil { return } text := fmt.Sprintf("Work type set to: %s", wt.Name) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := backKeyboard() edit.ReplyMarkup = &kb 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 { h.sendText(msg.Chat.ID, "Error loading profile") return } now := time.Now() slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month()) if err != nil { slog.Error("generate report", "error", err) h.sendText(msg.Chat.ID, "Error generating report") return } doc := tgbotapi.NewDocument(msg.Chat.ID, tgbotapi.FileBytes{ Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), Bytes: data, }) if _, err := h.Bot.Send(doc); err != nil { slog.Error("send document", "error", err) h.sendText(msg.Chat.ID, "Error sending file") } } func (h *Handler) exportCallback(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) user, err := h.getOrCreateUser(chatID) if err != nil { edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error loading profile") kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) return } now := time.Now() slog.Info("export", "user_id", user.ID, "year", now.Year(), "month", now.Month()) data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, now.Year(), now.Month()) if err != nil { slog.Error("generate report", "error", err) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Error generating report") kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) return } slog.Info("report generated", "bytes", len(data)) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Report ready:") kb := backKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{ Name: fmt.Sprintf("worktime_%s.xlsx", now.Format("2006_01")), Bytes: data, }) if _, err := h.Bot.Send(doc); err != nil { slog.Error("send document", "error", err) } } 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, "")) text := h.buildStatusText(chatID) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) kb := mainKeyboard() edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) buildDailyReport(user *db.User, day *db.Day, events []db.Event) string { loc, err := time.LoadLocation(user.Timezone) if err != nil { loc = time.UTC } if len(events) == 0 { if day.IsDayOff { return fmt.Sprintf("%s - Day Off", day.Date) } return fmt.Sprintf("%s - No events recorded.", day.Date) } totals := ComputeDailyTotals(events, day.MinBreakThreshold) lines := fmt.Sprintf("Report for %s", day.Date) if day.IsDayOff { lines += "\nDay Off: Yes" } lines += "\n\n" for _, e := range events { t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") label := "IN" if e.EventType == "out" { label = "OUT" } suffix := "" if e.WorkTypeID != nil { if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { suffix = " [" + wtObj.Name + "]" } } if e.Note != "" { suffix += " (" + e.Note + ")" } lines += fmt.Sprintf("%s %s%s\n", label, t, suffix) } workStr := formatDuration(totals.TotalSeconds) breakStr := formatDuration(totals.BreakSeconds) lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", workStr, breakStr) return lines } func (h *Handler) SendDailyReport(userID int64, chatID int64) { user, err := h.DB.GetUserByChatID(chatID) if err != nil { slog.Error("daily report: user not found", "user_id", userID) return } loc, err := time.LoadLocation(user.Timezone) if err != nil { loc = time.UTC } now := time.Now().In(loc) today := now.Format("2006-01-02") day, err := h.DB.GetOrCreateDay(user.ID, today) if err != nil { slog.Error("daily report: get day", "error", err) return } events, err := h.DB.EventsForDayByDayID(day.ID) if err != nil { slog.Error("daily report: get events", "error", err) return } text := h.buildDailyReport(user, day, events) reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = h.smartKeyboard(chatID) h.Bot.Send(reply) h.DB.MarkReportSent(user.ID, today) } 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"), ), ) } func mainKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"), tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"), ), tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"), tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"), ), tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Report", "report"), tgbotapi.NewInlineKeyboardButtonData("Export", "export"), ), tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Settings", "settings"), ), ) } func backKeyboard() tgbotapi.InlineKeyboardMarkup { return tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), ), ) } func formatDuration(seconds int64) string { hours := seconds / 3600 mins := (seconds % 3600) / 60 if hours > 0 { return fmt.Sprintf("%dh %dm", hours, mins) } return fmt.Sprintf("%dm", mins) } func (h *Handler) sendText(chatID int64, text string) { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = h.smartKeyboard(chatID) h.Bot.Send(reply) } func (h *Handler) sendSuccessWithMenu(chatID int64, text string) { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = mainKeyboard() h.Bot.Send(reply) }