All checks were successful
CI / build (push) Successful in 50s
All /setrate, /setcurrency, /settimezone, /setbreak, /setusername, /setreporttime now send a confirmation message, matching the UI flow. Also fixes rate confirmation to show the actual currency symbol.
829 lines
29 KiB
Go
829 lines
29 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"worktimeBot/internal/domain"
|
|
)
|
|
|
|
// -- /start and /help --
|
|
|
|
func (h *Handler) handleStart(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
slog.Info("start", "user_id", user.ID, "chat_id", msg.Chat.ID)
|
|
kb := mainKeyboard()
|
|
h.sendWithKB(ctx, msg.Chat.ID, h.buildStatusText(user), kb)
|
|
}
|
|
|
|
func (h *Handler) handleSettings(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
|
|
return
|
|
}
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
|
}
|
|
|
|
func (h *Handler) handleHelp(ctx context.Context, msg *models.Message) {
|
|
text := `Commands:
|
|
/start - Show main menu
|
|
/settings - Open settings
|
|
/clockin - Clock in
|
|
/clockout - Clock out
|
|
/worktype - Select work type
|
|
/dayoff - Toggle day off
|
|
/report - Show today report
|
|
/export [YYYY-MM] - Export monthly report
|
|
/history - View calendar history
|
|
/edit YYYY-MM-DD - Edit a specific day
|
|
/note [YYYY-MM-DD] HH:MM <text> - Set note for an event
|
|
/summary [YYYY-MM] - Show monthly summary
|
|
/reporttoggle - Enable/disable auto report
|
|
/setreporttime HH:MM - Set daily report time
|
|
/setaccent - Select accent color
|
|
/settimezone <name> - Set timezone (e.g. Asia/Tehran)
|
|
/setbreak <minutes> - Set break threshold in minutes
|
|
/setusername <name> - Set your display name
|
|
/rpg - Show RPG stats and XP
|
|
/achievements - View unlocked achievements
|
|
/salary - Show salary estimate
|
|
/burnout - View burnout assessment
|
|
/league - View WorkTime League rankings
|
|
/setrate <amount> - Set hourly rate (e.g. 25.50)
|
|
/setcurrency <symbol> <code> - Set currency
|
|
/rpgtoggle - Enable/disable RPG system
|
|
/leaguetoggle - Enable/disable league participation`
|
|
h.sendText(ctx, msg.Chat.ID, text)
|
|
}
|
|
|
|
// -- Settings menu callbacks --
|
|
|
|
func (h *Handler) settingsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettings, breakThreshold int64) (string, models.InlineKeyboardMarkup) {
|
|
reportStatus := "off"
|
|
if st.ReportEnabled {
|
|
reportStatus = "on"
|
|
}
|
|
rpgStatus := "off"
|
|
if st.RPGEnabled {
|
|
rpgStatus = "on"
|
|
}
|
|
leagueStatus := "off"
|
|
if st.RPGEnabled && st.LeagueOptIn {
|
|
leagueStatus = "on"
|
|
}
|
|
rows := [][]models.InlineKeyboardButton{
|
|
{{Text: fmt.Sprintf("Username: %s", user.Username), CallbackData: "username"}},
|
|
{
|
|
{Text: fmt.Sprintf("Timezone: %s", user.Timezone), CallbackData: "timezone"},
|
|
{Text: fmt.Sprintf("Calendar: %s", user.Calendar), CallbackData: "caltype"},
|
|
},
|
|
{
|
|
{Text: fmt.Sprintf("Report: %s", reportStatus), CallbackData: "reporttoggle"},
|
|
{Text: fmt.Sprintf("Report time: %s", st.ReportTime), CallbackData: "reporttime"},
|
|
},
|
|
{
|
|
{Text: fmt.Sprintf("Break: %dm", breakThreshold/60), CallbackData: "breakthreshold"},
|
|
{Text: fmt.Sprintf("Accent: %s", st.ExportAccent), CallbackData: "accent"},
|
|
},
|
|
{
|
|
{Text: fmt.Sprintf("RPG: %s", rpgStatus), CallbackData: "rpgtoggle"},
|
|
{Text: fmt.Sprintf("League: %s", leagueStatus), CallbackData: "leaguetoggle"},
|
|
},
|
|
{
|
|
{Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
|
|
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
|
|
},
|
|
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
|
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
|
}
|
|
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
}
|
|
|
|
func (h *Handler) backToSettings(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Toggle callbacks (binary select pickers) --
|
|
|
|
func (h *Handler) rpgToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) leagueToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if !st.RPGEnabled {
|
|
kb := settingsBackKeyboard()
|
|
h.editText(ctx, chatID, msgID, "League requires the RPG system. Enable RPG first in Settings.", &kb)
|
|
return
|
|
}
|
|
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) reportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text, kb := h.buildToggleKeyboard(user.ID, "report", "Daily Report")
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) buildToggleKeyboard(userID int64, setting, label string) (string, models.InlineKeyboardMarkup) {
|
|
st, err := h.Repo.GetOrCreateSettings(userID)
|
|
current := false
|
|
if err == nil {
|
|
switch setting {
|
|
case "rpg":
|
|
current = st.RPGEnabled
|
|
case "league":
|
|
current = st.LeagueOptIn
|
|
case "report":
|
|
current = st.ReportEnabled
|
|
}
|
|
}
|
|
options := []struct {
|
|
label string
|
|
data string
|
|
value bool
|
|
}{
|
|
{"Enabled", setting + "_enable", true},
|
|
{"Disabled", setting + "_disable", false},
|
|
}
|
|
rows := [][]models.InlineKeyboardButton{}
|
|
for _, opt := range options {
|
|
l := opt.label
|
|
if opt.value == current {
|
|
l = "> " + l
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: l, CallbackData: opt.data},
|
|
})
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: "Back to Settings", CallbackData: "back_settings"},
|
|
})
|
|
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
}
|
|
|
|
func (h *Handler) setRPGCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
st.RPGEnabled = enable
|
|
if enable {
|
|
h.Repo.GetOrCreateRPGStats(user.ID)
|
|
h.tryUnlockAchievement(user.ID, "rpg_pioneer")
|
|
} else {
|
|
st.LeagueOptIn = false
|
|
}
|
|
h.Repo.UpdateSettings(st)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) setLeagueCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if enable && !st.RPGEnabled {
|
|
h.answerCbWithText(ctx, cbID, "Enable RPG first")
|
|
return
|
|
}
|
|
st.LeagueOptIn = enable
|
|
h.Repo.UpdateSettings(st)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) setReportToggleCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, enable bool) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
st.ReportEnabled = enable
|
|
h.Repo.UpdateSettings(st)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Prompt callbacks (settings items that need text input) --
|
|
|
|
func (h *Handler) usernameCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingUsername,
|
|
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
|
|
}
|
|
|
|
func (h *Handler) rateCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingRate,
|
|
"Send me the hourly rate (e.g. 25.50):", nil)
|
|
}
|
|
|
|
func (h *Handler) currencyCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingCurrency,
|
|
"Send me the currency (symbol and code, e.g. $ USD):\nPresets: $ USD, T Toman, IRR Rial, EUR, GBP", nil)
|
|
}
|
|
|
|
func (h *Handler) timezoneCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingTimezone,
|
|
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone (e.g. Asia/Tehran, Europe/London):", user.Timezone), nil)
|
|
}
|
|
|
|
func (h *Handler) breakThresholdCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingBreak,
|
|
"Send me the break threshold in minutes (0-480, e.g. 15):", nil)
|
|
}
|
|
|
|
func (h *Handler) reportTimeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
|
current := "08:00"
|
|
if st != nil {
|
|
current = st.ReportTime
|
|
}
|
|
h.promptForInput(ctx, chatID, msgID, user.ID, PendingReportTime,
|
|
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM (24-hour):", current), nil)
|
|
}
|
|
|
|
func (h *Handler) calTypeCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text, kb := h.buildCalendarTypeKeyboard(user)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) accentCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
text, kb := h.buildAccentKeyboard(st)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Accent picker --
|
|
|
|
func (h *Handler) buildAccentKeyboard(st *domain.UserSettings) (string, models.InlineKeyboardMarkup) {
|
|
type accentOption struct{ name, color string }
|
|
accents := []accentOption{
|
|
{"ocean", "Blue"},
|
|
{"beach", "Warm"},
|
|
{"rose", "Pink"},
|
|
{"catppuccin", "Purple"},
|
|
}
|
|
rows := [][]models.InlineKeyboardButton{}
|
|
for _, a := range accents {
|
|
label := fmt.Sprintf("%s (%s)", a.name, a.color)
|
|
if a.name == st.ExportAccent {
|
|
label = "> " + label
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: label, CallbackData: "accent_" + a.name},
|
|
})
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: "Back to Settings", CallbackData: "back_settings"},
|
|
})
|
|
return "Select accent color:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
}
|
|
|
|
func (h *Handler) selectAccent(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
|
|
defer h.answerCb(ctx, cbID)
|
|
valid := map[string]bool{"ocean": true, "beach": true, "rose": true, "catppuccin": true}
|
|
if !valid[name] {
|
|
return
|
|
}
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
st.ExportAccent = name
|
|
h.Repo.UpdateSettings(st)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Calendar type picker --
|
|
|
|
func (h *Handler) buildCalendarTypeKeyboard(user *domain.User) (string, models.InlineKeyboardMarkup) {
|
|
cals := []string{"gregorian", "jalali", "hijri"}
|
|
calLabels := map[string]string{
|
|
"gregorian": "Gregorian",
|
|
"jalali": "Jalali",
|
|
"hijri": "Hijri",
|
|
}
|
|
rows := [][]models.InlineKeyboardButton{}
|
|
for _, c := range cals {
|
|
label := calLabels[c]
|
|
if c == user.Calendar {
|
|
label = "> " + label
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: label, CallbackData: "caltype_" + c},
|
|
})
|
|
}
|
|
rows = append(rows, []models.InlineKeyboardButton{
|
|
{Text: "Back to Settings", CallbackData: "back_settings"},
|
|
})
|
|
return "Select calendar type:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
}
|
|
|
|
func (h *Handler) selectCalendar(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, name string) {
|
|
defer h.answerCb(ctx, cbID)
|
|
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
|
if !valid[name] {
|
|
return
|
|
}
|
|
user.Calendar = name
|
|
if err := h.Repo.UpdateUser(user); err != nil {
|
|
return
|
|
}
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Break threshold select (preset picker) --
|
|
|
|
func (h *Handler) selectBreakThreshold(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, valStr string) {
|
|
defer h.answerCb(ctx, cbID)
|
|
mins, err := strconv.Atoi(valStr)
|
|
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
|
|
return
|
|
}
|
|
loc := h.loadLocation(user.Timezone)
|
|
today := time.Now().In(loc).Format(domain.DateLayout)
|
|
day, err := h.Repo.GetOrCreateDay(user.ID, today)
|
|
if err != nil {
|
|
return
|
|
}
|
|
day.MinBreakThreshold = int64(mins) * 60
|
|
h.Repo.UpdateDay(day)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Currency preset picker --
|
|
|
|
func (h *Handler) selectCurrency(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User, currency string) {
|
|
defer h.answerCb(ctx, cbID)
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
st.Currency = currency
|
|
h.Repo.UpdateSettings(st)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- Command implementations for settings (prompt-and-wait OR direct args) --
|
|
|
|
func (h *Handler) handleSetRate(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setrate"))
|
|
if args == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingRate,
|
|
"Send me the hourly rate (e.g. 25.50):", nil)
|
|
return
|
|
}
|
|
var rate float64
|
|
if _, err := fmt.Sscanf(args, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
|
|
h.sendText(ctx, msg.Chat.ID, "Invalid rate. Use a positive number (e.g. /setrate 25.50)")
|
|
return
|
|
}
|
|
h.saveRate(ctx, msg.Chat.ID, user, rate)
|
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
|
sym := "$"
|
|
if st != nil && st.Currency != "" {
|
|
sym = domain.CurrencySymbol(st.Currency)
|
|
}
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate))
|
|
}
|
|
|
|
func (h *Handler) handleSetCurrency(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcurrency"))
|
|
if args == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCurrency,
|
|
"Send me the currency (symbol and code, e.g. $ USD):", nil)
|
|
return
|
|
}
|
|
currency, errMsg := domain.ParseCurrencyInput(args)
|
|
if errMsg != "" {
|
|
h.sendText(ctx, msg.Chat.ID, errMsg)
|
|
return
|
|
}
|
|
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Currency set to: %s", currency))
|
|
}
|
|
|
|
func (h *Handler) handleSetTimezone(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/settimezone"))
|
|
if args == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingTimezone,
|
|
fmt.Sprintf("Current timezone: %s\n\nSend me the IANA timezone:", user.Timezone), nil)
|
|
return
|
|
}
|
|
if _, err := time.LoadLocation(args); err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", args))
|
|
return
|
|
}
|
|
h.saveTimezone(ctx, msg.Chat.ID, user, args)
|
|
loc := h.loadLocation(args)
|
|
now := time.Now().In(loc).Format(domain.TimeLayout)
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Timezone set to %s (current time: %s)", args, now))
|
|
}
|
|
|
|
func (h *Handler) handleSetBreak(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setbreak"))
|
|
if args == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingBreak,
|
|
"Send me the break threshold in minutes (0-480):", nil)
|
|
return
|
|
}
|
|
mins, err := strconv.Atoi(args)
|
|
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
|
|
h.sendText(ctx, msg.Chat.ID, "Invalid value. Must be between 0 and 480 minutes.")
|
|
return
|
|
}
|
|
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Break threshold set to %d minutes.", mins))
|
|
}
|
|
|
|
func (h *Handler) handleSetUsername(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
name := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setusername"))
|
|
if name == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingUsername,
|
|
fmt.Sprintf("Current username: %s\n\nSend me the new username:", user.Username), nil)
|
|
return
|
|
}
|
|
h.saveUsername(ctx, msg.Chat.ID, user, name)
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Username set to: %s", name))
|
|
}
|
|
|
|
func (h *Handler) handleSetReportTime(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setreporttime"))
|
|
if args == "" {
|
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
|
current := "08:00"
|
|
if st != nil {
|
|
current = st.ReportTime
|
|
}
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingReportTime,
|
|
fmt.Sprintf("Current report time: %s\n\nSend me the report time in HH:MM:", current), nil)
|
|
return
|
|
}
|
|
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, args); err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, err.Error())
|
|
return
|
|
}
|
|
h.sendText(ctx, msg.Chat.ID, fmt.Sprintf("Report time set to %s", args))
|
|
}
|
|
|
|
func (h *Handler) handleSetAccent(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
|
|
return
|
|
}
|
|
text, kb := h.buildAccentKeyboard(st)
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
|
}
|
|
|
|
func (h *Handler) handleRPGSettings(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
text, kb := h.buildToggleKeyboard(user.ID, "rpg", "RPG System")
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
|
}
|
|
|
|
func (h *Handler) handleLeagueToggle(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, msg.Chat.ID, "Error loading settings")
|
|
return
|
|
}
|
|
if !st.RPGEnabled {
|
|
h.sendText(ctx, msg.Chat.ID, "League requires the RPG system. Enable RPG first with /rpgtoggle.")
|
|
return
|
|
}
|
|
text, kb := h.buildToggleKeyboard(user.ID, "league", "WorkTime League")
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
|
}
|
|
|
|
// -- Pending input processors --
|
|
|
|
func (h *Handler) processRateInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
var rate float64
|
|
if _, err := fmt.Sscanf(text, "%f", &rate); err != nil || rate < 0 || rate > domain.MaxHourlyRate {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, "Invalid rate. Use a positive number.", &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "setrate"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.saveRate(ctx, msg.Chat.ID, user, rate)
|
|
backKB := settingsBackKeyboard()
|
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
|
sym := "$"
|
|
if st != nil && st.Currency != "" {
|
|
sym = domain.CurrencySymbol(st.Currency)
|
|
}
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
fmt.Sprintf("Hourly rate set to %s%.2f", sym, rate), &backKB)
|
|
}
|
|
|
|
func (h *Handler) processCurrencyInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
currency, errMsg := domain.ParseCurrencyInput(text)
|
|
if errMsg != "" {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, errMsg, &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "currency"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.saveCurrency(ctx, msg.Chat.ID, user, currency)
|
|
backKB := settingsBackKeyboard()
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Currency set to: %s", currency), &backKB)
|
|
}
|
|
|
|
func (h *Handler) processTimezoneInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
if _, err := time.LoadLocation(text); err != nil {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
fmt.Sprintf("Invalid timezone: %s\nUse IANA format, e.g. Asia/Tehran", text), &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "timezone"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.saveTimezone(ctx, msg.Chat.ID, user, text)
|
|
loc := h.loadLocation(text)
|
|
now := time.Now().In(loc)
|
|
backKB := settingsBackKeyboard()
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
fmt.Sprintf("Timezone set to %s (current time: %s)", text, now.Format(domain.TimeLayout)), &backKB)
|
|
}
|
|
|
|
func (h *Handler) handleSetCalendar(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/setcalendar"))
|
|
if args == "" {
|
|
h.promptForInput(ctx, msg.Chat.ID, 0, user.ID, PendingCalendar,
|
|
fmt.Sprintf("Current calendar: %s\n\nValid options: gregorian, jalali, hijri", user.Calendar), nil)
|
|
return
|
|
}
|
|
args = strings.ToLower(args)
|
|
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
|
if !valid[args] {
|
|
h.sendText(ctx, msg.Chat.ID, "Invalid calendar. Valid options: gregorian, jalali, hijri")
|
|
return
|
|
}
|
|
h.saveCalendar(ctx, msg.Chat.ID, user, args)
|
|
}
|
|
|
|
func (h *Handler) processCalendarInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
text = strings.ToLower(strings.TrimSpace(text))
|
|
valid := map[string]bool{"gregorian": true, "jalali": true, "hijri": true}
|
|
if !valid[text] {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
"Invalid calendar. Valid options: gregorian, jalali, hijri", &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "caltype"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.saveCalendar(ctx, msg.Chat.ID, user, text)
|
|
backKB := settingsBackKeyboard()
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
fmt.Sprintf("Calendar set to %s", text), &backKB)
|
|
}
|
|
|
|
func (h *Handler) saveCalendar(_ context.Context, _ int64, user *domain.User, cal string) {
|
|
user.Calendar = cal
|
|
if err := h.Repo.UpdateUser(user); err != nil {
|
|
slog.Error("save calendar failed", "user_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) processBreakInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
mins, err := strconv.Atoi(text)
|
|
if err != nil || mins < 0 || mins > domain.MaxBreakThresholdMins {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID,
|
|
"Invalid value. Must be between 0 and 480 minutes.", &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "breakthreshold"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.saveBreakThreshold(ctx, msg.Chat.ID, user, mins)
|
|
st, _ := h.Repo.GetOrCreateSettings(user.ID)
|
|
bt := h.getTodayBreakThreshold(user)
|
|
text2, kb := h.buildSettingsKeyboard(user, st, bt)
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, text2, &kb)
|
|
}
|
|
|
|
func (h *Handler) processUsernameInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
h.saveUsername(ctx, msg.Chat.ID, user, text)
|
|
backKB := settingsBackKeyboard()
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Username set to: %s", text), &backKB)
|
|
}
|
|
|
|
func (h *Handler) processReportTimeInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
if err := h.validateAndSaveReportTime(ctx, msg.Chat.ID, user, text); err != nil {
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, err.Error(), &models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Try again", CallbackData: "reporttime"}},
|
|
{{Text: "Cancel", CallbackData: "back_settings"}},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
backKB := settingsBackKeyboard()
|
|
h.editText(ctx, msg.Chat.ID, pi.MsgID, fmt.Sprintf("Report time set to %s", text), &backKB)
|
|
}
|
|
|
|
func (h *Handler) processNoteInput(ctx context.Context, msg *models.Message, user *domain.User, pi *PendingInput, text string) {
|
|
eventIDStr, ok := pi.Data["event_id"]
|
|
if !ok {
|
|
return
|
|
}
|
|
var eventID int64
|
|
if _, err := fmt.Sscanf(eventIDStr, "%d", &eventID); err != nil {
|
|
return
|
|
}
|
|
note := domain.SanitizeNote(text)
|
|
if err := h.Repo.SetEventNote(eventID, note); err != nil {
|
|
slog.Error("failed to save event note", "event_id", eventID, "error", err)
|
|
return
|
|
}
|
|
event, err := h.Repo.GetEventByID(eventID, user.ID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
loc := h.loadLocation(user.Timezone)
|
|
date := time.Unix(event.OccurredAt, 0).In(loc).Format(domain.DateLayout)
|
|
h.sendDayView(ctx, msg.Chat.ID, 0, user, date, loc, true)
|
|
}
|
|
|
|
// -- Shared save helpers --
|
|
|
|
func (h *Handler) saveRate(ctx context.Context, chatID int64, user *domain.User, rate float64) {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
h.sendText(ctx, chatID, "Error saving rate")
|
|
return
|
|
}
|
|
wasZero := st.HourlyRate == 0
|
|
st.HourlyRate = float64(int(rate*domain.SalaryRoundFactor)) / 100.0
|
|
if err := h.Repo.UpdateSettings(st); err != nil {
|
|
h.sendText(ctx, chatID, "Error saving rate")
|
|
return
|
|
}
|
|
if wasZero {
|
|
h.tryUnlockAchievement(user.ID, "salary_setter")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveCurrency(_ context.Context, _ int64, user *domain.User, currency string) {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
slog.Error("save currency: get settings failed", "user_id", user.ID, "error", err)
|
|
return
|
|
}
|
|
st.Currency = currency
|
|
if err := h.Repo.UpdateSettings(st); err != nil {
|
|
slog.Error("save currency: update settings failed", "user_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveTimezone(_ context.Context, _ int64, user *domain.User, tz string) {
|
|
user.Timezone = tz
|
|
if err := h.Repo.UpdateUser(user); err != nil {
|
|
slog.Error("save timezone failed", "user_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveBreakThreshold(_ context.Context, _ int64, user *domain.User, mins int) {
|
|
loc := h.loadLocation(user.Timezone)
|
|
today := time.Now().In(loc).Format(domain.DateLayout)
|
|
day, err := h.Repo.GetOrCreateDay(user.ID, today)
|
|
if err != nil {
|
|
slog.Error("save break threshold: get day failed", "user_id", user.ID, "error", err)
|
|
return
|
|
}
|
|
day.MinBreakThreshold = int64(mins) * 60
|
|
if err := h.Repo.UpdateDay(day); err != nil {
|
|
slog.Error("save break threshold: update day failed", "user_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveUsername(_ context.Context, _ int64, user *domain.User, name string) {
|
|
name = domain.SanitizeDisplayName(name)
|
|
if name == "" {
|
|
return
|
|
}
|
|
user.Username = name
|
|
if err := h.Repo.UpdateUser(user); err != nil {
|
|
slog.Error("save username failed", "user_id", user.ID, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *domain.User, timeStr string) error {
|
|
if len(timeStr) != 5 || timeStr[2] != ':' {
|
|
return fmt.Errorf("invalid format. Use HH:MM (24-hour)")
|
|
}
|
|
hours, err1 := strconv.Atoi(timeStr[:2])
|
|
mins, err2 := strconv.Atoi(timeStr[3:])
|
|
if err1 != nil || err2 != nil || hours < 0 || hours > 23 || mins < 0 || mins > 59 {
|
|
return fmt.Errorf("invalid time. Use HH:MM (24-hour)")
|
|
}
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil {
|
|
return fmt.Errorf("error saving report time")
|
|
}
|
|
st.ReportTime = timeStr
|
|
return h.Repo.UpdateSettings(st)
|
|
}
|
|
|
|
func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{
|
|
{Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", user.ID), Style: "danger"},
|
|
{Text: "Cancel", CallbackData: "back_settings"},
|
|
},
|
|
},
|
|
}
|
|
h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (events, days, RPG progress, settings, achievements).\nAre you sure?", &kb)
|
|
}
|
|
|
|
func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
if err := h.Repo.DeleteUserData(user.ID); err != nil {
|
|
slog.Error("delete user data", "user_id", user.ID, "error", err)
|
|
h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil)
|
|
return
|
|
}
|
|
h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil)
|
|
}
|