"""All XP changes go through here. Safety properties * Every award is written to the immutable ledger (xp_events) FIRST; the INSERT is idempotent (UNIQUE user+kind+ref), so retries / double taps / replays can never pay twice. * users.points / week_xp / day_xp are updated with ONE atomic SQL statement (no lost updates). * Automatic sources are capped per day and per kind; the daily cap is enforced here, not in handlers. """ from dataclasses import dataclass from datetime import timedelta from typing import Optional from uuid import uuid4 from aiogram import Bot from sqlalchemy import case, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm.attributes import set_committed_value from app.core import cache, season from app.core.i18n import t from app.core.notify import safe_send from app.db.models import User from app.db.repos import xp as xp_repo from app.services import xp_rules as R @dataclass class AwardResult: amount: int = 0 old_level: int = 1 new_level: int = 1 @property def leveled_up(self) -> bool: return self.new_level > self.old_level async def award( session: AsyncSession, user: User, kind: str, amount: int, ref: Optional[str] = None, *, capped: bool = True ) -> AwardResult: if amount <= 0 or user.is_banned: return AwardResult() now = season.now_et() wk, dk = season.week_key(now), season.day_key(now) daily = capped and kind in R.CAPPED_KINDS if daily: used_today = user.day_xp if user.day_key == dk else 0 amount = min(amount, R.DAILY_CAP - used_today) per_kind = R.PER_KIND_DAILY.get(kind) if amount > 0 and per_kind is not None: if await xp_repo.count_kind_on_day(session, user.id, kind, dk) >= per_kind: amount = 0 if amount <= 0: return AwardResult() if not await xp_repo.insert_event(session, user.id, kind, amount, ref, wk, dk): return AwardResult() # already paid for this exact action values = dict( points=User.points + amount, week_xp=case((User.week_key == wk, User.week_xp + amount), else_=amount), week_key=wk, ) if daily: values.update(day_xp=case((User.day_key == dk, User.day_xp + amount), else_=amount), day_key=dk) stmt = ( update(User) .where(User.id == user.id) .values(**values) .returning(User.points, User.week_xp, User.week_key, User.day_xp, User.day_key) .execution_options(synchronize_session=False) ) row = (await session.execute(stmt)).one() old_points = user.points or 0 for name, value in zip(("points", "week_xp", "week_key", "day_xp", "day_key"), row): set_committed_value(user, name, value) # keep the in-memory copy right without marking it dirty session.sync_session.info["wrote"] = True return AwardResult(amount, R.level_for(old_points)[0], R.level_for(row[0])[0]) async def adjust_trust(session: AsyncSession, user_id: int, delta: int, reason: str, ref: Optional[str] = None) -> bool: """Lower (or raise) a user's trust score. Logged in the ledger as a 0-XP 'flag' event.""" now = season.now_et() event_ref = ref or f"{reason}:{uuid4().hex[:8]}" if not await xp_repo.insert_event(session, user_id, "flag", 0, event_ref, season.week_key(now), season.day_key(now)): return False new_trust = User.trust + delta await session.execute( update(User) .where(User.id == user_id) .values(trust=case((new_trust < 0, 0), (new_trust > 100, 100), else_=new_trust)) .execution_options(synchronize_session=False) ) session.sync_session.info["wrote"] = True session.sync_session.info.setdefault("invalidate", set()).add(user_id) return True async def on_activity(session: AsyncSession, user: User, bot: Bot) -> None: """Called once per update from a gate-passed user: daily check-in, streak, level-up messages.""" now = season.now_et() today = season.day_key(now) last = user.last_active_on.isoformat() if user.last_active_on else None if last == today: return yesterday = season.day_key(now - timedelta(days=1)) user.streak = R.next_streak(last, today, user.streak or 0, yesterday) user.best_streak = max(user.best_streak or 0, user.streak) user.last_active_on = now.date() user.active_days = (user.active_days or 0) + 1 result = await award(session, user, "checkin", R.checkin_xp(user.streak), ref=today) bonus = R.STREAK_MILESTONES.get(user.streak, 0) bonus_result = await award(session, user, "streak_bonus", bonus, ref=f"s{user.streak}:{today}", capped=False) if bonus else AwardResult() if bonus_result.amount: await safe_send(bot, user.id, t(user.lang, "streak_bonus", streak=user.streak, xp=bonus_result.amount)) final_level = bonus_result.new_level if bonus_result.amount else result.new_level if final_level > result.old_level: await announce_level(bot, user, final_level) async def announce_level(bot: Bot, user: User, level: int) -> None: await safe_send(bot, user.id, t(user.lang, "levelup", level=level, title=t(user.lang, f"level_{level}"))) def stale_cache(user_id: int) -> None: cache.invalidate(user_id)