package bot import ( "database/sql" "errors" "fmt" "log/slog" "strconv" "time" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "worktimeBot/internal/db" ) type Handler struct { Bot *tgbotapi.BotAPI DB *db.Store AllowedUsers map[int64]bool } func NewHandler(bot *tgbotapi.BotAPI, store *db.Store, allowed map[int64]bool) *Handler { return &Handler{Bot: bot, DB: store, AllowedUsers: allowed} } type actionFunc func(int64) (string, error) 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 } 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) 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 } 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 "back_menu": h.backToMenu(chatID, msgID, cb.ID) default: if len(cb.Data) > 9 && cb.Data[:9] == "worktype_" { h.selectWorkType(chatID, msgID, cb.ID, cb.Data[9:]) } } } 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" { 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 } 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) 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 = quickKeyboard() h.Bot.Send(reply) menu := tgbotapi.NewMessage(msg.Chat.ID, "Welcome. Choose an action:") 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 } callbackData := fmt.Sprintf("worktype_%d", wt.ID) row := tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData(label, callbackData), ) rows = append(rows, row) } rows = append(rows, tgbotapi.NewInlineKeyboardRow( tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"), )) kb := tgbotapi.NewInlineKeyboardMarkup(rows...) return text, kb } 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) 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) backToMenu(chatID int64, msgID int, callbackID string) { defer h.Bot.Request(tgbotapi.NewCallback(callbackID, "")) edit := tgbotapi.NewEditMessageText(chatID, msgID, "Welcome. Choose an action:") 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" } wt := "" if e.WorkTypeID != nil { if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil { wt = " [" + wtObj.Name + "]" } } lines += fmt.Sprintf("%s %s%s\n", label, t, wt) } 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 = quickKeyboard() h.Bot.Send(reply) h.DB.MarkReportSent(user.ID, today) } func quickKeyboard() tgbotapi.ReplyKeyboardMarkup { return tgbotapi.NewReplyKeyboard( tgbotapi.NewKeyboardButtonRow( tgbotapi.NewKeyboardButton("Clock In"), tgbotapi.NewKeyboardButton("Clock Out"), ), ) } 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"), ), ) } 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 = quickKeyboard() h.Bot.Send(reply) } func (h *Handler) sendSuccessWithMenu(chatID int64, text string) { reply := tgbotapi.NewMessage(chatID, text) reply.ReplyMarkup = mainKeyboard() h.Bot.Send(reply) }