Files
worktimeBot/internal/bot/handlers.go
db123 69c5e20d8e display date in user's selected calendar on main menu and reports
buildStatusText and buildDailyReport now use formatDateForCalendar
to convert the stored Gregorian date to the user's calendar type
(jalali/hijri/gregorian).
2026-06-24 10:06:39 +03:30

1388 lines
40 KiB
Go

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 "/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)
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 "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) 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) 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("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) 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", date)
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))
}