All checks were successful
CI / build (push) Successful in 37s
- Add delete all data button with red danger style and confirmation prompt - Add DeleteUserData repo method that wipes user from all tables in a transaction - Add helper methods deleteAccountPrompt and deleteAccountConfirm - Implement consistency_master, perfect_week, remote_veteran, onsite_master achievements - Add GetDailyXPInRange and SumWorkSecondsByWorkType repo methods - Update Dockerfile to alpine3.23, docker-compose to use env vars for proxy/paths
400 lines
12 KiB
Go
400 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-telegram/bot/models"
|
|
|
|
"worktimeBot/internal/domain"
|
|
)
|
|
|
|
// -- Commands --
|
|
|
|
func (h *Handler) handleRPG(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, "RPG system is disabled. Enable it in Settings or use /rpgtoggle.")
|
|
return
|
|
}
|
|
text := h.buildRPGStatus(user)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
|
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
|
},
|
|
}
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, kb)
|
|
}
|
|
|
|
func (h *Handler) handleAchievements(ctx context.Context, msg *models.Message, user *domain.User) {
|
|
text := h.buildAchievementsList(user)
|
|
h.sendWithKB(ctx, msg.Chat.ID, text, backKeyboard())
|
|
}
|
|
|
|
// -- Callbacks --
|
|
|
|
func (h *Handler) rpgCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text := h.buildRPGStatus(user)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
|
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
|
},
|
|
}
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) achievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text := h.buildAchievementsList(user)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
|
},
|
|
}
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) rpgAchievementsCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text := h.buildAchievementsList(user)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Back to RPG", CallbackData: "rpg_back"}},
|
|
},
|
|
}
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
func (h *Handler) rpgBackCallback(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) {
|
|
defer h.answerCb(ctx, cbID)
|
|
text := h.buildRPGStatus(user)
|
|
kb := models.InlineKeyboardMarkup{
|
|
InlineKeyboard: [][]models.InlineKeyboardButton{
|
|
{{Text: "Achievements", CallbackData: "rpg_achievements"}},
|
|
{{Text: "Back to Menu", CallbackData: "back_menu"}},
|
|
},
|
|
}
|
|
h.editText(ctx, chatID, msgID, text, &kb)
|
|
}
|
|
|
|
// -- RPG business logic --
|
|
|
|
func (h *Handler) buildRPGStatus(user *domain.User) string {
|
|
st, err := h.Repo.GetOrCreateSettings(user.ID)
|
|
if err != nil || !st.RPGEnabled {
|
|
return "RPG system is disabled."
|
|
}
|
|
stats, err := h.Repo.GetOrCreateRPGStats(user.ID)
|
|
if err != nil {
|
|
return "RPG stats unavailable"
|
|
}
|
|
nextLevelXP := domain.XpForLevel(stats.Level + 1)
|
|
currentLevelXP := domain.XpForLevel(stats.Level)
|
|
xpIntoLevel := stats.XP - currentLevelXP
|
|
xpNeeded := nextLevelXP - currentLevelXP
|
|
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("Level: %d\n", stats.Level))
|
|
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%%)\n", stats.XP, nextLevelXP, xpIntoLevel*100/xpNeeded))
|
|
b.WriteString(fmt.Sprintf("Streak: %d days (best: %d)\n", stats.CurrentStreak, stats.LongestStreak))
|
|
hoursTotal := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
|
|
b.WriteString(fmt.Sprintf("Total work: %.1f hours\n", hoursTotal))
|
|
achs, _ := h.Repo.GetUserAchievements(user.ID)
|
|
if len(achs) > 0 {
|
|
b.WriteString(fmt.Sprintf("Achievements: %d unlocked\n", len(achs)))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (h *Handler) buildAchievementsList(user *domain.User) string {
|
|
achs, err := h.Repo.GetUserAchievements(user.ID)
|
|
if err != nil {
|
|
return "Achievements unavailable"
|
|
}
|
|
if len(achs) == 0 {
|
|
return "No achievements unlocked yet.\nWork consistently to earn them!"
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(fmt.Sprintf("Achievements (%d):\n\n", len(achs)))
|
|
for _, a := range achs {
|
|
t := time.Unix(a.UnlockedAt, 0)
|
|
b.WriteString(fmt.Sprintf(" %s\n %s - %s\n", a.Name, a.Description, t.Format(domain.DateLayout)))
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (h *Handler) awardXPAndCheckAchievements(user *domain.User, stats *domain.RPGStats, sessionWork int64, today string, isWeekend bool) string {
|
|
text := ""
|
|
h.updateStreak(stats, user.ID, today)
|
|
xp, leveledUp, newLevel, _ := h.computeAndAwardXP(stats, user.ID, sessionWork, stats.CurrentStreak, today)
|
|
totalHours := float64(stats.TotalWorkSeconds) / domain.SecondsPerHour
|
|
unlocked := h.checkAchievements(user.ID, stats, sessionWork, today, isWeekend, totalHours)
|
|
text += fmt.Sprintf("\n\n+%d XP (Lv.%d)", xp, newLevel)
|
|
if leveledUp {
|
|
text += "\nLevel up!"
|
|
}
|
|
for _, a := range unlocked {
|
|
text += fmt.Sprintf("\nAchievement: %s", a)
|
|
}
|
|
return text
|
|
}
|
|
|
|
func (h *Handler) computeAndAwardXP(stats *domain.RPGStats, userID int64, workSeconds int64, streak int, date string) (newXP int64, leveledUp bool, newLevel int, _ error) {
|
|
baseXP := domain.XpForWorkSeconds(workSeconds)
|
|
bonus := domain.StreakBonus(streak)
|
|
totalXP := baseXP + bonus
|
|
if totalXP < 0 {
|
|
totalXP = 0
|
|
}
|
|
stats.XP += totalXP
|
|
newLevel = domain.LevelForXP(stats.XP)
|
|
leveledUp = newLevel > stats.Level
|
|
stats.Level = newLevel
|
|
stats.TotalWorkSeconds += workSeconds
|
|
if err := h.Repo.UpdateRPGStats(stats); err != nil {
|
|
return 0, false, 0, err
|
|
}
|
|
if err := h.Repo.UpsertDailyXP(userID, date, totalXP, workSeconds); err != nil {
|
|
slog.Warn("failed to upsert daily xp", "user_id", userID, "error", err)
|
|
}
|
|
return stats.XP, leveledUp, newLevel, nil
|
|
}
|
|
|
|
func (h *Handler) updateStreak(stats *domain.RPGStats, userID int64, date string) {
|
|
t, err := time.Parse(domain.DateLayout, date)
|
|
if err != nil {
|
|
return
|
|
}
|
|
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
|
|
prevEvents, err := h.Repo.EventsForDayByDate(userID, yesterday)
|
|
hadWorkYesterday := err == nil && len(prevEvents) > 0
|
|
if hadWorkYesterday {
|
|
stats.CurrentStreak++
|
|
} else {
|
|
stats.CurrentStreak = 1
|
|
}
|
|
if stats.CurrentStreak > stats.LongestStreak {
|
|
stats.LongestStreak = stats.CurrentStreak
|
|
}
|
|
}
|
|
|
|
func (h *Handler) recalcDayWorkSeconds(userID int64, date string, loc *time.Location) {
|
|
events, err := h.Repo.EventsForDayByDate(userID, date)
|
|
if err != nil {
|
|
return
|
|
}
|
|
day, err := h.Repo.GetDay(userID, date)
|
|
if err != nil {
|
|
return
|
|
}
|
|
y, m, d := domain.ParseGregorianDateKey(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 := domain.ComputeDailyTotals(events, day.MinBreakThreshold, dayStart, dayEnd)
|
|
if err := h.Repo.SetDailyWorkSeconds(userID, date, totals.TotalSeconds); err != nil {
|
|
slog.Warn("failed to set daily work seconds", "user_id", userID, "date", date, "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) recalcAggregates(userID int64, loc *time.Location) {
|
|
total, err := h.Repo.SumDailyWorkSeconds(userID)
|
|
if err != nil {
|
|
slog.Warn("failed to sum daily work seconds", "user_id", userID, "error", err)
|
|
return
|
|
}
|
|
stats, err := h.Repo.GetOrCreateRPGStats(userID)
|
|
if err != nil {
|
|
slog.Warn("failed to get rpg stats for recalc", "user_id", userID, "error", err)
|
|
return
|
|
}
|
|
stats.TotalWorkSeconds = total
|
|
dates, err := h.Repo.GetDatesWithEvents(userID)
|
|
today := time.Now().In(loc).Format(domain.DateLayout)
|
|
if err == nil {
|
|
stats.CurrentStreak, stats.LongestStreak = domain.ComputeStreakFromDates(dates, today)
|
|
} else {
|
|
stats.CurrentStreak = 0
|
|
stats.LongestStreak = 0
|
|
}
|
|
if err := h.Repo.UpdateRPGStats(stats); err != nil {
|
|
slog.Warn("failed to update rpg stats after recalc", "user_id", userID, "error", err)
|
|
}
|
|
}
|
|
|
|
// -- Achievements --
|
|
|
|
func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSeconds int64, date string, isWeekend bool, totalHours float64) []string {
|
|
u := &achievementUnlocker{h: h, userID: userID}
|
|
h.checkEarlyAchievements(u, workSeconds)
|
|
h.checkEventTimeAchievements(u, userID, date)
|
|
h.checkStreakAchievements(u, stats)
|
|
h.checkHourAchievements(u, stats, totalHours)
|
|
h.checkSessionAchievements(u, workSeconds, isWeekend)
|
|
h.checkLevelAchievements(u, stats)
|
|
h.checkConsistencyAchievements(u, userID, date)
|
|
h.checkPerfectWeekAchievements(u, userID, date)
|
|
h.checkWorkTypeAchievements(u, userID)
|
|
return u.unlocked
|
|
}
|
|
|
|
type achievementUnlocker struct {
|
|
h *Handler
|
|
userID int64
|
|
unlocked []string
|
|
}
|
|
|
|
func (u *achievementUnlocker) unlock(code string) {
|
|
a, err := u.h.Repo.GetAchievementByCode(code)
|
|
if err != nil {
|
|
return
|
|
}
|
|
ok, err := u.h.Repo.UnlockAchievement(u.userID, a.ID)
|
|
if err != nil {
|
|
slog.Error("unlock achievement", "code", code, "error", err)
|
|
return
|
|
}
|
|
if ok {
|
|
u.unlocked = append(u.unlocked, a.Name)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkEarlyAchievements(u *achievementUnlocker, workSeconds int64) {
|
|
has, _ := h.Repo.HasAchievement(u.userID, "first_clockin")
|
|
if !has && workSeconds > 0 {
|
|
u.unlock("first_clockin")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkEventTimeAchievements(u *achievementUnlocker, userID int64, date string) {
|
|
events, err := h.Repo.EventsForDayByDate(userID, date)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range events {
|
|
et := time.Unix(e.OccurredAt, 0)
|
|
hh, mm, _ := et.Clock()
|
|
minutes := hh*60 + mm
|
|
if e.EventType == "in" && minutes < 7*60 {
|
|
u.unlock("early_bird")
|
|
}
|
|
if e.EventType == "out" && minutes >= 22*60 {
|
|
u.unlock("night_owl")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkStreakAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
|
|
if stats.CurrentStreak >= 5 {
|
|
u.unlock("iron_streak_5")
|
|
}
|
|
if stats.CurrentStreak >= 10 {
|
|
u.unlock("iron_streak_10")
|
|
}
|
|
if stats.CurrentStreak >= 30 {
|
|
u.unlock("iron_streak_30")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkHourAchievements(u *achievementUnlocker, stats *domain.RPGStats, hoursWorked float64) {
|
|
if hoursWorked >= 100.0 {
|
|
u.unlock("hundred_hours")
|
|
}
|
|
if hoursWorked >= 500.0 {
|
|
u.unlock("five_hundred_hours")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkSessionAchievements(u *achievementUnlocker, workSeconds int64, isWeekend bool) {
|
|
if workSeconds > 10*domain.SecondsPerHour {
|
|
u.unlock("overtime_hero")
|
|
}
|
|
if isWeekend && workSeconds > 0 {
|
|
u.unlock("weekend_warrior")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.RPGStats) {
|
|
if stats.Level >= 5 {
|
|
u.unlock("level_5")
|
|
}
|
|
if stats.Level >= 10 {
|
|
u.unlock("level_10")
|
|
}
|
|
if stats.Level >= 25 {
|
|
u.unlock("level_25")
|
|
}
|
|
if stats.Level >= 50 {
|
|
u.unlock("level_50")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkConsistencyAchievements(u *achievementUnlocker, userID int64, today string) {
|
|
t, err := time.Parse(domain.DateLayout, today)
|
|
if err != nil {
|
|
return
|
|
}
|
|
sevenDaysAgo := t.AddDate(0, 0, -7).Format(domain.DateLayout)
|
|
yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout)
|
|
records, err := h.Repo.GetDailyXPInRange(userID, sevenDaysAgo, yesterday)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(records) < 7 {
|
|
return
|
|
}
|
|
allHaveSixHours := true
|
|
for _, r := range records {
|
|
if r.WorkSeconds < 6*domain.SecondsPerHour {
|
|
allHaveSixHours = false
|
|
break
|
|
}
|
|
}
|
|
if allHaveSixHours {
|
|
u.unlock("consistency_master")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) checkPerfectWeekAchievements(u *achievementUnlocker, userID int64, today string) {
|
|
t, err := time.Parse(domain.DateLayout, today)
|
|
if err != nil {
|
|
return
|
|
}
|
|
start := t.AddDate(0, 0, -6).Format(domain.DateLayout)
|
|
days, err := h.Repo.GetUserDaysInRange(userID, start, today)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if len(days) < 7 {
|
|
return
|
|
}
|
|
for _, d := range days {
|
|
if d.IsDayOff {
|
|
return
|
|
}
|
|
}
|
|
u.unlock("perfect_week")
|
|
}
|
|
|
|
func (h *Handler) checkWorkTypeAchievements(u *achievementUnlocker, userID int64) {
|
|
rem, ons, err := h.Repo.SumWorkSecondsByWorkType(userID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if rem >= 100*domain.SecondsPerHour {
|
|
u.unlock("remote_veteran")
|
|
}
|
|
if ons >= 100*domain.SecondsPerHour {
|
|
u.unlock("onsite_master")
|
|
}
|
|
}
|