cipher-attacks's picture
inline fuzzy + courses
d896946
Raw History Blame Contribute Delete
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
@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)