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(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) handleActionMsg(msg *tgbotapi.Message, fn actionFunc) { text, err := fn(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, err.Error()) return } reply := tgbotapi.NewMessage(msg.Chat.ID, text) reply.ReplyMarkup = mainKeyboard() h.Bot.Send(reply) } 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 } 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) switch msg.Text { case "/start": h.handleStart(msg) case "/help": h.handleHelp(msg) case "/clockin": h.handleActionMsg(msg, h.clockIn) case "/clockout": 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 "/history": h.handleHistoryMsg(msg) case "/edit": h.handleEditMsg(msg) case "/reporttoggle": h.handleActionMsg(msg, h.toggleReport) case "/setreporttime": h.handleSetReportTime(msg) case "/setaccent": h.handleAccentMsg(msg) case "/settimezone": h.handleSetTimezone(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 } user, err := h.getOrCreateUser(cb.Message.Chat.ID) if err != nil { h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) return } if h.isRateLimited(user.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 "noop": h.Bot.Request(tgbotapi.NewCallback(cb.ID, "")) case "dayoff": h.handleActionCallback(chatID, msgID, cb.ID, h.dayOff) case "settings": h.settingsCallback(chatID, msgID, cb.ID) case "caltype": h.calTypeCallback(chatID, msgID, cb.ID) case "accent": h.accentCallback(chatID, msgID, cb.ID) case "timezone": h.timezoneCallback(chatID, msgID, cb.ID) case "history": h.historyCallback(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 } if len(cb.Data) > 8 && cb.Data[:8] == "caltype_" { h.selectCalendar(chatID, msgID, cb.ID, cb.Data[8:]) return } h.handleHistoryCallback(chatID, msgID, cb.ID, cb.Data) } } 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 } 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) now := time.Now().In(loc) lastTime := time.Unix(last.OccurredAt, 0).In(loc) if lastTime.Format("2006-01-02") == now.Format("2006-01-02") { return "", errors.New("Already clocked in") } } 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 } 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) handleSetTimezone(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 timezone: %s\nUsage: /settimezone \nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone)) return } loc, err := time.LoadLocation(arg) if err != nil { h.sendText(msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran, Europe/London", arg)) return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, "Error loading profile") return } user.Timezone = arg if err := h.DB.UpdateUser(user); err != nil { h.sendText(msg.Chat.ID, "Error saving timezone") return } now := time.Now().In(loc) h.sendText(msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", arg, now.Format("15:04"))) } 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) menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID)) menu.ReplyMarkup = mainKeyboard() h.Bot.Send(menu) } func (h *Handler) handleHelp(msg *tgbotapi.Message) { text := `Commands: /start - Show main menu /clockin - Clock in /clockout - Clock out /worktype - Select work type /dayoff - Toggle day off /report - Show today report /export - Export monthly report (Excel) /history - View calendar history /edit YYYY-MM-DD - Edit a specific day /reporttoggle - Enable/disable auto report /setreporttime HH:MM - Set daily report time /setaccent - Select accent color /settimezone - Set your timezone (e.g. Asia/Tehran) Buttons on the main menu also work.` h.sendText(msg.Chat.ID, text) } 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("Timezone: %s", user.Timezone), "timezone"), )) rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Accent: %s", user.ExportAccent), "accent"), )) rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Calendar: %s", user.Calendar), "caltype"), )) 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("History", "history"), )) 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) timezoneCallback(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 timezone: %s\n\nUse /settimezone to change it.\nExamples: Asia/Tehran, Europe/London, America/New_York", user.Timezone) kb := tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), ), ) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) buildAccentKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { accents := []struct { name string color string }{ {"ocean", "Blue"}, {"beach", "Warm"}, {"rose", "Pink"}, {"catppuccin", "Purple"}, } var rows [][]tgbotapi.InlineKeyboardButton for _, a := range accents { label := fmt.Sprintf("%s (%s)", a.name, a.color) if a.name == user.ExportAccent { label = "> " + label } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(label, "accent_"+a.name), )) } 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) calTypeCallback(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.buildCalendarKeyboard(user) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) buildCalendarKeyboard(user *db.User) (string, tgbotapi.InlineKeyboardMarkup) { cals := []string{"gregorian", "jalali", "hijri"} calLabels := map[string]string{ "gregorian": "Gregorian", "jalali": "Jalali", "hijri": "Hijri", } var rows [][]tgbotapi.InlineKeyboardButton for _, c := range cals { label := calLabels[c] if c == user.Calendar { label = "> " + label } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(label, "caltype_"+c), )) } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Settings", "back_settings"), )) return "Select calendar type:", tgbotapi.NewInlineKeyboardMarkup(rows...) } func (h *Handler) selectCalendar(chatID int64, msgID int, callbackID, name string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true} if !valid[name] { return } user, err := h.getOrCreateUser(chatID) if err != nil { return } user.Calendar = name if err := h.DB.UpdateUser(user); err != nil { return } text, kb := h.buildSettingsKeyboard(user) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) handleHistoryMsg(msg *tgbotapi.Message) { h.sendCalendar(msg.Chat.ID, 0, 0, 0) } func (h *Handler) historyCallback(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) h.editCalendar(chatID, msgID, 0, 0) } func (h *Handler) handleEditMsg(msg *tgbotapi.Message) { date := msg.CommandArguments() if date == "" { h.sendText(msg.Chat.ID, "Usage: /edit YYYY-MM-DD") return } if len(date) != 10 || date[4] != '-' || date[7] != '-' { h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD") return } user, err := h.getOrCreateUser(msg.Chat.ID) if err != nil { h.sendText(msg.Chat.ID, "Error loading profile") return } loc, _ := time.LoadLocation(user.Timezone) h.sendDayView(msg.Chat.ID, 0, user, date, loc, true) } func (h *Handler) handleHistoryCallback(chatID int64, msgID int, callbackID, data string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) user, err := h.getOrCreateUser(chatID) if err != nil { return } loc, _ := time.LoadLocation(user.Timezone) if data == "history" { h.editCalendar(chatID, msgID, 0, 0) return } var y, m int var date string var eid int64 // cal_prev_YYYY_M if n, _ := fmt.Sscanf(data, "cal_prev_%d_%d", &y, &m); n == 2 { m-- if m < 1 { m = 12 y-- } h.editCalendar(chatID, msgID, y, m) return } // cal_next_YYYY_M if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 { m++ if m > 12 { m = 1 y++ } h.editCalendar(chatID, msgID, y, m) return } // cal_day_DATE if n, _ := fmt.Sscanf(data, "cal_day_%s", &date); n == 1 && len(date) == 10 { h.editDayView(chatID, msgID, user, date, loc) return } // back_day_EVENTID -> go back to day view if n, _ := fmt.Sscanf(data, "back_day_%d", &eid); n == 1 { h.backToDayView(chatID, msgID, user, eid, loc) return } // settype_EVENTID_WTID var wtid int64 if n, _ := fmt.Sscanf(data, "settype_%d_%d", &eid, &wtid); n == 2 { h.setEventWorkType(chatID, msgID, user, eid, wtid, loc) return } // edit_type_EVENTID -> show work type picker if n, _ := fmt.Sscanf(data, "edit_type_%d", &eid); n == 1 { h.editEventType(chatID, msgID, user, eid, loc) return } // delete_EVENTID if n, _ := fmt.Sscanf(data, "delete_%d", &eid); n == 1 { h.deleteEventPrompt(chatID, msgID, user, eid, loc) return } // delconf_EVENTID if n, _ := fmt.Sscanf(data, "delconf_%d", &eid); n == 1 { h.deleteEventConfirm(chatID, msgID, user, eid, loc) return } // edit_time_EVENTID -> show hour picker if n, _ := fmt.Sscanf(data, "edit_time_%d", &eid); n == 1 { h.editEventTime(chatID, msgID, user, eid, loc) return } // edittm_EVENTID_HH_MM -> set time var hh, mm int if n, _ := fmt.Sscanf(data, "edittm_%d_%d_%d", &eid, &hh, &mm); n == 3 { h.editEventTimeSet(chatID, msgID, user, eid, hh, mm, loc) return } // edittm_EVENTID_HH -> show minute picker if n, _ := fmt.Sscanf(data, "edittm_%d_%d", &eid, &hh); n == 2 { h.editEventTimeMin(chatID, msgID, user, eid, hh, loc) return } } func (h *Handler) sendCalendar(chatID int64, msgID int, year, month int) { h.editCalendar(chatID, msgID, year, month) } func (h *Handler) editCalendar(chatID int64, msgID int, year, month int) { user, err := h.getOrCreateUser(chatID) if err != nil { return } loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) if year == 0 || month == 0 { switch user.Calendar { case "jalali": jy, jm, _ := gregorianToJalali(now.Year(), int(now.Month()), now.Day()) year, month = jy, jm case "hijri": hy, hm, _ := gregorianToHijri(now.Year(), int(now.Month()), now.Day()) year, month = hy, hm default: year, month = now.Year(), int(now.Month()) } } cal := user.Calendar cm := buildCalendarMonth(cal, year, month) text := cm.title todayKey := now.Format("2006-01-02") hasEvent := make(map[string]bool) if cal == "jalali" { gy, gm, _ := jalaliToGregorian(year, month, 1) lastDay := monthLenJalali(year, month) gyEnd, gmEnd, gdEnd := jalaliToGregorian(year, month, lastDay) startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) rows, err := h.DB.DB().Query( "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", user.ID, startDate, endDate, ) if err == nil { for rows.Next() { var d string rows.Scan(&d) hasEvent[d] = true } rows.Close() } } else if cal == "hijri" { gy, gm, _ := hijriToGregorian(year, month, 1) lastDay := monthLenHijri(year, month) gyEnd, gmEnd, gdEnd := hijriToGregorian(year, month, lastDay) startDate := fmt.Sprintf("%04d-%02d-%02d", gy, gm, 1) endDate := fmt.Sprintf("%04d-%02d-%02d", gyEnd, gmEnd, gdEnd) rows, err := h.DB.DB().Query( "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", user.ID, startDate, endDate, ) if err == nil { for rows.Next() { var d string rows.Scan(&d) hasEvent[d] = true } rows.Close() } } else { startDate := fmt.Sprintf("%04d-%02d-%02d", year, month, 1) endDate := fmt.Sprintf("%04d-%02d-%02d", year, month, monthLenGreg(year, month)) rows, err := h.DB.DB().Query( "SELECT DISTINCT date FROM days WHERE user_id=? AND date >= ? AND date <= ?", user.ID, startDate, endDate, ) if err == nil { for rows.Next() { var d string rows.Scan(&d) hasEvent[d] = true } rows.Close() } } var kbRows [][]tgbotapi.InlineKeyboardButton var headerRow []tgbotapi.InlineKeyboardButton for _, wn := range cm.weekDays { headerRow = append(headerRow, tgbotapi.NewInlineKeyboardButtonData(wn, "noop")) } kbRows = append(kbRows, headerRow) var row []tgbotapi.InlineKeyboardButton for i, d := range cm.days { if d.dayNum == 0 { row = append(row, tgbotapi.NewInlineKeyboardButtonData(" ", "noop")) } else { label := fmt.Sprintf("%d", d.dayNum) if d.date == todayKey { label = fmt.Sprintf("[%d]", d.dayNum) } else if hasEvent[d.date] { label = fmt.Sprintf("%d*", d.dayNum) } row = append(row, tgbotapi.NewInlineKeyboardButtonData(label, "cal_day_"+d.date)) } if len(row) == 7 || i == len(cm.days)-1 { kbRows = append(kbRows, row) row = nil } } var prevY, prevM, nextY, nextM int prevY, prevM = year, month-1 if prevM < 1 { prevM = 12 prevY-- } nextY, nextM = year, month+1 if nextM > 12 { nextM = 1 nextY++ } navRow := tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("cal_prev_%d_%d", prevY, prevM)), tgbotapi.NewInlineKeyboardButtonData("Today", "history"), tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("cal_next_%d_%d", nextY, nextM)), ) kbRows = append(kbRows, navRow) kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), )) kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) if msgID == 0 { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = &kb h.Bot.Send(reply) } else { edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } } func (h *Handler) editDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location) { h.sendDayView(chatID, msgID, user, date, loc, false) } func (h *Handler) sendDayView(chatID int64, msgID int, user *db.User, date string, loc *time.Location, isNewMsg bool) { events, err := h.DB.EventsForDayByDate(user.ID, date) if err != nil { if isNewMsg { h.sendText(chatID, "Error loading events") } return } text := fmt.Sprintf("Date: %s", formatDateForCalendar(date, user.Calendar)) if len(events) == 0 { text += "\nNo events for this day." kb := tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), ), ) if isNewMsg || msgID == 0 { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = &kb h.Bot.Send(reply) } else { edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } return } text += "\n\nEvents:" var kbRows [][]tgbotapi.InlineKeyboardButton for _, e := range events { t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04") label := "IN" if e.EventType == "out" { label = "OUT" } wt := "" if e.WorkTypeID != nil { if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { wt = " [" + wtObj.Name + "]" } } note := "" if e.Note != "" { note = " (" + e.Note + ")" } text += fmt.Sprintf("\n%s %s%s%s", label, t, wt, note) kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(fmt.Sprintf("Time %s", t), fmt.Sprintf("edit_time_%d", e.ID)), tgbotapi.NewInlineKeyboardButtonData("Type", fmt.Sprintf("edit_type_%d", e.ID)), tgbotapi.NewInlineKeyboardButtonData("DEL", fmt.Sprintf("delete_%d", e.ID)), )) } kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Calendar", "history"), )) kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) if isNewMsg || msgID == 0 { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = &kb h.Bot.Send(reply) } else { edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } } func (h *Handler) editEventType(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { wts, err := h.DB.GetWorkTypes() if err != nil { return } text := "Select new work type:" var kbRows [][]tgbotapi.InlineKeyboardButton for _, wt := range wts { kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(wt.Name, fmt.Sprintf("settype_%d_%d", eventID, wt.ID)), )) } kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), )) kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) setEventWorkType(chatID int64, msgID int, user *db.User, eventID, workTypeID int64, loc *time.Location) { h.DB.DB().Exec("UPDATE events SET work_type_id=? WHERE id=?", workTypeID, eventID) event, err := h.getEventByID(user.ID, eventID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) date := t.Format("2006-01-02") h.sendDayView(chatID, msgID, user, date, loc, false) } func (h *Handler) editEventTime(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { text := "Select hour:" var kbRows [][]tgbotapi.InlineKeyboardButton var hourRow []tgbotapi.InlineKeyboardButton for hh := 0; hh < 24; hh++ { hourRow = append(hourRow, tgbotapi.NewInlineKeyboardButtonData( fmt.Sprintf("%02d", hh), fmt.Sprintf("edittm_%d_%02d", eventID, hh), )) if len(hourRow) == 6 || hh == 23 { kbRows = append(kbRows, hourRow) hourRow = nil } } kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("back_day_%d", eventID)), )) kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) editEventTimeMin(chatID int64, msgID int, user *db.User, eventID int64, hh int, loc *time.Location) { text := fmt.Sprintf("Select minute for hour %02d:", hh) var kbRows [][]tgbotapi.InlineKeyboardButton var minRow []tgbotapi.InlineKeyboardButton minutes := []int{0, 15, 30, 45} for _, mm := range minutes { minRow = append(minRow, tgbotapi.NewInlineKeyboardButtonData( fmt.Sprintf("%02d", mm), fmt.Sprintf("edittm_%d_%02d_%02d", eventID, hh, mm), )) } kbRows = append(kbRows, minRow) kbRows = append(kbRows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back", fmt.Sprintf("edit_time_%d", eventID)), )) kb := tgbotapi.NewInlineKeyboardMarkup(kbRows...) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kb h.Bot.Send(edit) } func (h *Handler) editEventTimeSet(chatID int64, msgID int, user *db.User, eventID int64, hh, mm int, loc *time.Location) { event, err := h.getEventByID(user.ID, eventID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) newTime := time.Date(t.Year(), t.Month(), t.Day(), hh, mm, 0, 0, loc) date := t.Format("2006-01-02") // Check for overlaps with adjacent events events, err := h.DB.EventsForDayByDate(user.ID, date) if err == nil { for _, e := range events { if e.ID == eventID { continue } eTime := time.Unix(e.OccurredAt, 0) if newTime.Unix() == eTime.Unix() { h.acknowledgeCallback(chatID, msgID) h.sendDayView(chatID, msgID, user, date, loc, false) return } } type eventPos struct { id int64 typ string t int64 } var sorted []eventPos for _, e := range events { et := e.OccurredAt if e.ID == eventID { et = newTime.Unix() } sorted = append(sorted, eventPos{e.ID, e.EventType, et}) } for i := 0; i < len(sorted); i++ { for j := i + 1; j < len(sorted); j++ { if sorted[j].t < sorted[i].t || (sorted[j].t == sorted[i].t && sorted[j].id < sorted[i].id) { sorted[i], sorted[j] = sorted[j], sorted[i] } } } for i := 1; i < len(sorted); i++ { if sorted[i].typ == sorted[i-1].typ { text := "Edit rejected: overlapping events. Two consecutive events must be different types (in/out)." edit := tgbotapi.NewEditMessageText(chatID, msgID, text) h.Bot.Send(edit) return } } } h.DB.DB().Exec("UPDATE events SET occurred_at=? WHERE id=?", newTime.Unix(), eventID) h.sendDayView(chatID, msgID, user, date, loc, false) } func (h *Handler) acknowledgeCallback(chatID int64, msgID int) { h.Bot.Request(tgbotapi.NewCallback(fmt.Sprintf("cb_%d_%d", chatID, msgID), "")) } func (h *Handler) deleteEventPrompt(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { text := "Delete this event?" kbRows := tgbotapi.NewInlineKeyboardMarkup( tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Yes, DEL", fmt.Sprintf("delconf_%d", eventID)), tgbotapi.NewInlineKeyboardButtonData("Cancel", fmt.Sprintf("back_day_%d", eventID)), ), ) edit := tgbotapi.NewEditMessageText(chatID, msgID, text) edit.ReplyMarkup = &kbRows h.Bot.Send(edit) } func (h *Handler) deleteEventConfirm(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { event, err := h.getEventByID(user.ID, eventID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) date := t.Format("2006-01-02") h.DB.DB().Exec("DELETE FROM events WHERE id=?", eventID) // Check if remaining events need repair events, err := h.DB.EventsForDayByDate(user.ID, date) if err == nil && len(events) > 1 { var hasIssue bool for i := 1; i < len(events); i++ { if events[i].EventType == events[i-1].EventType { hasIssue = true break } } if hasIssue { h.sendText(chatID, "Warning: Timeline has consecutive events of same type after deletion. Timeline requires manual repair.") } } h.sendDayView(chatID, msgID, user, date, loc, false) } func (h *Handler) backToDayView(chatID int64, msgID int, user *db.User, eventID int64, loc *time.Location) { event, err := h.getEventByID(user.ID, eventID) if err != nil { return } t := time.Unix(event.OccurredAt, 0).In(loc) date := t.Format("2006-01-02") h.sendDayView(chatID, msgID, user, date, loc, false) } func (h *Handler) getEventByID(userID, eventID int64) (*db.Event, error) { row := h.DB.DB().QueryRow( "SELECT id, user_id, day_id, event_type, work_type_id, occurred_at, note, created_at FROM events WHERE id=? AND user_id=?", eventID, userID, ) var e db.Event var wt sql.NullInt64 if err := row.Scan(&e.ID, &e.UserID, &e.DayID, &e.EventType, &wt, &e.OccurredAt, &e.Note, &e.CreatedAt); err != nil { return nil, err } if wt.Valid { e.WorkTypeID = &wt.Int64 } return &e, nil } 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, user.Calendar, 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, user.Calendar, 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", formatDateForCalendar(day.Date, user.Calendar)) 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 { loc, _ := time.LoadLocation(user.Timezone) now := time.Now().In(loc) y, m, d := now.Date() dayStart := time.Date(y, m, d, 0, 0, 0, 0, loc).Unix() dayEnd := now.Unix() totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) 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", formatDateForCalendar(day.Date, user.Calendar)) } return fmt.Sprintf("%s - No events recorded.", formatDateForCalendar(day.Date, user.Calendar)) } y, m, d := parseGregorianDateKey(day.Date) dayStart := time.Date(y, time.Month(m), d, 0, 0, 0, 0, loc).Unix() dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix() totals := ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd) lines := fmt.Sprintf("Report for %s", formatDateForCalendar(day.Date, user.Calendar)) 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) h.Bot.Send(reply) h.DB.MarkReportSent(user.ID, today) } 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) { h.Bot.Send(tgbotapi.NewMessage(chatID, text)) }