Spaces:
Running on Zero
Running on Zero
Download app/services/xp.py from cipher-attacks/Dbu-central-bot: direct link, hf CLI and curl.
- Browser
- Download file 5.28 kB
-
https://huggingface.co/spaces/cipher-attacks/Dbu-central-bot/resolve/main/app/services/xp.py
- Command line
-
hf download hf://spaces/cipher-attacks/Dbu-central-bot/app/services/xp.py
-
curl -L -o xp.py https://huggingface.co/spaces/cipher-attacks/Dbu-central-bot/resolve/main/app/services/xp.py
5.28 kB
| """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 | |
| class AwardResult: | |
| amount: int = 0 | |
| old_level: int = 1 | |
| new_level: int = 1 | |
| 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) | |