feat: add telegram guardrails for trials and referrals

This commit is contained in:
3252a8
2026-06-04 10:51:58 +03:00
parent 33707b7257
commit 2ad6b14513
26 changed files with 1944 additions and 125 deletions
+53 -3
View File
@@ -407,6 +407,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Триал без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для "
"активации триала. Disposable email домены всегда требуют Telegram."
),
optional=False,
subsection="trial",
),
SettingField(
"TRIAL_SQUAD_UUIDS",
"string",
@@ -417,12 +429,50 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
# ─── Referral program ──────────────────────────────────────────
SettingField(
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
"REFERRAL_ONE_BONUS_PER_REFEREE",
"bool",
"pricing",
"Один бонус на приглашённого",
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
"REFERRAL_WELCOME_BONUS_DAYS",
"int",
"pricing",
"Приветственный бонус (дней)",
min=0,
subsection="referral",
),
SettingField(
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
"bool",
"pricing",
"Приветственный бонус без Telegram",
(
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
),
subsection="referral",
),
SettingField(
"LEGACY_REFS",
"bool",
"pricing",
"Поддержка старых ref-ссылок",
subsection="referral",
),
SettingField(
"DISPOSABLE_EMAIL_DOMAINS",
"text",
"pricing",
"Disposable email домены",
(
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
"email не смогут получить trial или реферальный приветственный бонус."
),
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
subsection="referral",
),
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
SettingField(
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
"bool",
+1 -1
View File
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
+120 -4
View File
@@ -1012,13 +1012,53 @@ async def _request_email_code(
def _telegram_id_for_user(user: User) -> Optional[int]:
if user.telegram_id:
return int(user.telegram_id)
if user.user_id and int(user.user_id) > 0:
return int(user.user_id)
telegram_id = getattr(user, "telegram_id", None)
if telegram_id:
return int(telegram_id)
user_id = getattr(user, "user_id", None)
if user_id and int(user_id) > 0:
return int(user_id)
return None
def _user_has_linked_telegram(user: User) -> bool:
return bool(getattr(user, "telegram_id", None))
def _email_only_telegram_required_reason(
settings: Settings,
user: User,
*,
without_telegram_enabled_attr: str,
) -> Optional[str]:
if _user_has_linked_telegram(user):
return None
if is_disposable_email(getattr(user, "email", None), settings):
return "disposable_email"
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
return "telegram_required"
return None
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
)
def _referral_welcome_telegram_required_reason(
settings: Settings,
user: User,
) -> Optional[str]:
return _email_only_telegram_required_reason(
settings,
user,
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
)
def _panel_description_for_user(user: User) -> str:
return panel_description_from_profile(
user.username,
@@ -1433,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
if not raw_referral_param or not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
if _referral_welcome_telegram_required_reason(settings, user):
return None
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
async def _grant_referral_welcome_bonus_if_eligible(
request: web.Request,
session: AsyncSession,
user: User,
) -> Optional[datetime]:
if not user.referred_by_id:
return None
settings: Settings = request.app["settings"]
referral_welcome_days = max(
0,
@@ -1456,6 +1511,67 @@ async def _apply_referral_welcome_bonus_if_needed(
)
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
if not value:
return None
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return normalized.strftime("%d.%m.%Y %H:%M")
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
rate_limit_response = await _enforce_webapp_rate_limit(
request,
user_id=user_id,
action="referral_welcome_claim",
)
if rate_limit_response:
return rate_limit_response
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
await session.rollback()
return _json_error(403, "access_denied", "Access denied")
reason = _referral_welcome_telegram_required_reason(settings, db_user)
if reason:
await session.rollback()
return _json_error(400, "referral_welcome_telegram_required", reason)
end_date = await _grant_referral_welcome_bonus_if_eligible(
request,
session,
db_user,
)
if not end_date:
await session.rollback()
return _json_error(
400,
"referral_welcome_unavailable",
"Referral welcome bonus is not available",
)
await session.commit()
except Exception:
await session.rollback()
logger.exception("Referral welcome bonus claim failed")
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
return web.json_response(
{
"ok": True,
"claimed": True,
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
"end_date_text": _webapp_datetime_text(end_date),
}
)
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
+8
View File
@@ -1,6 +1,7 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import _trial_telegram_required_reason
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
from db.dal import message_log_dal
@@ -390,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
if telegram_required_reason:
return _json_error(
400,
"trial_telegram_required",
telegram_required_reason,
)
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
if not activation_result or not activation_result.get("activated"):
+1
View File
@@ -85,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
"/api/account/telegram/notifications/probe",
account_telegram_notifications_probe_route,
)
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
app.router.add_post("/api/promo/apply", apply_promo_route)
app.router.add_post("/api/trial/activate", activate_trial_route)
app.router.add_get("/api/devices", devices_route)
+32 -4
View File
@@ -1,6 +1,11 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.auth import (
_referral_welcome_telegram_required_reason,
_trial_telegram_required_reason,
_user_has_linked_telegram,
)
from config.subscription_guides_config import subscription_guides_available
from config.webapp_themes_config import public_themes_catalog_payload
from bot.services.telegram_notifications import (
@@ -64,11 +69,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if active and local_sub
else None
)
trial_available = bool(
trial_base_available = bool(
settings.TRIAL_ENABLED
and settings.TRIAL_DURATION_DAYS > 0
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
)
trial_telegram_required_reason = (
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
)
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
plans_payload = _serialize_plans(
settings,
@@ -95,6 +104,15 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
telegram_linked = _user_has_linked_telegram(db_user)
referral_welcome_days = max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
)
referral_welcome_telegram_required_reason = (
_referral_welcome_telegram_required_reason(settings, db_user)
if db_user.referred_by_id and not active and referral_welcome_days > 0
else None
)
telegram_notifications_status = normalize_telegram_notification_status(
getattr(db_user, "telegram_notifications_status", None)
)
@@ -111,7 +129,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
db_user.email and db_user.email_verified_at and db_user.password_hash
),
"telegram_id": db_user.telegram_id,
"telegram_linked": bool(_telegram_id_for_user(db_user)),
"telegram_linked": telegram_linked,
"telegram_notifications_status": telegram_notifications_status,
"telegram_notifications_enabled": (
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
@@ -137,9 +155,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
"webapp_link": webapp_referral_link,
"invited_count": referral_stats.get("invited_count", 0),
"purchased_count": referral_stats.get("purchased_count", 0),
"welcome_bonus_days": max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
"welcome_bonus_days": referral_welcome_days,
"welcome_bonus_without_telegram_enabled": bool(
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
),
"welcome_bonus_requires_telegram": bool(
referral_welcome_telegram_required_reason and not telegram_linked
),
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
"one_bonus_per_referee": bool(
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
),
@@ -174,6 +197,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
),
"trial_enabled": bool(settings.TRIAL_ENABLED),
"trial_available": trial_available,
"trial_without_telegram_enabled": bool(
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
),
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
"trial_block_reason": trial_telegram_required_reason,
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
return (value or "").strip().lower()
def email_domain(value: Optional[str]) -> str:
email = normalize_email(value or "")
if "@" not in email:
return ""
return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
def is_valid_email(value: str) -> bool:
email = normalize_email(value)
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
def _split_disposable_domain_values(value: str) -> list[str]:
return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
domain = email_domain(value)
if not domain:
return False
blocked_domains = getattr(settings, "disposable_email_domains", None)
if blocked_domains is None:
blocked_domains = _split_disposable_domain_values(
str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
)
blocked_domains = blocked_domains or []
for blocked in blocked_domains:
normalized = str(blocked or "").strip().lower().lstrip("@.")
if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
return True
return False
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
target_part = "none" if target_user_id is None else str(target_user_id)
return f"{purpose}:{target_part}:{email}"