Previous approach (send \u200C then immediately delete it) lost the reply keyboard because Telegram may not persist the keyboard from a deleted message. Now we delete the OLD keyboard message and keep the new one, ensuring the reply keyboard is always present and correct.
1450 lines
41 KiB
Go
1450 lines
41 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
|
|
keyboardMsg map[int64]int
|
|
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),
|
|
keyboardMsg: make(map[int64]int),
|
|
}
|
|
}
|
|
|
|
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())
|
|
} else {
|
|
h.sendSuccessWithMenu(msg.Chat.ID, text)
|
|
h.updateReplyKeyboard(msg.Chat.ID)
|
|
}
|
|
}
|
|
|
|
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)
|
|
h.updateReplyKeyboard(chatID)
|
|
}
|
|
|
|
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", "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 "/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)
|
|
reply := tgbotapi.NewMessage(msg.Chat.ID, "We hope you have a happy day working.")
|
|
reply.ReplyMarkup = h.smartKeyboard(msg.Chat.ID)
|
|
h.Bot.Send(reply)
|
|
menu := tgbotapi.NewMessage(msg.Chat.ID, h.buildStatusText(msg.Chat.ID))
|
|
menu.ReplyMarkup = mainKeyboard()
|
|
h.Bot.Send(menu)
|
|
}
|
|
|
|
func (h *Handler) updateReplyKeyboard(chatID int64) {
|
|
h.mu.Lock()
|
|
oldID := h.keyboardMsg[chatID]
|
|
h.mu.Unlock()
|
|
if oldID != 0 {
|
|
h.Bot.Request(tgbotapi.NewDeleteMessage(chatID, oldID))
|
|
}
|
|
msg := tgbotapi.NewMessage(chatID, "\u200C")
|
|
msg.ReplyMarkup = h.smartKeyboard(chatID)
|
|
sent, err := h.Bot.Send(msg)
|
|
if err == nil {
|
|
h.mu.Lock()
|
|
h.keyboardMsg[chatID] = sent.MessageID
|
|
h.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
h.updateReplyKeyboard(chatID)
|
|
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
|
|
}
|
|
h.updateReplyKeyboard(chatID)
|
|
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
|
|
}
|
|
h.updateReplyKeyboard(chatID)
|
|
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", day.Date)
|
|
|
|
if day.IsDayOff {
|
|
text += "\nDay Off"
|
|
return text + "\n\nChoose an action:"
|
|
}
|
|
|
|
wt, _ := h.DB.GetWorkType(day.CurrentWorkTypeID)
|
|
if wt != nil {
|
|
text += fmt.Sprintf("\nWork type: %s", wt.Name)
|
|
}
|
|
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err == nil && last != nil && last.EventType == "in" {
|
|
loc, _ := time.LoadLocation(user.Timezone)
|
|
t := time.Unix(last.OccurredAt, 0).In(loc).Format("15:04")
|
|
text += fmt.Sprintf("\nStatus: working since %s", t)
|
|
} else {
|
|
text += "\nStatus: not working"
|
|
}
|
|
|
|
events, err := h.DB.EventsForDayByDayID(day.ID)
|
|
if err == nil && len(events) > 0 {
|
|
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", day.Date)
|
|
}
|
|
return fmt.Sprintf("%s - No events recorded.", day.Date)
|
|
}
|
|
|
|
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", day.Date)
|
|
if day.IsDayOff {
|
|
lines += "\nDay Off: Yes"
|
|
}
|
|
lines += "\n\n"
|
|
|
|
for _, e := range events {
|
|
t := time.Unix(e.OccurredAt, 0).In(loc).Format("15:04")
|
|
label := "IN"
|
|
if e.EventType == "out" {
|
|
label = "OUT"
|
|
}
|
|
suffix := ""
|
|
if e.WorkTypeID != nil {
|
|
if wtObj, err := h.DB.GetWorkType(*e.WorkTypeID); err == nil {
|
|
suffix = " [" + wtObj.Name + "]"
|
|
}
|
|
}
|
|
if e.Note != "" {
|
|
suffix += " (" + e.Note + ")"
|
|
}
|
|
lines += fmt.Sprintf("%s %s%s\n", label, t, suffix)
|
|
}
|
|
|
|
workStr := formatDuration(totals.TotalSeconds)
|
|
breakStr := formatDuration(totals.BreakSeconds)
|
|
lines += fmt.Sprintf("\nSummary:\n Work: %s\n Break: %s\n", workStr, breakStr)
|
|
|
|
return lines
|
|
}
|
|
|
|
func (h *Handler) SendDailyReport(userID int64, chatID int64) {
|
|
user, err := h.DB.GetUserByChatID(chatID)
|
|
if err != nil {
|
|
slog.Error("daily report: user not found", "user_id", userID)
|
|
return
|
|
}
|
|
loc, err := time.LoadLocation(user.Timezone)
|
|
if err != nil {
|
|
loc = time.UTC
|
|
}
|
|
now := time.Now().In(loc)
|
|
today := now.Format("2006-01-02")
|
|
|
|
day, err := h.DB.GetOrCreateDay(user.ID, today)
|
|
if err != nil {
|
|
slog.Error("daily report: get day", "error", err)
|
|
return
|
|
}
|
|
events, err := h.DB.EventsForDayByDayID(day.ID)
|
|
if err != nil {
|
|
slog.Error("daily report: get events", "error", err)
|
|
return
|
|
}
|
|
text := h.buildDailyReport(user, day, events)
|
|
reply := tgbotapi.NewMessage(chatID, text)
|
|
reply.ReplyMarkup = h.smartKeyboard(chatID)
|
|
h.Bot.Send(reply)
|
|
h.DB.MarkReportSent(user.ID, today)
|
|
}
|
|
|
|
func (h *Handler) smartKeyboard(chatID int64) tgbotapi.ReplyKeyboardMarkup {
|
|
user, err := h.getOrCreateUser(chatID)
|
|
if err != nil {
|
|
return defaultKeyboard()
|
|
}
|
|
last, err := h.DB.GetLastEvent(user.ID)
|
|
if err != nil || last == nil || last.EventType == "out" {
|
|
return tgbotapi.NewReplyKeyboard(
|
|
tgbotapi.NewKeyboardButtonRow(
|
|
tgbotapi.NewKeyboardButton("Clock In"),
|
|
),
|
|
)
|
|
}
|
|
return tgbotapi.NewReplyKeyboard(
|
|
tgbotapi.NewKeyboardButtonRow(
|
|
tgbotapi.NewKeyboardButton("Clock Out"),
|
|
),
|
|
)
|
|
}
|
|
|
|
func defaultKeyboard() tgbotapi.ReplyKeyboardMarkup {
|
|
return tgbotapi.NewReplyKeyboard(
|
|
tgbotapi.NewKeyboardButtonRow(
|
|
tgbotapi.NewKeyboardButton("Clock In"),
|
|
),
|
|
)
|
|
}
|
|
|
|
func mainKeyboard() tgbotapi.InlineKeyboardMarkup {
|
|
return tgbotapi.NewInlineKeyboardMarkup(
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Clock In", "clockin"),
|
|
tgbotapi.NewInlineKeyboardButtonData("Clock Out", "clockout"),
|
|
),
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Work Type", "worktype"),
|
|
tgbotapi.NewInlineKeyboardButtonData("Day Off", "dayoff"),
|
|
),
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Report", "report"),
|
|
tgbotapi.NewInlineKeyboardButtonData("Export", "export"),
|
|
),
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Settings", "settings"),
|
|
),
|
|
)
|
|
}
|
|
|
|
func backKeyboard() tgbotapi.InlineKeyboardMarkup {
|
|
return tgbotapi.NewInlineKeyboardMarkup(
|
|
tgbotapi.NewInlineKeyboardRow(
|
|
tgbotapi.NewInlineKeyboardButtonData("Back to Menu", "back_menu"),
|
|
),
|
|
)
|
|
}
|
|
|
|
func formatDuration(seconds int64) string {
|
|
hours := seconds / 3600
|
|
mins := (seconds % 3600) / 60
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dh %dm", hours, mins)
|
|
}
|
|
return fmt.Sprintf("%dm", mins)
|
|
}
|
|
|
|
func (h *Handler) sendText(chatID int64, text string) {
|
|
reply := tgbotapi.NewMessage(chatID, text)
|
|
reply.ReplyMarkup = h.smartKeyboard(chatID)
|
|
h.Bot.Send(reply)
|
|
}
|
|
|
|
func (h *Handler) sendSuccessWithMenu(chatID int64, text string) {
|
|
reply := tgbotapi.NewMessage(chatID, text)
|
|
reply.ReplyMarkup = mainKeyboard()
|
|
h.Bot.Send(reply)
|
|
}
|