- GenerateMonthlyReport now takes explicit Gregorian start/end range, computed via monthGregorianRange, so Jalali/Hijri months spanning two Gregorian months are fully covered in exports. - Added loadLocation helper (UTC fallback on error) and replaced all 11 ignored-error call sites in handlers + export. - Added periodicCleanup goroutine in NewHandler to prevent unbounded growth of the rateLimit map. - schemaPath is now read from SCHEMA_PATH env var (init), defaulting to /app/db/schema.sql. - Migrated sendDayView to use sendOrEdit helper, removing duplicated send/edit branching. - Removed dead code: userYearMonthToGregorian (unused), the old startOffset function (replaced). - Updated README with calendar docs, new env vars, full project tree. - Updated .env.example and docker-compose.yml with SCHEMA_PATH. - Simplified Makefile: added fmt, tidy, check, run-dev targets; removed broken Make-glob prerequisite pattern.
1572 lines
46 KiB
Go
1572 lines
46 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 {
|
|
h := &Handler{
|
|
Bot: bot,
|
|
DB: store,
|
|
AllowedUsers: allowed,
|
|
lastMsgTime: make(map[int64]time.Time),
|
|
}
|
|
go h.periodicCleanup()
|
|
return h
|
|
}
|
|
|
|
func (h *Handler) periodicCleanup() {
|
|
for {
|
|
time.Sleep(10 * time.Second)
|
|
h.mu.Lock()
|
|
cutoff := time.Now().Add(-rateLimitInterval * 2)
|
|
for uid, t := range h.lastMsgTime {
|
|
if t.Before(cutoff) {
|
|
delete(h.lastMsgTime, uid)
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
if len(cb.Data) > 4 && cb.Data[:4] == "exp_" {
|
|
h.handleExportCallback(chatID, msgID, cb.ID, cb.Data)
|
|
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 := 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 := 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 := 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 <IANA timezone>\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 [YYYY-MM] - Export monthly report (current month if omitted)
|
|
/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 <name> - Set your timezone (e.g. Asia/Tehran)
|
|
|
|
Dates use your calendar type (Gregorian/Jalali/Hijri).
|
|
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 <name> 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 (your calendar type)")
|
|
return
|
|
}
|
|
if len(date) != 10 || date[4] != '-' || date[7] != '-' {
|
|
h.sendText(msg.Chat.ID, "Invalid date. Use: /edit YYYY-MM-DD (your calendar type)")
|
|
return
|
|
}
|
|
_, m, d := parseGregorianDateKey(date)
|
|
if m < 1 || m > 12 || d < 1 || d > 31 {
|
|
h.sendText(msg.Chat.ID, "Invalid date.")
|
|
return
|
|
}
|
|
user, err := h.getOrCreateUser(msg.Chat.ID)
|
|
if err != nil {
|
|
h.sendText(msg.Chat.ID, "Error loading profile")
|
|
return
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
date = userDateToGregorian(date, user.Calendar)
|
|
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 := 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 {
|
|
h.editCalendar(chatID, msgID, y, m)
|
|
return
|
|
}
|
|
// cal_next_YYYY_M
|
|
if n, _ := fmt.Sscanf(data, "cal_next_%d_%d", &y, &m); n == 2 {
|
|
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 := 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)
|
|
startDate, endDate := monthGregorianRange(cal, 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
|
|
}
|
|
}
|
|
|
|
prevY, prevM, nextY, nextM := navMonth(year, month)
|
|
|
|
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...)
|
|
|
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
|
}
|
|
|
|
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"),
|
|
),
|
|
)
|
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
|
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...)
|
|
|
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
|
}
|
|
|
|
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
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
|
|
// Default to current month in user's calendar
|
|
cy, cm := now.Year(), int(now.Month())
|
|
switch user.Calendar {
|
|
case "jalali":
|
|
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
|
case "hijri":
|
|
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
|
}
|
|
|
|
// Parse optional YYYY-MM argument (in user's calendar)
|
|
if len(msg.Text) > 7 {
|
|
if n, _ := fmt.Sscanf(msg.Text[7:], "%d-%d", &cy, &cm); n == 2 {
|
|
if cy < 0 || cm < 1 || cm > 12 {
|
|
h.sendText(msg.Chat.ID, "Invalid date. Use: /export YYYY-MM")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
h.sendExportDirect(msg.Chat.ID, user, cy, cm)
|
|
}
|
|
|
|
func (h *Handler) sendExportDirect(chatID int64, user *db.User, calYear, calMonth int) {
|
|
// Check if clocked in
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err == nil && last != nil && last.EventType == "in" {
|
|
h.sendText(chatID, "You are currently clocked in. Please clock out first, then try again.")
|
|
return
|
|
}
|
|
|
|
loc := loadLocation(user.Timezone)
|
|
startStr, endStr := monthGregorianRange(user.Calendar, calYear, calMonth)
|
|
start, err := time.Parse("2006-01-02", startStr)
|
|
if err != nil {
|
|
start = time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
|
}
|
|
end, err := time.Parse("2006-01-02", endStr)
|
|
if err != nil {
|
|
startOfMonth := time.Date(calYear, time.Month(calMonth), 1, 0, 0, 0, 0, loc)
|
|
end = startOfMonth.AddDate(0, 1, -1)
|
|
}
|
|
|
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", calYear, "cal_m", calMonth, "from", startStr, "to", endStr)
|
|
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, calYear, calMonth, start, end)
|
|
if err != nil {
|
|
slog.Error("generate report", "error", err)
|
|
h.sendText(chatID, "Error generating report")
|
|
return
|
|
}
|
|
slog.Info("report generated", "bytes", len(data))
|
|
doc := tgbotapi.NewDocument(chatID, tgbotapi.FileBytes{
|
|
Name: fmt.Sprintf("worktime_%s.xlsx", fmt.Sprintf("%04d_%02d", calYear, calMonth)),
|
|
Bytes: data,
|
|
})
|
|
if _, err := h.Bot.Send(doc); err != nil {
|
|
slog.Error("send document", "error", err)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return
|
|
}
|
|
loc := loadLocation(user.Timezone)
|
|
now := time.Now().In(loc)
|
|
cy, cm := now.Year(), int(now.Month())
|
|
switch user.Calendar {
|
|
case "jalali":
|
|
cy, cm, _ = gregorianToJalali(cy, cm, now.Day())
|
|
case "hijri":
|
|
cy, cm, _ = gregorianToHijri(cy, cm, now.Day())
|
|
}
|
|
h.editExportMonthPicker(chatID, msgID, cy, cm, user)
|
|
}
|
|
|
|
func (h *Handler) sendExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) {
|
|
h.editExportMonthPicker(chatID, msgID, year, month, user)
|
|
}
|
|
|
|
func (h *Handler) editExportMonthPicker(chatID int64, msgID int, year, month int, user *db.User) {
|
|
text := "Select month to export:"
|
|
text += fmt.Sprintf("\n\n%s", formatMonthTitle(user.Calendar, year, month))
|
|
|
|
prevY, prevM, nextY, nextM := navMonth(year, month)
|
|
|
|
kb := tgbotapi.NewInlineKeyboardMarkup(
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("<", fmt.Sprintf("exp_prev_%d_%d", prevY, prevM)),
|
|
tgbotapi.NewInlineKeyboardButtonData("Export", fmt.Sprintf("exp_do_%d_%d", year, month)),
|
|
tgbotapi.NewInlineKeyboardButtonData(">", fmt.Sprintf("exp_next_%d_%d", nextY, nextM)),
|
|
),
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
|
),
|
|
)
|
|
|
|
h.sendOrEdit(chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func formatMonthTitle(cal string, year, month int) string {
|
|
switch cal {
|
|
case "jalali":
|
|
return fmt.Sprintf("%s %d", jalaliMonthNames[month-1], year)
|
|
case "hijri":
|
|
return fmt.Sprintf("%s %d", hijriMonthNames[month-1], year)
|
|
default:
|
|
return fmt.Sprintf("%s %d", gregMonthNames[month-1], year)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) handleExportCallback(chatID int64, msgID int, callbackID, data string) {
|
|
defer h.Bot.Request(tgbotapi.NewCallback(callbackID, ""))
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var y, m int
|
|
|
|
if n, _ := fmt.Sscanf(data, "exp_prev_%d_%d", &y, &m); n == 2 {
|
|
h.editExportMonthPicker(chatID, msgID, y, m, user)
|
|
return
|
|
}
|
|
if n, _ := fmt.Sscanf(data, "exp_next_%d_%d", &y, &m); n == 2 {
|
|
h.editExportMonthPicker(chatID, msgID, y, m, user)
|
|
return
|
|
}
|
|
|
|
if n, _ := fmt.Sscanf(data, "exp_do_%d_%d", &y, &m); n == 2 {
|
|
// Check if clocked in
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err == nil && last != nil && last.EventType == "in" {
|
|
text := "You are currently clocked in. Please clock out first, then try again."
|
|
kb := tgbotapi.NewInlineKeyboardMarkup(
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
|
),
|
|
)
|
|
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
|
edit.ReplyMarkup = &kb
|
|
h.Bot.Send(edit)
|
|
return
|
|
}
|
|
// Convert from user's calendar to full Gregorian range
|
|
startStr, endStr := monthGregorianRange(user.Calendar, y, m)
|
|
start, err := time.Parse("2006-01-02", startStr)
|
|
if err != nil {
|
|
h.sendText(chatID, "Error processing date")
|
|
return
|
|
}
|
|
end, err := time.Parse("2006-01-02", endStr)
|
|
if err != nil {
|
|
h.sendText(chatID, "Error processing date")
|
|
return
|
|
}
|
|
slog.Info("export", "user_id", user.ID, "cal", user.Calendar, "cal_y", y, "cal_m", m, "from", startStr, "to", endStr)
|
|
data, err := GenerateMonthlyReport(h.DB, user.ID, user.Timezone, user.ExportAccent, user.Calendar, y, m, start, end)
|
|
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", fmt.Sprintf("%04d_%02d", y, m)),
|
|
Bytes: data,
|
|
})
|
|
if _, err := h.Bot.Send(doc); err != nil {
|
|
slog.Error("send document", "error", err)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
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 := 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 := 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()
|
|
now := time.Now().In(loc)
|
|
todayStr := now.Format("2006-01-02")
|
|
dayEnd := time.Date(y, time.Month(m), d, 23, 59, 59, 0, loc).Unix()
|
|
if day.Date == todayStr {
|
|
dayEnd = now.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))
|
|
}
|
|
|
|
func navMonth(year, month int) (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++
|
|
}
|
|
return
|
|
}
|
|
|
|
func (h *Handler) sendOrEdit(chatID int64, msgID int, text string, kb *tgbotapi.InlineKeyboardMarkup) {
|
|
if msgID == 0 {
|
|
msg := tgbotapi.NewMessage(chatID, text)
|
|
msg.ReplyMarkup = kb
|
|
h.Bot.Send(msg)
|
|
} else {
|
|
edit := tgbotapi.NewEditMessageText(chatID, msgID, text)
|
|
edit.ReplyMarkup = kb
|
|
h.Bot.Send(edit)
|
|
}
|
|
}
|