feat: add telegram guardrails for trials and referrals
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user