Merge branch 'dev' into patch-1
This commit is contained in:
@@ -9,7 +9,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive", "expired"}:
|
||||
if target not in {"all", "active", "inactive", "expired", "never"}:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
@@ -24,6 +24,8 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
elif target == "expired":
|
||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||
elif target == "never":
|
||||
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
@@ -54,3 +56,20 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||
|
||||
|
||||
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||
"""Return how many users each broadcast audience currently resolves to."""
|
||||
_require_admin_user_id(request)
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
counts = {
|
||||
"all": len(await user_dal.get_all_active_user_ids_for_broadcast(session)),
|
||||
"active": len(await user_dal.get_user_ids_with_active_subscription(session)),
|
||||
"inactive": len(await user_dal.get_user_ids_without_active_subscription(session)),
|
||||
"expired": len(await user_dal.get_user_ids_with_expired_subscription(session)),
|
||||
"never": len(await user_dal.get_user_ids_without_any_subscription(session)),
|
||||
}
|
||||
|
||||
return _ok({"counts": counts})
|
||||
|
||||
@@ -94,6 +94,9 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False))
|
||||
premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False))
|
||||
premium_limit_bytes = _premium_limit_bytes_from_subscription(sub)
|
||||
provider = sub.provider
|
||||
is_trial = str(provider or "").strip().lower() == "trial"
|
||||
display_label = "Trial" if is_trial else sub.tariff_key
|
||||
return {
|
||||
"subscription_id": int(sub.subscription_id),
|
||||
"panel_user_uuid": sub.panel_user_uuid,
|
||||
@@ -117,9 +120,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
|
||||
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
|
||||
"tariff_key": sub.tariff_key,
|
||||
"display_label": display_label,
|
||||
"is_trial": is_trial,
|
||||
"auto_renew_enabled": bool(sub.auto_renew_enabled),
|
||||
"provider": sub.provider,
|
||||
"provider": provider,
|
||||
"is_throttled": bool(sub.is_throttled),
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
|
||||
admin_user_hwid_device_limit_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
@@ -57,6 +61,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
|
||||
@@ -136,10 +136,6 @@ def _favicon_digest(url: str) -> Optional[str]:
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
@@ -156,15 +152,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
@@ -184,10 +171,9 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
# Emoji logos were removed; purge any leftover animated-emoji cache files.
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
if path.is_file():
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
@@ -389,7 +375,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
|
||||
@@ -745,6 +745,24 @@ def _user_search_condition(query: str):
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def _serialize_trial_summary(user: User, trial_subs: List[Subscription]) -> Dict[str, Any]:
|
||||
first_trial_sub = trial_subs[0] if trial_subs else None
|
||||
latest_trial_sub = trial_subs[-1] if trial_subs else None
|
||||
first_start = getattr(first_trial_sub, "start_date", None)
|
||||
latest_start = getattr(latest_trial_sub, "start_date", None)
|
||||
latest_end = getattr(latest_trial_sub, "end_date", None)
|
||||
reset_at = getattr(user, "trial_eligibility_reset_at", None)
|
||||
return {
|
||||
"used": bool(trial_subs),
|
||||
"count": len(trial_subs),
|
||||
"first_activated_at": first_start.isoformat() if first_start else None,
|
||||
"latest_activated_at": latest_start.isoformat() if latest_start else None,
|
||||
"latest_end_date": latest_end.isoformat() if latest_end else None,
|
||||
"active": bool(latest_trial_sub and getattr(latest_trial_sub, "is_active", False)),
|
||||
"last_reset_at": reset_at.isoformat() if reset_at else None,
|
||||
}
|
||||
|
||||
|
||||
async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
@@ -764,6 +782,15 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
.limit(20)
|
||||
)
|
||||
latest_subs = (await session.execute(latest_subs_stmt)).scalars().all()
|
||||
trial_subs_stmt = (
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.user_id == target_id,
|
||||
sa_func.lower(sa_func.coalesce(Subscription.provider, "")) == "trial",
|
||||
)
|
||||
.order_by(Subscription.start_date.asc().nullslast(), Subscription.end_date.asc())
|
||||
)
|
||||
trial_subs = (await session.execute(trial_subs_stmt)).scalars().all()
|
||||
total_paid = await payment_dal.get_user_total_paid(session, target_id)
|
||||
recent_payments_stmt = (
|
||||
select(Payment)
|
||||
@@ -830,12 +857,14 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
serialized_inviter = (
|
||||
_serialize_admin_user_with_avatar(inviter, avatar_keys) if inviter is not None else None
|
||||
)
|
||||
trial_payload = _serialize_trial_summary(user, trial_subs)
|
||||
|
||||
return _ok(
|
||||
{
|
||||
"user": serialized_user,
|
||||
"active_subscription": _serialize_subscription(active_sub) if active_sub else None,
|
||||
"subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])],
|
||||
"trial": trial_payload,
|
||||
"total_paid": float(total_paid),
|
||||
"recent_payments": [_serialize_payment(p) for p in recent_payments],
|
||||
"log_count": int(log_count or 0),
|
||||
@@ -1318,6 +1347,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
|
||||
"""Override the user's base HWID device limit.
|
||||
|
||||
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
|
||||
default is used. Purchased extra devices remain tracked separately and are
|
||||
added when syncing the effective panel limit.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
|
||||
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
|
||||
|
||||
if unlimited:
|
||||
hwid_device_limit: Optional[int] = 0
|
||||
elif use_default or limit_raw is None or limit_raw == "":
|
||||
hwid_device_limit = None
|
||||
else:
|
||||
try:
|
||||
hwid_device_limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be a non-negative integer",
|
||||
)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be an integer from 0 to 1000000",
|
||||
)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = None
|
||||
if subscription_service is not None:
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_id
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_hwid_device_limit_webapp",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""Credit regular or premium traffic to a user without a payment.
|
||||
|
||||
@@ -1414,6 +1515,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
extend_hwid_devices = payload.get("extend_hwid_devices")
|
||||
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
@@ -1426,6 +1529,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=extend_hwid_devices,
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
@@ -1436,7 +1540,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"content": (
|
||||
f"+{days}d -> {new_end.isoformat()} "
|
||||
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
|
||||
@@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
|
||||
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||
),
|
||||
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
||||
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||
@@ -77,14 +76,20 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"int",
|
||||
"general",
|
||||
"ID обязательного канала",
|
||||
"Telegram ID канала, в котором нужно состоять.",
|
||||
(
|
||||
"Telegram ID канала для проверки подписки. Если бот видит канал, "
|
||||
"ссылка кнопки будет получена автоматически."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"REQUIRED_CHANNEL_LINK",
|
||||
"string",
|
||||
"general",
|
||||
"Ссылка на канал",
|
||||
"Имя пользователя или invite-link.",
|
||||
(
|
||||
"Необязательно: публичный @username или invite-link, "
|
||||
"если ссылку нельзя получить по ID канала."
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_URL",
|
||||
@@ -101,6 +106,42 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Секретный ключ API панели.",
|
||||
secret=True,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||
"float",
|
||||
"remnawave",
|
||||
"Panel API total timeout",
|
||||
"Maximum total time for one Remnawave API request, in seconds.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||
"float",
|
||||
"remnawave",
|
||||
"Panel API connect timeout",
|
||||
"Maximum time to get or open a Remnawave API connection, in seconds.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||
"float",
|
||||
"remnawave",
|
||||
"Panel API socket connect timeout",
|
||||
"Maximum TCP/TLS connection time for Remnawave API, in seconds.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||
"float",
|
||||
"remnawave",
|
||||
"Panel API socket read timeout",
|
||||
"Maximum time to wait for response data from Remnawave API, in seconds.",
|
||||
optional=False,
|
||||
min=1,
|
||||
),
|
||||
SettingField(
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"string",
|
||||
@@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"string",
|
||||
"appearance",
|
||||
"Шрифт эмоджи-логотипа",
|
||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||
choices=(
|
||||
("system", "Системный (по умолчанию)"),
|
||||
("noto-color", "Noto Color Emoji"),
|
||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||
("noto-emoji", "Noto Emoji"),
|
||||
("twemoji", "Twitter Emoji"),
|
||||
("openmoji", "OpenMoji"),
|
||||
("apple", "Apple Color Emoji (local)"),
|
||||
("segoe", "Segoe UI Emoji (local)"),
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"bool",
|
||||
@@ -348,7 +369,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
@@ -386,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",
|
||||
@@ -396,12 +429,82 @@ 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(
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые ref-ссылки Remnashop",
|
||||
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые промокоды Remnashop",
|
||||
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"string",
|
||||
"migrations",
|
||||
"Последний импорт Remnashop",
|
||||
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
"text",
|
||||
"migrations",
|
||||
"Заметки по миграции Remnashop",
|
||||
"Внутренние заметки оператора по перенесенному инстансу.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -734,6 +837,7 @@ def manifest_payload() -> List[dict]:
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
"migrations": 13,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
|
||||
@@ -318,6 +318,81 @@
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- Admin controls: range sliders and sortable rows ---------- */
|
||||
|
||||
.theme-key-ascii .ui-range-input {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__range {
|
||||
height: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb {
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb:hover,
|
||||
.theme-key-ascii .ui-range-input__thumb:focus-visible {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable {
|
||||
--sortable-drop-line: #ffffff;
|
||||
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-dragging {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dashed #ffffff;
|
||||
outline-offset: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
|
||||
top: -5px;
|
||||
height: 1px;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle {
|
||||
align-self: center;
|
||||
height: 28px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle:hover,
|
||||
.theme-key-ascii .ui-sortable-handle:focus-visible,
|
||||
.theme-key-ascii .ui-sortable-handle:active {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
@@ -1104,6 +1179,7 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
.admin-cn-card-skeleton--tall,
|
||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||
.admin-tabs-trigger, .admin-tabs-list,
|
||||
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
|
||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||
.admin-dialog,
|
||||
@@ -1243,3 +1319,51 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
.theme-key-ascii table tbody tr:hover td {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Newer webapp surfaces: telegram banner, traffic/referral
|
||||
* dropdowns, login language picker. Flatten the accent pills,
|
||||
* rounded badges and colored gradients these ship with so they
|
||||
* read as plain console boxes.
|
||||
* ============================================================ */
|
||||
|
||||
/* Telegram notifications banner: the .card chrome is already
|
||||
* flattened above; only the rounded, color-tinted icon badge needs
|
||||
* squaring off (the Send glyph itself is whitened by the global rule). */
|
||||
.theme-key-ascii .telegram-notifications-icon {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Premium-server / referral-tariff dropdown help glyph: drop the
|
||||
* pill background in every state (the accent maps to white here,
|
||||
* which would otherwise paint a white blob behind the icon). */
|
||||
.theme-key-ascii .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* The check on the selected language sits on a solid white row, so a
|
||||
* white glyph would vanish — invert it to black to keep it readable. */
|
||||
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-ascii .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
|
||||
.theme-key-ascii .emoji-flag {
|
||||
filter: grayscale(1) contrast(1.05);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 4,
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -204,3 +204,56 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-light .ui-range-input::before {
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__range {
|
||||
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb {
|
||||
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb:focus-visible {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle {
|
||||
border-radius: 6px;
|
||||
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle:hover,
|
||||
.theme-key-light .ui-sortable-handle:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.055);
|
||||
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable {
|
||||
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
|
||||
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target::before {
|
||||
background: var(--sortable-drop-line);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Telegram notifications banner: keep the warm warning tint but swap the
|
||||
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
|
||||
.theme-key-light .telegram-notifications-card {
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"assets_version": 5,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
.theme-key-windows95 svg.lucide-file-text,
|
||||
.theme-key-windows95 svg.lucide-gift,
|
||||
.theme-key-windows95 svg.lucide-globe-2,
|
||||
.theme-key-windows95 svg.lucide-grip-vertical,
|
||||
.theme-key-windows95 svg.lucide-home,
|
||||
.theme-key-windows95 svg.lucide-house,
|
||||
.theme-key-windows95 svg.lucide-info,
|
||||
@@ -232,6 +233,10 @@
|
||||
--win95-button-icon: var(--win95-icon-globe);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-grip-vertical {
|
||||
--win95-button-icon: var(--win95-icon-sliders);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-file-text {
|
||||
--win95-button-icon: var(--win95-icon-file-text);
|
||||
}
|
||||
@@ -375,6 +380,7 @@
|
||||
svg.lucide-file-text,
|
||||
svg.lucide-gift,
|
||||
svg.lucide-globe-2,
|
||||
svg.lucide-grip-vertical,
|
||||
svg.lucide-home,
|
||||
svg.lucide-house,
|
||||
svg.lucide-info,
|
||||
@@ -1171,6 +1177,96 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-windows95 .ui-range-input {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
border-radius: 0 !important;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__range {
|
||||
height: 8px;
|
||||
border-radius: 0 !important;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb {
|
||||
width: 14px;
|
||||
height: 20px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0 !important;
|
||||
background: #c0c0c0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb:hover,
|
||||
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb[data-active] {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: 3px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
|
||||
top: -7px;
|
||||
height: 2px;
|
||||
border-radius: 0;
|
||||
background: #000080;
|
||||
box-shadow:
|
||||
0 1px 0 #ffffff,
|
||||
0 -1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle {
|
||||
align-self: center;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:hover,
|
||||
.theme-key-windows95 .ui-sortable-handle:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 input::placeholder,
|
||||
.theme-key-windows95 textarea::placeholder,
|
||||
.theme-key-windows95 .input::placeholder,
|
||||
@@ -1352,3 +1448,64 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
|
||||
color: #800080;
|
||||
}
|
||||
|
||||
/* ---------- Newer webapp surfaces: telegram banner, traffic /
|
||||
* referral dropdowns, login language picker ---------- */
|
||||
|
||||
/* Telegram notifications banner: the Card chrome is already beveled by
|
||||
* the shared .card rule; give the icon badge a raised chip look instead
|
||||
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
|
||||
.theme-key-windows95 .telegram-notifications-icon {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
|
||||
* rest of the surfaces so they don't read as flat 1px boxes. */
|
||||
.theme-key-windows95 .referral-tariff-dropdown,
|
||||
.theme-key-windows95 .referral-bonus-row {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .referral-bonus-row-nested {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
/* Premium-server / referral help glyph: drop the rounded accent pill so it
|
||||
* sits inline as a plain stroked question mark. */
|
||||
.theme-key-windows95 .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The selected language row turns navy; its check maps to a dark bitmap,
|
||||
* so invert it to white to keep it visible. */
|
||||
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
|
||||
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-windows95 .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 11,
|
||||
"assets_version": 13,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -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
|
||||
@@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||
|
||||
@@ -33,7 +33,6 @@ def create_subscription_webapp_application(
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
|
||||
@@ -206,9 +206,6 @@ async def theme_asset_route(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return ""
|
||||
|
||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||
if not raw_logo_url:
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
@@ -303,29 +300,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
|
||||
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
|
||||
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
|
||||
codepoints = _emoji_to_codepoints(emoji)
|
||||
if not codepoints or ext not in {"gif", "webp"}:
|
||||
return ""
|
||||
return f"/webapp-emoji/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
raise web.HTTPNotFound(text="webapp_logo_disabled")
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||
@@ -521,37 +497,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re
|
||||
return web.Response(body=body, content_type=content_type)
|
||||
|
||||
|
||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
||||
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_not_found")
|
||||
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
async with cache_lock:
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if emoji_cache:
|
||||
emoji_caches[emoji_cache_key] = emoji_cache
|
||||
|
||||
if not emoji_cache:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
|
||||
|
||||
body, content_type = emoji_cache
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||
return
|
||||
@@ -573,111 +520,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
|
||||
return
|
||||
|
||||
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
|
||||
if not codepoints:
|
||||
return
|
||||
|
||||
app.setdefault("webapp_emoji_cache", {})
|
||||
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
|
||||
|
||||
for ext in ("gif", "webp"):
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
if emoji_cache_key in emoji_caches:
|
||||
continue
|
||||
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if loaded_emoji:
|
||||
emoji_caches[emoji_cache_key] = loaded_emoji
|
||||
if ext == "gif":
|
||||
return
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_animated_emoji(
|
||||
codepoints: str, ext: str
|
||||
) -> Optional[Tuple[bytes, str]]:
|
||||
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
|
||||
if disk_emoji:
|
||||
return disk_emoji
|
||||
|
||||
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if fetched_emoji:
|
||||
await asyncio.to_thread(
|
||||
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
|
||||
)
|
||||
return fetched_emoji
|
||||
|
||||
|
||||
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
body = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return None
|
||||
return body, "image/gif" if ext == "gif" else "image/webp"
|
||||
|
||||
|
||||
def _write_webapp_animated_emoji_to_disk(
|
||||
codepoints: str, ext: str, emoji: Tuple[bytes, str]
|
||||
) -> None:
|
||||
body, _content_type = emoji
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return
|
||||
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(body)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
|
||||
|
||||
|
||||
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
try:
|
||||
session = await _get_shared_http_session()
|
||||
timeout = ClientTimeout(total=4)
|
||||
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
|
||||
async with session.get(
|
||||
source_url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
|
||||
if content_type and content_type != expected_content_type:
|
||||
return None
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
return bytes(body), expected_content_type
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
|
||||
if disk_logo:
|
||||
@@ -923,7 +765,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
"traffic_packages": settings.traffic_packages,
|
||||
"stars_traffic_packages": settings.stars_traffic_packages,
|
||||
"support_url": settings.SUPPORT_LINK or "",
|
||||
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
|
||||
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
||||
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
@@ -937,9 +778,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
def _resolve_app_version() -> str:
|
||||
# Single source of truth shared with the telemetry worker so the admin
|
||||
# sidebar and the install beacon always report the same version.
|
||||
from bot.utils.app_version import resolve_app_version
|
||||
from bot.utils import app_version as app_version_module
|
||||
|
||||
return resolve_app_version()
|
||||
global _APP_VERSION_CACHE
|
||||
|
||||
app_version_module.APP_ROOT = APP_ROOT
|
||||
app_version_module._run_git_command = _run_git_command
|
||||
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
|
||||
version = app_version_module.resolve_app_version()
|
||||
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
|
||||
return version
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=APP_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
async def _enforce_webapp_rate_limit(
|
||||
@@ -1144,9 +1007,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"themesDir": settings.WEBAPP_THEMES_DIR,
|
||||
"themePreviewKey": preview_key,
|
||||
"logoUrl": cached["logo_url"],
|
||||
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
|
||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||
"faviconUrl": cached["favicon_url"],
|
||||
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
||||
"apiBase": "/api",
|
||||
@@ -1157,7 +1017,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
||||
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
|
||||
"supportUrl": cached["support_url"],
|
||||
"termsUrl": cached["terms_url"],
|
||||
"privacyPolicyUrl": cached["privacy_policy_url"],
|
||||
"userAgreementUrl": cached["user_agreement_url"],
|
||||
"currency": cached["currency"],
|
||||
@@ -1308,12 +1167,6 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if (
|
||||
not brand_asset_url
|
||||
and settings.WEBAPP_LOGO_USE_EMOJI
|
||||
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
|
||||
):
|
||||
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
|
||||
if brand_asset_url:
|
||||
html = html.replace(
|
||||
"</head>",
|
||||
@@ -1683,6 +1536,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"font_sans": "--font-sans",
|
||||
"font_logo": "--font-logo",
|
||||
"font_mono": "--font-mono",
|
||||
"home_logo_scale": "--home-logo-scale",
|
||||
"home_logo_scale_desktop": "--home-logo-scale-desktop",
|
||||
"home_logo_scale_mobile": "--home-logo-scale-mobile",
|
||||
"admin_bg": "--admin-bg",
|
||||
"admin_surface": "--admin-surface",
|
||||
"admin_surface_2": "--admin-surface-2",
|
||||
@@ -1694,6 +1550,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"admin_dim": "--admin-dim",
|
||||
}
|
||||
|
||||
_INITIAL_THEME_LOGO_SCALE_TOKENS = {
|
||||
"home_logo_scale",
|
||||
"home_logo_scale_desktop",
|
||||
"home_logo_scale_mobile",
|
||||
}
|
||||
|
||||
|
||||
def _theme_css_href_for_html(theme: Any) -> str:
|
||||
css_file = str(getattr(theme, "css_file", "") or "").strip()
|
||||
@@ -1737,6 +1599,14 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
|
||||
tokens = tokens if isinstance(tokens, dict) else {}
|
||||
declarations = []
|
||||
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
||||
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
|
||||
try:
|
||||
scale = float(tokens.get(token_key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if scale > 0:
|
||||
declarations.append(f"{css_name}:{scale / 100:g}")
|
||||
continue
|
||||
value = str(tokens.get(token_key) or "").strip()
|
||||
if value:
|
||||
declarations.append(f"{css_name}:{value}")
|
||||
|
||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -1010,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,
|
||||
@@ -1292,17 +1334,35 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||
if settings is None:
|
||||
return False
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _strip_referral_param_prefix(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
preserve_current_u_prefix: bool,
|
||||
) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return ""
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
@@ -1310,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _referral_param_lookup_candidates(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> List[str]:
|
||||
if not remnashop_compat:
|
||||
normalized = _normalize_referral_param(raw)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
candidates.append(value[1:])
|
||||
|
||||
unique: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||
candidates = _referral_param_lookup_candidates(
|
||||
raw_referral_param,
|
||||
remnashop_compat=remnashop_compat,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
if not ref_user:
|
||||
return None
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
return int(ref_user.user_id)
|
||||
for normalized in candidates:
|
||||
ref_user = None
|
||||
if normalized.isdigit() and not remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
normalized,
|
||||
include_legacy=remnashop_compat,
|
||||
)
|
||||
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
continue
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
continue
|
||||
return int(ref_user.user_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
@@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
settings=request.app["settings"],
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
@@ -1374,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,
|
||||
@@ -1397,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],
|
||||
@@ -1427,6 +1602,7 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
# 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
|
||||
|
||||
|
||||
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _plain_text_message(value: Any) -> str:
|
||||
"""Strip Telegram-style HTML markup from a localized message for the web app."""
|
||||
text = _HTML_TAG_RE.sub("", str(value))
|
||||
return html.unescape(text).strip()
|
||||
|
||||
|
||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
@@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not success:
|
||||
await session.commit()
|
||||
return _json_error(400, "promo_apply_failed", str(result))
|
||||
return _json_error(400, "promo_apply_failed", _plain_text_message(result))
|
||||
await session.commit()
|
||||
end_date = result if isinstance(result, datetime) else None
|
||||
return web.json_response(
|
||||
@@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
hwid_quote: Optional[Dict[str, Any]] = None
|
||||
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
||||
|
||||
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
|
||||
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
|
||||
if tariffs_config and requested_sale_mode in {
|
||||
"hwid_device",
|
||||
"hwid_devices",
|
||||
"hwid_devices_renewal",
|
||||
}:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
if not tariff_key:
|
||||
@@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=int(payment_units),
|
||||
tariff_key=sale_tariff_key,
|
||||
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
|
||||
renewal=False,
|
||||
currency=currency,
|
||||
)
|
||||
if not hwid_quote:
|
||||
@@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
else:
|
||||
price = float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
elif _sale_mode_base(sale_mode) == "subscription" and bool(
|
||||
payment_payload.renew_hwid_devices
|
||||
):
|
||||
currency = "stars" if method == "stars" else default_currency
|
||||
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
|
||||
if sale_tariff_key:
|
||||
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_tariff_key,
|
||||
months=int(payment_units),
|
||||
currency=currency,
|
||||
)
|
||||
if hwid_quote:
|
||||
if method == "stars":
|
||||
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
|
||||
else:
|
||||
price = float(price or 0) + float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return await _create_subscription_payment(
|
||||
@@ -360,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"):
|
||||
@@ -395,6 +433,28 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
||||
except Exception:
|
||||
logger.exception("Failed to send WebApp trial activation notification")
|
||||
|
||||
try:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"telegram_username": getattr(db_user, "username", None),
|
||||
"telegram_first_name": getattr(db_user, "first_name", None),
|
||||
"event_type": "webapp_trial_activate",
|
||||
"content": (
|
||||
f"Trial activated via WebApp for user_id={user_id}; "
|
||||
f"email={getattr(db_user, 'email', None) or 'N/A'}"
|
||||
),
|
||||
"is_admin_event": False,
|
||||
"target_user_id": user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to add WebApp trial activation audit log")
|
||||
|
||||
await session.commit()
|
||||
|
||||
try:
|
||||
from db.dal import ad_dal as _ad_dal
|
||||
|
||||
@@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
@@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency=default_currency,
|
||||
)
|
||||
if count in currency_counts
|
||||
@@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency="stars",
|
||||
)
|
||||
if count in stars_counts
|
||||
@@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
continue
|
||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||
quote = currency_quote or stars_quote
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
plan = {
|
||||
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
|
||||
"id": f"{tariff.key}:hwid:{count}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode_for_plan,
|
||||
"sale_mode": "hwid_devices",
|
||||
"renewal": False,
|
||||
"months": count,
|
||||
"device_count": count,
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
"valid_from": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_from")
|
||||
),
|
||||
"valid_until": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_until")
|
||||
),
|
||||
"proration_ratio": float(
|
||||
(currency_quote or stars_quote).get("proration_ratio") or 0
|
||||
),
|
||||
"valid_from": _billing_iso_datetime(valid_from),
|
||||
"valid_from_text": _billing_datetime_text(valid_from),
|
||||
"valid_until": _billing_iso_datetime(valid_until),
|
||||
"valid_until_text": _billing_datetime_text(valid_until),
|
||||
"proration_ratio": float(quote.get("proration_ratio") or 0),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
plan["stars_price"] = int(stars_quote["price"])
|
||||
@@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
else 0,
|
||||
"renewal_available": False,
|
||||
"renewal_recommended_count": 0,
|
||||
"plans": plans,
|
||||
}
|
||||
)
|
||||
@@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
|
||||
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
||||
payment = await _refresh_wata_payment_status(request, session, payment)
|
||||
if payment.status == "succeeded":
|
||||
await invalidate_webapp_user_caches(request.app["settings"], user_id)
|
||||
await invalidate_webapp_user_caches(
|
||||
request.app["settings"],
|
||||
user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -1005,6 +1065,7 @@ async def _create_subscription_payment(
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
|
||||
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
renew_hwid_devices: Optional[bool] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
|
||||
@@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(
|
||||
@@ -89,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,20 +69,48 @@ 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,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
)
|
||||
await _attach_hwid_renewal_quotes_to_plans(
|
||||
session,
|
||||
subscription_service,
|
||||
user_id=user_id,
|
||||
settings=settings,
|
||||
active=active,
|
||||
local_sub=local_sub,
|
||||
plans=plans_payload,
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
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)
|
||||
)
|
||||
@@ -94,7 +127,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
|
||||
@@ -120,22 +153,20 @@ 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)
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"plans": plans_payload,
|
||||
"payment_methods": _serialize_payment_methods(
|
||||
settings,
|
||||
request.app,
|
||||
@@ -164,6 +195,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"),
|
||||
@@ -342,11 +378,12 @@ def _serialize_subscription(
|
||||
and tariff.premium_topup_packages.has_any()
|
||||
)
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices"))
|
||||
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.billing_model == "period"
|
||||
and tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
and max_devices not in (None, 0)
|
||||
)
|
||||
except Exception:
|
||||
can_topup_regular_traffic = False
|
||||
@@ -437,6 +474,102 @@ def _serialize_subscription(
|
||||
}
|
||||
|
||||
|
||||
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _attach_hwid_renewal_quotes_to_plans(
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
*,
|
||||
user_id: int,
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
plans: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
|
||||
if not callable(quote_method):
|
||||
return
|
||||
if not active or not local_sub or not settings.tariffs_config:
|
||||
return
|
||||
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
|
||||
return
|
||||
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
for plan in plans:
|
||||
if str(plan.get("sale_mode") or "subscription") != "subscription":
|
||||
continue
|
||||
target_tariff_key = str(plan.get("tariff_key") or "").strip()
|
||||
if not target_tariff_key:
|
||||
continue
|
||||
try:
|
||||
months = int(plan.get("months") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if months <= 0:
|
||||
continue
|
||||
try:
|
||||
currency_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to quote HWID renewal for plan %s/%s",
|
||||
target_tariff_key,
|
||||
months,
|
||||
)
|
||||
continue
|
||||
quote = currency_quote or stars_quote
|
||||
if not quote:
|
||||
continue
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
active_until = quote.get("active_until")
|
||||
renewal = {
|
||||
"available": True,
|
||||
"device_count": int(quote.get("device_count") or 0),
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"valid_from": _webapp_iso_datetime(valid_from),
|
||||
"valid_from_text": _webapp_datetime_text(valid_from),
|
||||
"valid_until": _webapp_iso_datetime(valid_until),
|
||||
"valid_until_text": _webapp_datetime_text(valid_until),
|
||||
"active_until": _webapp_iso_datetime(active_until),
|
||||
"active_until_text": _webapp_datetime_text(active_until),
|
||||
"pricing_period_months": int(quote.get("pricing_period_months") or months),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
renewal["stars_price"] = int(stars_quote["price"])
|
||||
plan["hwid_renewal"] = renewal
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
@@ -495,7 +628,10 @@ def _serialize_plans(
|
||||
else [],
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
# Render periods in the configured order (enabled_periods is the
|
||||
# source of truth for purchase-period ordering, matching the bot
|
||||
# keyboards). Do not sort so admins can reorder via drag & drop.
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), default_currency)
|
||||
stars_price = tariff.period_price(int(months), "stars")
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
@@ -528,7 +664,14 @@ def _serialize_plans(
|
||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||
# Preserve the configured package order (default-currency list first,
|
||||
# then any Stars-only volumes) so admins can reorder via drag & drop.
|
||||
# Matches the bot keyboard, which iterates the package list as-is.
|
||||
ordered_gb: List[float] = []
|
||||
for traffic_gb in list(currency_packages) + list(stars_packages):
|
||||
if traffic_gb not in ordered_gb:
|
||||
ordered_gb.append(traffic_gb)
|
||||
for traffic_gb in ordered_gb:
|
||||
price = currency_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
|
||||
@@ -236,6 +236,10 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_traffic_grant_button"),
|
||||
callback_data=f"user_action:traffic_grant:{user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_hwid_limit_button"),
|
||||
callback_data=f"user_action:hwid_limit:{user_id}",
|
||||
)
|
||||
|
||||
# Row 4: Quick links — only for users with a real Telegram profile
|
||||
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||
@@ -261,9 +265,9 @@ def get_user_card_keyboard(
|
||||
|
||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||
if quick_links_count == 0:
|
||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -403,6 +407,26 @@ async def format_user_card(
|
||||
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
||||
)
|
||||
|
||||
max_devices = subscription_details.get("max_devices")
|
||||
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
|
||||
if max_devices is not None:
|
||||
if int(max_devices) == 0:
|
||||
devices_display = _("admin_hwid_limit_state_unlimited")
|
||||
elif extra_hwid_devices > 0:
|
||||
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
|
||||
if base_hwid_limit is None:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
else:
|
||||
devices_display = _(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=int(max_devices),
|
||||
base=int(base_hwid_limit),
|
||||
extra=extra_hwid_devices,
|
||||
)
|
||||
else:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
|
||||
|
||||
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
||||
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
||||
if premium_unlimited:
|
||||
@@ -706,6 +730,32 @@ async def user_action_handler(
|
||||
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
||||
elif action == "traffic_grant_premium":
|
||||
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
||||
elif action == "hwid_limit":
|
||||
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
|
||||
elif action == "hwid_limit_set_unlimited":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=0,
|
||||
)
|
||||
elif action == "hwid_limit_reset":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=None,
|
||||
)
|
||||
elif action == "hwid_limit_set_number":
|
||||
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _admin_hwid_limit_state_text(
|
||||
get_text: Callable[..., str],
|
||||
hwid_device_limit: Optional[int],
|
||||
extra_hwid_devices: int = 0,
|
||||
) -> str:
|
||||
if hwid_device_limit is None:
|
||||
return get_text("admin_hwid_limit_state_default")
|
||||
base_limit = int(hwid_device_limit)
|
||||
if base_limit == 0:
|
||||
return get_text("admin_hwid_limit_state_unlimited")
|
||||
extra = max(0, int(extra_hwid_devices or 0))
|
||||
if extra > 0:
|
||||
return get_text(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=base_limit + extra,
|
||||
base=base_limit,
|
||||
extra=extra,
|
||||
)
|
||||
return get_text("admin_hwid_limit_state_count", count=base_limit)
|
||||
|
||||
|
||||
async def handle_hwid_limit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Show HWID device limit override controls."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(
|
||||
_,
|
||||
getattr(active_sub, "hwid_device_limit", None),
|
||||
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
|
||||
)
|
||||
text = "\n".join(
|
||||
[
|
||||
f"<b>{_('admin_hwid_limit_title')}</b>",
|
||||
"",
|
||||
_("admin_hwid_limit_hint"),
|
||||
"",
|
||||
_("admin_hwid_limit_current", current=current_text),
|
||||
]
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_set_number"),
|
||||
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_unlimited"),
|
||||
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_reset"),
|
||||
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
except Exception:
|
||||
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_hwid_limit_apply(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
hwid_device_limit: Optional[int],
|
||||
) -> None:
|
||||
"""Persist a HWID device base limit override and push it to the panel."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user.user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, user.user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": callback.from_user.id if callback.from_user else user.user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user.user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n_instance, lang
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to apply HWID device limit for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_hwid_limit_prompt(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Ask admin for an explicit HWID device limit."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
|
||||
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
|
||||
try:
|
||||
await callback.message.edit_text(prompt)
|
||||
except Exception:
|
||||
await callback.message.answer(prompt)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_traffic_grant_menu(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
|
||||
async def process_hwid_device_limit_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Read explicit HWID device limit and apply it."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
target_user_id = data.get("target_user_id")
|
||||
if not target_user_id:
|
||||
await message.answer(_("admin_hwid_limit_state_missing"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw = (message.text or "").strip()
|
||||
try:
|
||||
hwid_device_limit = int(raw)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
raise ValueError("out_of_range")
|
||||
except (TypeError, ValueError):
|
||||
await message.answer(_("admin_hwid_limit_invalid"))
|
||||
return
|
||||
|
||||
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not target_user:
|
||||
await message.answer(_("admin_user_not_found_action"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, target_user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await message.answer(_("admin_hwid_limit_no_subscription"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": message.from_user.id if message.from_user else target_user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
|
||||
await message.answer(
|
||||
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
|
||||
)
|
||||
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
bot_username = await _resolve_bot_username(message.bot)
|
||||
user_card_text = await format_user_card(
|
||||
target_user,
|
||||
session,
|
||||
subscription_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
referral_service,
|
||||
settings=settings,
|
||||
bot_username=bot_username,
|
||||
)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id, i18n, current_lang, target_user.referred_by_id
|
||||
)
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error setting HWID device limit for user %s: %s",
|
||||
target_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_hwid_limit_save_error"))
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
||||
async def process_traffic_grant_gb_handler(
|
||||
message: types.Message,
|
||||
|
||||
@@ -27,6 +27,7 @@ from bot.utils.callback_answer import safe_answer_callback
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
resolve_required_channel_link,
|
||||
)
|
||||
from bot.utils.install_links import (
|
||||
append_install_share_link_text,
|
||||
@@ -40,6 +41,67 @@ from db.models import User
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _referral_code_lookup_candidates(
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> list[str]:
|
||||
value = str(raw_ref_value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
stripped_current_prefix = value[1:]
|
||||
if remnashop_compat:
|
||||
candidates.append(stripped_current_prefix)
|
||||
else:
|
||||
candidates = [stripped_current_prefix]
|
||||
|
||||
unique: list[str] = []
|
||||
for candidate in candidates:
|
||||
candidate = candidate.strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_from_start_ref(
|
||||
session: AsyncSession,
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_user_id: int,
|
||||
) -> Optional[int]:
|
||||
ref_user: Optional[User] = None
|
||||
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != current_user_id:
|
||||
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||
|
||||
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||
if not ref_user:
|
||||
for code in _referral_code_lookup_candidates(
|
||||
raw_ref_value,
|
||||
remnashop_compat=include_legacy,
|
||||
):
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
code,
|
||||
include_legacy=include_legacy,
|
||||
)
|
||||
if ref_user:
|
||||
break
|
||||
|
||||
if ref_user and ref_user.user_id != current_user_id:
|
||||
return int(ref_user.user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
@@ -376,11 +438,12 @@ async def ensure_required_channel_subscription(
|
||||
)
|
||||
return True
|
||||
|
||||
keyboard = (
|
||||
get_channel_subscription_keyboard(current_lang, i18n, settings.REQUIRED_CHANNEL_LINK)
|
||||
if i18n
|
||||
else None
|
||||
channel_link = await resolve_required_channel_link(
|
||||
bot_instance,
|
||||
required_channel_id,
|
||||
settings.REQUIRED_CHANNEL_LINK,
|
||||
)
|
||||
keyboard = get_channel_subscription_keyboard(current_lang, i18n, channel_link) if i18n else None
|
||||
|
||||
prompt_text = translate("channel_subscription_required")
|
||||
|
||||
@@ -410,14 +473,10 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
||||
"ref_match"
|
||||
)
|
||||
)
|
||||
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@@ -534,22 +593,12 @@ async def start_command_handler(
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id
|
||||
):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||
session,
|
||||
raw_ref_value,
|
||||
settings=settings,
|
||||
current_user_id=user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
@@ -1135,7 +1184,7 @@ async def main_action_callback_handler(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
privacy_url = settings.PRIVACY_POLICY_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL
|
||||
|
||||
if not privacy_url and not user_agreement_url:
|
||||
await safe_answer_callback(
|
||||
|
||||
@@ -319,7 +319,11 @@ async def select_tariff_callback(
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff:period:"))
|
||||
async def select_tariff_period_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key, months_raw = parts[2], parts[3]
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
callback_tokens = [part for part in parts[4:] if part]
|
||||
callback_context = "bot" if "bot" in callback_tokens else None
|
||||
renew_hwid_devices = "no_hwid" not in callback_tokens
|
||||
tariff = config.require(tariff_key)
|
||||
months = int(months_raw)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
|
||||
if price_rub is None:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
hwid_renewal_stars_quote = (
|
||||
await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
)
|
||||
markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
hwid_renewal_quote=hwid_renewal_quote,
|
||||
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
|
||||
hwid_renewal_selected=bool(renew_hwid_devices),
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
||||
markup = get_hwid_device_packages_keyboard(
|
||||
tariff,
|
||||
packages,
|
||||
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
|
||||
i18n,
|
||||
settings,
|
||||
back_callback="main_action:my_devices",
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
renewal=False,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
text_key,
|
||||
"select_hwid_device_package",
|
||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||
),
|
||||
reply_markup=markup,
|
||||
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||
renewal = action == "renewal_package"
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
currency_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency="stars",
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
|
||||
@@ -4,6 +4,7 @@ from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
|
||||
from bot.middlewares.i18n import locale_language_options
|
||||
from bot.utils.channel_subscription import normalize_required_channel_link
|
||||
from bot.utils.install_links import bot_install_guide_url
|
||||
from bot.utils.mini_app_url import subscription_mini_app_trial_url
|
||||
from config.settings import Settings
|
||||
@@ -13,6 +14,13 @@ from config.tariffs_config import (
|
||||
)
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
HWID_RENEWAL_TOKEN = "hwid_renewal"
|
||||
|
||||
|
||||
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return ()
|
||||
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
|
||||
|
||||
|
||||
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
||||
@@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
|
||||
|
||||
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
if not context or "|" in sale_mode:
|
||||
if not context or context in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{context}"
|
||||
|
||||
|
||||
def sale_mode_with_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or token in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{token}"
|
||||
|
||||
|
||||
def sale_mode_without_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or "|" not in sale_mode:
|
||||
return sale_mode
|
||||
base, *tokens = sale_mode.split("|")
|
||||
kept = [item for item in tokens if item.strip() and item.strip() != token]
|
||||
return "|".join([base, *kept])
|
||||
|
||||
|
||||
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
|
||||
return str(token or "").strip() in sale_mode_tokens(sale_mode)
|
||||
|
||||
|
||||
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return None
|
||||
context = str(sale_mode).split("|", 1)[1].strip()
|
||||
return context or None
|
||||
tokens = sale_mode_tokens(sale_mode)
|
||||
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
|
||||
|
||||
|
||||
def callback_suffix_for_context(context: Optional[str]) -> str:
|
||||
@@ -138,8 +166,7 @@ def get_main_menu_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
|
||||
)
|
||||
@@ -206,8 +233,7 @@ def get_bot_interface_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
|
||||
@@ -483,6 +509,9 @@ def get_payment_method_keyboard(
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_selected: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -491,12 +520,39 @@ def get_payment_method_keyboard(
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
value_str = _format_value(months)
|
||||
import logging as _kbd_logging
|
||||
|
||||
_kbd_logging.info(
|
||||
"payment_method_keyboard build: order=%s",
|
||||
settings.payment_methods_order,
|
||||
)
|
||||
payment_sale_mode = sale_mode
|
||||
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
|
||||
if selected_hwid_quote:
|
||||
tariff_key = None
|
||||
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
|
||||
if "@" in sale_mode_main:
|
||||
tariff_key = sale_mode_main.split("@", 1)[1]
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
|
||||
if context:
|
||||
toggle_tokens.append(context)
|
||||
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(
|
||||
"payment_hwid_renewal_toggle_on"
|
||||
if hwid_renewal_selected
|
||||
else "payment_hwid_renewal_toggle_off",
|
||||
count=int(selected_hwid_quote.get("device_count") or 0),
|
||||
price=(
|
||||
hwid_renewal_quote.get("price")
|
||||
if hwid_renewal_quote
|
||||
else hwid_renewal_stars_quote.get("price")
|
||||
),
|
||||
currency_symbol=currency_symbol_val,
|
||||
),
|
||||
callback_data=":".join(toggle_tokens),
|
||||
)
|
||||
)
|
||||
if hwid_renewal_selected:
|
||||
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
else:
|
||||
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
||||
|
||||
for method in settings.payment_methods_order:
|
||||
@@ -517,7 +573,7 @@ def get_payment_method_keyboard(
|
||||
value=value_str,
|
||||
rub_price=price,
|
||||
stars_price=stars_price,
|
||||
sale_mode=sale_mode,
|
||||
sale_mode=payment_sale_mode,
|
||||
)
|
||||
if not callback_data:
|
||||
continue
|
||||
@@ -576,7 +632,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -718,10 +774,11 @@ def get_channel_subscription_keyboard(
|
||||
|
||||
has_buttons = False
|
||||
|
||||
if channel_link:
|
||||
channel_url = normalize_required_channel_link(channel_link)
|
||||
if channel_url:
|
||||
builder.button(
|
||||
text=_(key="channel_subscription_join_button"),
|
||||
url=channel_link,
|
||||
url=channel_url,
|
||||
)
|
||||
has_buttons = True
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.channel_subscription import normalize_required_channel_id
|
||||
from bot.utils.channel_subscription import (
|
||||
normalize_required_channel_id,
|
||||
resolve_required_channel_link,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
|
||||
@@ -86,10 +89,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
return i18n_instance.gettext(current_lang, key)
|
||||
return key
|
||||
|
||||
bot_instance = data.get("bot") or data.get("bot_instance")
|
||||
channel_link = await resolve_required_channel_link(
|
||||
bot_instance,
|
||||
required_channel_id,
|
||||
self.settings.REQUIRED_CHANNEL_LINK,
|
||||
)
|
||||
keyboard = (
|
||||
get_channel_subscription_keyboard(
|
||||
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
||||
)
|
||||
get_channel_subscription_keyboard(current_lang, i18n_instance, channel_link)
|
||||
if i18n_instance
|
||||
else None
|
||||
)
|
||||
|
||||
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
|
||||
sale_mode: str
|
||||
currency: str = "RUB"
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_device_count: Optional[int] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
hwid_pricing_period_months: Optional[int] = None
|
||||
|
||||
@@ -192,6 +192,7 @@ class CryptoPayService:
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
@@ -210,7 +211,11 @@ class CryptoPayService:
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
amounts = payment_record_amounts(
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
hwid_device_count=hwid_device_count,
|
||||
)
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -252,6 +257,7 @@ class CryptoPayService:
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||
"hwid_devices": amounts.purchased_hwid_devices,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
||||
else None,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
if not url:
|
||||
return payment_failed()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -609,6 +609,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
|
||||
from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa
|
||||
from .base import (
|
||||
PaymentProviderPresentation,
|
||||
PaymentProviderSpec,
|
||||
@@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
||||
stars.SPEC,
|
||||
cryptopay.SPEC,
|
||||
heleket.SPEC,
|
||||
paykilla.SPEC,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ from aiogram import types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
HWID_RENEWAL_TOKEN,
|
||||
get_payment_url_keyboard,
|
||||
payment_methods_back_callback,
|
||||
sale_mode_has_token,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal
|
||||
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
|
||||
subscription_service,
|
||||
currency: str = "rub",
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
base = sale_mode_base(parts.sale_mode)
|
||||
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
|
||||
try:
|
||||
months = int(parts.months)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
months=months,
|
||||
currency=currency,
|
||||
)
|
||||
if not quote:
|
||||
return parts, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=months,
|
||||
price=float(parts.price or 0) + float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
return quoted_parts, quote
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
|
||||
@@ -100,6 +100,11 @@ def build_payment_record_payload(
|
||||
base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||
hwid_devices = int(float(months)) if is_hwid else None
|
||||
if hwid_quote:
|
||||
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
if quote_devices is not None:
|
||||
hwid_devices = quote_devices
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
@@ -111,9 +116,9 @@ def build_payment_record_payload(
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
||||
"purchased_hwid_devices": hwid_devices,
|
||||
}
|
||||
if hwid_quote and is_hwid:
|
||||
if hwid_quote and hwid_devices is not None:
|
||||
payload.update(
|
||||
{
|
||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||
@@ -164,14 +169,20 @@ def payment_record_amounts(
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
traffic_gb: Optional[float] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
) -> PaymentRecordAmounts:
|
||||
traffic_sale = sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
||||
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
|
||||
if not hwid_devices_sale and hwid_device_count is not None:
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
|
||||
if parsed_hwid_devices is not None:
|
||||
purchased_hwid_devices = parsed_hwid_devices
|
||||
return PaymentRecordAmounts(
|
||||
months=int(float(units)) if traffic_sale else int(float(months)),
|
||||
purchased_gb=float(units) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
purchased_hwid_devices=purchased_hwid_devices,
|
||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||
traffic_sale=traffic_sale,
|
||||
hwid_devices_sale=hwid_devices_sale,
|
||||
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
return await create_base_payment_record(
|
||||
ctx.session,
|
||||
|
||||
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
def append_hwid_renewed_note(
|
||||
text: str,
|
||||
translator: Translator,
|
||||
*,
|
||||
count: Any,
|
||||
valid_until: Optional[datetime],
|
||||
) -> str:
|
||||
try:
|
||||
count_int = int(count or 0)
|
||||
except (TypeError, ValueError):
|
||||
count_int = 0
|
||||
if count_int <= 0:
|
||||
return text
|
||||
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
|
||||
note = translator(
|
||||
"payment_successful_hwid_devices_renewed_note",
|
||||
count=format_human_units(count_int),
|
||||
date=date_text,
|
||||
)
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
async def send_success_message_to_user(
|
||||
*,
|
||||
bot: Bot,
|
||||
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
req.session,
|
||||
req.payment.payment_id,
|
||||
"activation_failed",
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s activation_failed.",
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
await invalidate_webapp_user_caches(
|
||||
req.settings,
|
||||
req.user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"%s: failed to invalidate webapp caches for user %s.",
|
||||
req.log_prefix,
|
||||
req.user_id,
|
||||
)
|
||||
|
||||
db_user, language = await resolve_user_language(
|
||||
req.session,
|
||||
user_id=req.user_id,
|
||||
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
|
||||
)
|
||||
)
|
||||
if is_subscription and activation:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if activation.get("hwid_devices_renewed_count"):
|
||||
success_text = append_hwid_renewed_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewed_count"),
|
||||
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
|
||||
)
|
||||
else:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if req.text_prefix:
|
||||
success_text = f"{req.text_prefix}\n{success_text}"
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
|
||||
message_key: str = "payment_failed",
|
||||
) -> None:
|
||||
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
language = (
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
|
||||
@@ -978,6 +978,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
|
||||
|
||||
@@ -448,6 +448,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
|
||||
return value is not None and str(value).strip() != ""
|
||||
|
||||
|
||||
def _metadata_int(value: Optional[Any]) -> Optional[int]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_float(value: Optional[Any]) -> Optional[float]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_yookassa_activation_amounts(
|
||||
*,
|
||||
sale_mode_base: str,
|
||||
@@ -559,6 +589,11 @@ async def process_successful_payment(
|
||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
|
||||
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
|
||||
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
|
||||
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
|
||||
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
|
||||
|
||||
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
||||
logging.error(
|
||||
@@ -574,6 +609,19 @@ async def process_successful_payment(
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return
|
||||
if sale_mode_base == "subscription" and hwid_devices_count > 0:
|
||||
if (
|
||||
not hwid_valid_from
|
||||
or not hwid_valid_until
|
||||
or hwid_valid_from >= hwid_valid_until
|
||||
or hwid_full_price is None
|
||||
):
|
||||
logging.error(
|
||||
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
|
||||
yk_payment_id_from_hook,
|
||||
metadata,
|
||||
)
|
||||
return
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
@@ -600,6 +648,16 @@ async def process_successful_payment(
|
||||
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_hwid_devices=(
|
||||
hwid_devices_count if hwid_devices_count > 0 else None
|
||||
),
|
||||
hwid_valid_from=hwid_valid_from,
|
||||
hwid_valid_until=hwid_valid_until,
|
||||
hwid_pricing_period_months=hwid_pricing_period_months,
|
||||
hwid_proration_ratio=hwid_proration_ratio,
|
||||
hwid_full_price=hwid_full_price,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
@@ -1315,6 +1373,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
|
||||
parts = payload.split(":")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price = float(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
page = 0
|
||||
sale_mode = "subscription"
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
page = int(parts[2])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except ValueError:
|
||||
sale_mode = parts[2]
|
||||
return months, price, page, sale_mode
|
||||
|
||||
|
||||
def _metadata_iso(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(
|
||||
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
||||
) -> str:
|
||||
@@ -1363,6 +1451,9 @@ async def _initiate_yk_payment(
|
||||
return False
|
||||
|
||||
sale_base = _sale_mode_base(sale_mode)
|
||||
hwid_device_count = None
|
||||
if hwid_quote:
|
||||
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
@@ -1379,12 +1470,14 @@ async def _initiate_yk_payment(
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"sale_mode": sale_base,
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"purchased_hwid_devices": (
|
||||
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
|
||||
),
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
@@ -1430,6 +1523,19 @@ async def _initiate_yk_payment(
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES:
|
||||
yookassa_metadata["hwid_devices"] = str(months)
|
||||
elif hwid_device_count:
|
||||
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
|
||||
if hwid_quote and hwid_device_count:
|
||||
hwid_metadata = {
|
||||
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
|
||||
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
||||
"hwid_full_price": hwid_quote.get("full_price"),
|
||||
}
|
||||
yookassa_metadata.update(
|
||||
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
|
||||
)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
@@ -1709,22 +1815,6 @@ async def pay_yk_callback_handler(
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1786,6 +1876,22 @@ async def pay_yk_callback_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
@@ -1863,6 +1969,22 @@ async def pay_yk_new_card_handler(
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1889,6 +2011,7 @@ async def pay_yk_new_card_handler(
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -1928,27 +2051,15 @@ async def pay_yk_saved_list_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
parsed_saved_list = _parse_saved_list_payload(data_payload)
|
||||
if not parsed_saved_list:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months, price_rub, page, sale_mode = parsed_saved_list
|
||||
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
@@ -2138,6 +2249,24 @@ async def pay_yk_use_saved_handler(
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
base_months = months
|
||||
base_price_rub = price_rub
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
@@ -2182,10 +2311,13 @@ async def pay_yk_use_saved_handler(
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
back_callback=(
|
||||
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
|
||||
),
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -2754,6 +2886,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -2775,8 +2908,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if amounts.traffic_sale:
|
||||
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
||||
if amounts.hwid_devices_sale:
|
||||
metadata["hwid_devices"] = str(int(float(ctx.months)))
|
||||
if amounts.purchased_hwid_devices:
|
||||
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
|
||||
if amounts.tariff_key:
|
||||
metadata["tariff_key"] = amounts.tariff_key
|
||||
response = await service.create_payment(
|
||||
|
||||
@@ -10,13 +10,13 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal, user_dal
|
||||
@@ -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}"
|
||||
@@ -453,6 +481,7 @@ class EmailAuthService:
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
@@ -460,6 +489,7 @@ class EmailAuthService:
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
inline_images=inline_images,
|
||||
)
|
||||
|
||||
async def send_rendered_email(
|
||||
@@ -473,6 +503,7 @@ class EmailAuthService:
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
inline_images=content.inline_images,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
@@ -493,17 +524,13 @@ class EmailAuthService:
|
||||
i18n=self.i18n,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = content.subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
message = self._build_email_message(
|
||||
email=email,
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
inline_images=content.inline_images,
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(content.text)
|
||||
message.add_alternative(content.html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -554,19 +581,15 @@ class EmailAuthService:
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
message = self._build_email_message(
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
inline_images=inline_images,
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -610,6 +633,64 @@ class EmailAuthService:
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def _build_email_message(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
self._attach_inline_images(message, inline_images)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _attach_inline_images(
|
||||
message: EmailMessage,
|
||||
inline_images: Sequence[EmailInlineImage],
|
||||
) -> None:
|
||||
if not inline_images:
|
||||
return
|
||||
html_part = message.get_body(("html",))
|
||||
if html_part is None:
|
||||
return
|
||||
|
||||
for image in inline_images:
|
||||
content_type = (image.content_type or "").split(";", 1)[0].strip().lower()
|
||||
if "/" not in content_type:
|
||||
continue
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
if maintype != "image" or not subtype:
|
||||
continue
|
||||
|
||||
body = bytes(image.data or b"")
|
||||
content_id = (image.content_id or "").strip()
|
||||
if not body or not content_id:
|
||||
continue
|
||||
|
||||
cid_header = content_id
|
||||
if not (cid_header.startswith("<") and cid_header.endswith(">")):
|
||||
cid_header = f"<{cid_header}>"
|
||||
html_part.add_related(
|
||||
body,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
cid=cid_header,
|
||||
)
|
||||
|
||||
def _send_message_via_smtp(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -27,6 +28,27 @@ _TEXT_MUTED = "#9aa3b2"
|
||||
_TEXT_DIM = "#5d6573"
|
||||
_DEFAULT_ACCENT = "#00fe7a"
|
||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
_EMAIL_LOGO_CONTENT_ID = "webapp-logo"
|
||||
_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads"
|
||||
_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)")
|
||||
_LOGO_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailInlineImage:
|
||||
content_id: str
|
||||
content_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -34,6 +56,13 @@ class EmailContent:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmailLayout:
|
||||
html: str
|
||||
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||
|
||||
|
||||
def _safe_color(value: Optional[str]) -> str:
|
||||
@@ -48,8 +77,6 @@ def _safe_color(value: Optional[str]) -> str:
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
|
||||
stored public https URL can be used directly. Anything else is dropped."""
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return None
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
@@ -59,6 +86,55 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
return raw
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
filename = path.removeprefix(prefix)
|
||||
return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None
|
||||
|
||||
|
||||
def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
|
||||
filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip())
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
|
||||
try:
|
||||
uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve()
|
||||
logo_path = (uploads_dir / filename).resolve()
|
||||
logo_path.relative_to(uploads_dir)
|
||||
body = logo_path.read_bytes()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
|
||||
return None
|
||||
|
||||
return EmailInlineImage(
|
||||
content_id=_EMAIL_LOGO_CONTENT_ID,
|
||||
content_type=content_type,
|
||||
data=body,
|
||||
)
|
||||
|
||||
|
||||
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
|
||||
inline_logo = _inline_uploaded_logo(settings)
|
||||
if inline_logo:
|
||||
return f"cid:{inline_logo.content_id}", (inline_logo,)
|
||||
|
||||
public_url = _public_logo_url(settings)
|
||||
if public_url:
|
||||
return public_url, ()
|
||||
|
||||
return None, ()
|
||||
|
||||
|
||||
def _brand_title(settings: Settings) -> str:
|
||||
title = (settings.WEBAPP_TITLE or "").strip()
|
||||
return title or "Subscription"
|
||||
@@ -98,10 +174,10 @@ def _layout(
|
||||
intro_html: str,
|
||||
body_html: str,
|
||||
footer_html: str,
|
||||
) -> str:
|
||||
) -> _EmailLayout:
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
logo_url, inline_images = _email_logo(settings)
|
||||
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
@@ -111,7 +187,7 @@ def _layout(
|
||||
f'border-radius:16px;">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
layout_html = f"""<!DOCTYPE html>
|
||||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -151,6 +227,16 @@ def _layout(
|
||||
</body>
|
||||
</html>
|
||||
""" # noqa: E501
|
||||
return _EmailLayout(html=layout_html, inline_images=inline_images)
|
||||
|
||||
|
||||
def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent:
|
||||
return EmailContent(
|
||||
subject=subject,
|
||||
text=text,
|
||||
html=layout.html,
|
||||
inline_images=layout.inline_images,
|
||||
)
|
||||
|
||||
|
||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||
@@ -319,7 +405,7 @@ def render_login_code(
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
@@ -372,7 +458,7 @@ def render_account_merged(
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
return _email_content(subject=subject, text=text, layout=rendered)
|
||||
|
||||
|
||||
def render_payment_success(
|
||||
@@ -506,7 +592,7 @@ def render_payment_success(
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_user_notification(
|
||||
@@ -567,7 +653,7 @@ def render_user_notification(
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
@@ -631,7 +717,7 @@ def render_subscription_expiring(
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def _subscription_lifecycle_title(
|
||||
@@ -733,7 +819,7 @@ def render_subscription_lifecycle_notification(
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def _support_email(
|
||||
@@ -785,7 +871,7 @@ def _support_email(
|
||||
]
|
||||
if safe_url:
|
||||
text_lines.extend(["", safe_url])
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_support_new_ticket_admin(
|
||||
|
||||
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
|
||||
"prefixes": (
|
||||
"admin_user_",
|
||||
"admin_users_",
|
||||
"admin_hwid_",
|
||||
"admin_ban_",
|
||||
"admin_unban_",
|
||||
"admin_banned_",
|
||||
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
|
||||
"admin_traffic_grant_",
|
||||
"admin_view_banned_",
|
||||
"user_card_",
|
||||
"user_hwid_",
|
||||
"user_premium_",
|
||||
"user_regular_",
|
||||
"user_traffic_",
|
||||
@@ -203,7 +205,6 @@ LOCALE_GROUPS = [
|
||||
"admin_settings_field_subscription_mini_app_url",
|
||||
"admin_settings_field_support_link",
|
||||
"admin_settings_field_server_status_url",
|
||||
"admin_settings_field_terms_",
|
||||
"admin_settings_field_privacy_",
|
||||
"admin_settings_field_user_agreement_",
|
||||
"appearance_",
|
||||
|
||||
@@ -22,6 +22,11 @@ class PanelApiService:
|
||||
_TRANSIENT_STATUS_CODES = (-1, -3)
|
||||
_SAFE_METHODS = frozenset({"GET", "HEAD"})
|
||||
_RETRY_BACKOFF_SECONDS = 0.5
|
||||
_MIN_TIMEOUT_SECONDS = 0.1
|
||||
_DEFAULT_TOTAL_TIMEOUT_SECONDS = 25.0
|
||||
_DEFAULT_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||
_DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS = 8.0
|
||||
_DEFAULT_SOCK_READ_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
@@ -70,17 +75,46 @@ class PanelApiService:
|
||||
|
||||
async def _get_session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
# Separate connect/read timeouts so a stuck panel does not hold a
|
||||
# bot worker for the full window; total caps worst-case latency.
|
||||
timeout = aiohttp.ClientTimeout(
|
||||
total=15,
|
||||
connect=3,
|
||||
sock_connect=3,
|
||||
sock_read=10,
|
||||
)
|
||||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||
self._session = aiohttp.ClientSession(timeout=self._client_timeout())
|
||||
return self._session
|
||||
|
||||
@classmethod
|
||||
def _timeout_setting(cls, settings: Settings, name: str, default: float) -> float:
|
||||
raw_value = getattr(settings, name, default)
|
||||
try:
|
||||
value = float(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if value <= 0:
|
||||
return default
|
||||
return max(cls._MIN_TIMEOUT_SECONDS, value)
|
||||
|
||||
def _client_timeout(self) -> aiohttp.ClientTimeout:
|
||||
# Separate connect/read timeouts so a slow panel route has more room,
|
||||
# while genuinely stuck requests still cannot pin a worker forever.
|
||||
return aiohttp.ClientTimeout(
|
||||
total=self._timeout_setting(
|
||||
self.settings,
|
||||
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||
self._DEFAULT_TOTAL_TIMEOUT_SECONDS,
|
||||
),
|
||||
connect=self._timeout_setting(
|
||||
self.settings,
|
||||
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||
self._DEFAULT_CONNECT_TIMEOUT_SECONDS,
|
||||
),
|
||||
sock_connect=self._timeout_setting(
|
||||
self.settings,
|
||||
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||
self._DEFAULT_SOCK_CONNECT_TIMEOUT_SECONDS,
|
||||
),
|
||||
sock_read=self._timeout_setting(
|
||||
self.settings,
|
||||
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||
self._DEFAULT_SOCK_READ_TIMEOUT_SECONDS,
|
||||
),
|
||||
)
|
||||
|
||||
async def close_session(self):
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
@@ -121,6 +155,15 @@ class PanelApiService:
|
||||
for attempt in range(max_attempts):
|
||||
result = await self._request_once(method, endpoint, log_full_response, **kwargs)
|
||||
if attempt + 1 < max_attempts and self._is_transient_error(result):
|
||||
logging.warning(
|
||||
"Retrying transient Panel API request method=%s endpoint=%s "
|
||||
"attempt=%s/%s status_code=%s",
|
||||
method.upper(),
|
||||
endpoint,
|
||||
attempt + 1,
|
||||
max_attempts,
|
||||
result.get("status_code") if isinstance(result, dict) else None,
|
||||
)
|
||||
await asyncio.sleep(self._RETRY_BACKOFF_SECONDS)
|
||||
continue
|
||||
return result
|
||||
@@ -158,8 +201,8 @@ class PanelApiService:
|
||||
)
|
||||
except Exception:
|
||||
log_prefix += f" | Payload: {str(json_payload_for_log)[:300]}..."
|
||||
started = time.monotonic()
|
||||
try:
|
||||
started = time.monotonic()
|
||||
async with aiohttp_session.request(
|
||||
method.upper(), url_for_request, headers=headers, **kwargs
|
||||
) as response:
|
||||
@@ -228,15 +271,48 @@ class PanelApiService:
|
||||
return {"error": True, "status_code": response_status, "details": error_details}
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
)
|
||||
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
||||
return {"error": True, "status_code": -1, "message": f"Connection error: {str(e)}"}
|
||||
except aiohttp.ServerTimeoutError as e:
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
)
|
||||
logging.warning("Panel API timeout to %s: %s", url_for_request, e)
|
||||
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
)
|
||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
||||
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
||||
except asyncio.TimeoutError:
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
)
|
||||
logging.error(f"Panel API request to {url_for_request} timed out.")
|
||||
return {"error": True, "status_code": -3, "message": "Request timed out"}
|
||||
except Exception as e:
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=unexpected_error",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
)
|
||||
logging.error(
|
||||
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
|
||||
)
|
||||
@@ -885,7 +961,14 @@ class PanelApiService:
|
||||
await self._devices_cache.invalidate_remote(f"user:{user_uuid}")
|
||||
|
||||
async def get_internal_squads(self) -> Optional[List[Dict[str, Any]]]:
|
||||
return await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
||||
squads = await self._squads_cache.get_or_load("list", self._get_internal_squads_uncached)
|
||||
if squads is not None:
|
||||
return squads
|
||||
stale_squads = self._squads_cache.get_stale("list")
|
||||
if stale_squads is not None:
|
||||
logging.warning("Using stale internal squads cache after panel fetch failed.")
|
||||
return stale_squads
|
||||
return None
|
||||
|
||||
async def _get_internal_squads_uncached(self) -> Optional[List[Dict[str, Any]]]:
|
||||
response_data = await self._request("GET", "/internal-squads", log_full_response=False)
|
||||
|
||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
preserve_case = bool(
|
||||
getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
|
||||
)
|
||||
code_input_clean = (code_input or "").strip()[:100]
|
||||
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
|
||||
code_display = html_escape(lookup_code[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
@@ -54,7 +58,7 @@ class PromoCodeService:
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
session, lookup_code, preserve_case=preserve_case
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
applied_code = str(promo_data.code or lookup_code)
|
||||
code_display = html_escape(applied_code[:100], quote=False)
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id
|
||||
)
|
||||
@@ -86,7 +92,7 @@ class PromoCodeService:
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
reason=f"promo code {code_input_upper}",
|
||||
reason=f"promo code {applied_code}",
|
||||
)
|
||||
|
||||
if new_end_date:
|
||||
@@ -109,7 +115,7 @@ class PromoCodeService:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
promo_code=applied_code,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None,
|
||||
email=getattr(user, "email", None) if user else None,
|
||||
|
||||
@@ -197,6 +197,9 @@ class ReferralService:
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
# Short bonus grant: warn only hours before it
|
||||
# ends, not days ahead. A real payment clears this.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
|
||||
@@ -28,10 +28,7 @@ from db.dal import app_settings_dal
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APPEARANCE_OVERRIDE_KEYS = {
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
@@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
|
||||
snapshot["WEBAPP_FAVICON_URL"] = favicon_url
|
||||
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
|
||||
snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
snapshot["WEBAPP_LOGO_USE_EMOJI"] = True
|
||||
snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "")
|
||||
emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "")
|
||||
if emoji_font and emoji_font != "system":
|
||||
snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font
|
||||
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None)
|
||||
if primary_color and primary_color != "#00fe7a":
|
||||
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
|
||||
|
||||
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
|
||||
hours_before=hours_before,
|
||||
)
|
||||
|
||||
# Trial and registration/referral-bonus subscriptions last only a
|
||||
# few days, so a multi-day "ending soon" reminder would fire almost
|
||||
# the moment they are granted and needlessly alarm newcomers. Skip
|
||||
# the day-before stages for them — they still get the hours-before
|
||||
# reminder above and the expiry/after-expiry notices below. Paying
|
||||
# for a real subscription clears the flag and restores all stages.
|
||||
if bool(getattr(sub, "suppress_early_expiry_notifications", False)):
|
||||
return None
|
||||
|
||||
days_before_limit = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||
|
||||
@@ -31,6 +31,49 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
||||
|
||||
async def sync_hwid_device_limit_to_panel(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[int]:
|
||||
"""Push the current local HWID device limit override to the panel."""
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub:
|
||||
return None
|
||||
|
||||
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
|
||||
base_hwid_limit = (
|
||||
int(sub.hwid_device_limit)
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||
sub.extra_hwid_devices = extra_hwid_devices
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
if effective_hwid_limit is None:
|
||||
return None
|
||||
|
||||
panel_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=db_user.panel_user_uuid,
|
||||
expire_at=sub.end_date,
|
||||
status="ACTIVE",
|
||||
hwid_device_limit=effective_hwid_limit,
|
||||
include_default_squads=False,
|
||||
)
|
||||
panel_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid, panel_payload
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id)
|
||||
return effective_hwid_limit
|
||||
|
||||
async def _hwid_topup_validity_window(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -73,6 +116,64 @@ class HwidDeviceMixin:
|
||||
packages = package_set.for_currency(currency)
|
||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||
|
||||
@staticmethod
|
||||
def _quote_hwid_full_period_package_price(
|
||||
tariff: Tariff,
|
||||
*,
|
||||
device_count: int,
|
||||
period_months: int,
|
||||
currency: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
package_set = tariff.hwid_device_packages
|
||||
if not package_set:
|
||||
return None
|
||||
try:
|
||||
target_count = int(device_count)
|
||||
months = max(1, int(period_months))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if target_count <= 0:
|
||||
return None
|
||||
|
||||
packages = [
|
||||
package
|
||||
for package in package_set.for_currency(currency)
|
||||
if int(getattr(package, "count", 0) or 0) > 0
|
||||
]
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
|
||||
for count in range(1, target_count + 1):
|
||||
best_for_count: Optional[tuple[float, List[Any]]] = None
|
||||
for package in packages:
|
||||
package_count = int(package.count)
|
||||
previous = best.get(count - package_count)
|
||||
if previous is None:
|
||||
continue
|
||||
price = previous[0] + float(package.price_for_period(months))
|
||||
selected = [*previous[1], package]
|
||||
if best_for_count is None or price < best_for_count[0]:
|
||||
best_for_count = (price, selected)
|
||||
if best_for_count is not None:
|
||||
best[count] = best_for_count
|
||||
|
||||
resolved = best.get(target_count)
|
||||
if resolved is None:
|
||||
return None
|
||||
full_price, selected_packages = resolved
|
||||
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
|
||||
if currency == "stars":
|
||||
rounded_price = float(int(math.ceil(rounded_price)))
|
||||
return {
|
||||
"price": rounded_price,
|
||||
"full_price": float(full_price),
|
||||
"pricing_period_months": months,
|
||||
"proration_ratio": 1.0,
|
||||
"currency": currency,
|
||||
"package_counts": [int(package.count) for package in selected_packages],
|
||||
}
|
||||
|
||||
def _quote_hwid_package_price(
|
||||
self,
|
||||
*,
|
||||
@@ -85,16 +186,10 @@ class HwidDeviceMixin:
|
||||
) -> Dict[str, Any]:
|
||||
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
|
||||
billable_start = max(now, valid_from)
|
||||
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
||||
ratio = billable_seconds / basis_seconds
|
||||
ratio = min(1.0, billable_seconds / basis_seconds)
|
||||
raw_price = full_price * ratio
|
||||
price = self._round_hwid_price(raw_price, currency=currency)
|
||||
min_price = getattr(package, "min_price", None)
|
||||
@@ -150,7 +245,7 @@ class HwidDeviceMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
if base_hwid_limit == 0:
|
||||
if base_hwid_limit in (None, 0):
|
||||
return None
|
||||
|
||||
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
||||
@@ -187,6 +282,80 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return quote
|
||||
|
||||
async def quote_hwid_device_renewal_for_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
months: int,
|
||||
currency: str = "rub",
|
||||
now: Optional[datetime] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
period_months = int(months)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if period_months <= 0:
|
||||
return None
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub or not sub.end_date:
|
||||
return None
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
subscription_end = self._as_aware_utc(sub.end_date)
|
||||
if not subscription_end or subscription_end <= now:
|
||||
return None
|
||||
|
||||
try:
|
||||
tariff = self._resolve_tariff(target_tariff_key)
|
||||
except Exception:
|
||||
return None
|
||||
if not tariff or tariff.billing_model != "period":
|
||||
return None
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
if base_hwid_limit in (None, 0):
|
||||
return None
|
||||
|
||||
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
active_devices = int(entitlement_summary.get("active_devices") or 0)
|
||||
if active_devices <= 0:
|
||||
return None
|
||||
|
||||
price_quote = self._quote_hwid_full_period_package_price(
|
||||
tariff,
|
||||
device_count=active_devices,
|
||||
period_months=period_months,
|
||||
currency=currency,
|
||||
)
|
||||
if not price_quote:
|
||||
return None
|
||||
|
||||
valid_from = subscription_end
|
||||
valid_until = add_months(valid_from, period_months)
|
||||
price_quote.update(
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"tariff_key": tariff.key,
|
||||
"device_count": active_devices,
|
||||
"renewal": True,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"active_until": entitlement_summary.get("active_until"),
|
||||
}
|
||||
)
|
||||
return price_quote
|
||||
|
||||
async def activate_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -248,7 +417,7 @@ class HwidDeviceMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
if base_hwid_limit == 0:
|
||||
if base_hwid_limit in (None, 0):
|
||||
logging.info(
|
||||
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
||||
)
|
||||
|
||||
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
mode: str,
|
||||
payment_id: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config = self._tariffs_config()
|
||||
if not config:
|
||||
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
|
||||
"from_tariff_key": before_tariff_key,
|
||||
"to_tariff_key": target.key,
|
||||
"mode": mode,
|
||||
"payment_id": None,
|
||||
"payment_id": payment_id,
|
||||
"days_before": options.get("remaining_days"),
|
||||
"days_after": (updated.end_date - now).days
|
||||
if updated.end_date and target.billing_model == "period"
|
||||
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
|
||||
user_id,
|
||||
tariff_key,
|
||||
"paid_diff",
|
||||
payment_id=payment_db_id,
|
||||
)
|
||||
if result:
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub:
|
||||
await tariff_dal.create_tariff_change(
|
||||
session,
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"from_tariff_key": None,
|
||||
"to_tariff_key": tariff_key,
|
||||
"mode": "paid_diff",
|
||||
"payment_id": payment_db_id,
|
||||
"days_before": None,
|
||||
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
|
||||
if sub.end_date
|
||||
else None,
|
||||
"converted_bytes": None,
|
||||
"eff_price_before": None,
|
||||
"eff_price_after": sub.effective_monthly_price_rub,
|
||||
},
|
||||
)
|
||||
result["end_date"] = sub.end_date
|
||||
result["is_active"] = sub.is_active
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
|
||||
await self._record_payment_context(
|
||||
session,
|
||||
payment_db_id,
|
||||
sale_mode=sale_mode_base,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=tariff.key if tariff else tariff_key,
|
||||
purchased_gb=None,
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
try:
|
||||
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_devices = 0
|
||||
try:
|
||||
hwid_renewal_price = (
|
||||
float(getattr(payment, "hwid_full_price", 0) or 0)
|
||||
if hwid_renewal_devices > 0
|
||||
else 0.0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_price = 0.0
|
||||
hwid_renewal_valid_from = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_from", None) if payment else None
|
||||
)
|
||||
hwid_renewal_valid_until = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_until", None) if payment else None
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
|
||||
promo_code_id_from_payment = None
|
||||
|
||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
|
||||
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
|
||||
days=applied_promo_bonus_days
|
||||
)
|
||||
if payment:
|
||||
payment.hwid_valid_until = hwid_renewal_valid_until
|
||||
elif applied_promo_bonus_days > 0 and current_active_sub:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=current_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
subscription_end_before=start_date,
|
||||
delta=timedelta(days=applied_promo_bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for promo payment bonus of user %s",
|
||||
user_id,
|
||||
)
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
|
||||
premium_topup_balance_bytes,
|
||||
premium_topup_used_bytes,
|
||||
)
|
||||
effective_monthly_price = float(payment_amount) / max(1, months_int)
|
||||
subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
|
||||
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
|
||||
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
|
||||
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
|
||||
@@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin:
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": False,
|
||||
# A real payment restores the full reminder spectrum, clearing any
|
||||
# trial/bonus suppression carried over on this panel subscription.
|
||||
"suppress_early_expiry_notifications": False,
|
||||
"auto_renew_enabled": auto_renew_should_enable,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"tier_baseline_bytes": tier_baseline_bytes,
|
||||
@@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
hwid_devices_renewed_count = 0
|
||||
hwid_devices_renewed_until = None
|
||||
if hwid_renewal_devices > 0:
|
||||
if (
|
||||
hwid_renewal_valid_from
|
||||
and hwid_renewal_valid_until
|
||||
and hwid_renewal_valid_from < hwid_renewal_valid_until
|
||||
):
|
||||
await tariff_dal.create_hwid_device_purchase(
|
||||
session,
|
||||
subscription_id=new_or_updated_sub.subscription_id,
|
||||
payment_id=payment_db_id,
|
||||
purchased_devices=hwid_renewal_devices,
|
||||
valid_from=hwid_renewal_valid_from,
|
||||
valid_until=hwid_renewal_valid_until,
|
||||
)
|
||||
hwid_devices_renewed_count = hwid_renewal_devices
|
||||
hwid_devices_renewed_until = hwid_renewal_valid_until
|
||||
else:
|
||||
logging.warning(
|
||||
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
|
||||
payment_db_id,
|
||||
hwid_renewal_valid_from,
|
||||
hwid_renewal_valid_until,
|
||||
)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
@@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin:
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_valid_until,
|
||||
"hwid_devices_renewal_recommended_count": 0
|
||||
if hwid_devices_renewed_count
|
||||
else extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
|
||||
"hwid_devices_renewed_count": hwid_devices_renewed_count,
|
||||
"hwid_devices_renewed_until": hwid_devices_renewed_until,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
@@ -725,6 +782,7 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
extend_hwid_devices: bool = True,
|
||||
) -> Optional[datetime]:
|
||||
reason_lower = (reason or "").lower()
|
||||
apply_main_traffic_limit = any(
|
||||
@@ -776,6 +834,9 @@ class SubscriptionLifecycleMixin:
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
"auto_renew_enabled": False,
|
||||
# Registration/referral bonus grants are short-lived, like a
|
||||
# trial: only warn a few hours before they end, not days ahead.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -792,6 +853,21 @@ class SubscriptionLifecycleMixin:
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
if updated_sub_model and extend_hwid_devices:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=active_sub.subscription_id,
|
||||
at=now_utc,
|
||||
subscription_end_before=current_end_date,
|
||||
delta=timedelta(days=bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for %s bonus of user %s",
|
||||
reason,
|
||||
user_id,
|
||||
)
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
|
||||
@@ -14,6 +14,7 @@ class PaymentContextMixin:
|
||||
"severpay": "SeverPay",
|
||||
"wata": "Wata",
|
||||
"cryptopay": "Crypto Pay",
|
||||
"paykilla": "PayKilla",
|
||||
"telegram_stars": "Telegram Stars",
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ class PaymentContextMixin:
|
||||
payment.sale_mode = sale_mode
|
||||
payment.tariff_key = tariff_key
|
||||
payment.purchased_gb = purchased_gb
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if purchased_hwid_devices is not None:
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if hwid_valid_from is not None:
|
||||
payment.hwid_valid_from = hwid_valid_from
|
||||
if hwid_valid_until is not None:
|
||||
|
||||
@@ -42,6 +42,8 @@ class RenewalMixin:
|
||||
|
||||
months = sub.duration_months or 1
|
||||
currency = default_payment_currency_code_for_settings(self.settings)
|
||||
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
|
||||
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
|
||||
amount = None
|
||||
tariffs_config = (
|
||||
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||
@@ -62,11 +64,55 @@ class RenewalMixin:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
hwid_quote = None
|
||||
quote_hwid_renewal = getattr(
|
||||
self,
|
||||
"quote_hwid_device_renewal_for_subscription",
|
||||
None,
|
||||
)
|
||||
if tariff_key and callable(quote_hwid_renewal):
|
||||
try:
|
||||
hwid_quote = await quote_hwid_renewal(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
target_tariff_key=tariff_key,
|
||||
months=int(months),
|
||||
currency=default_currency_key_for_settings(self.settings),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to quote HWID devices for auto-renew user %s",
|
||||
sub.user_id,
|
||||
)
|
||||
hwid_quote = None
|
||||
if hwid_quote:
|
||||
amount = float(amount) + float(hwid_quote.get("price") or 0)
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if hwid_quote:
|
||||
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
|
||||
for source_key, metadata_key in (
|
||||
("valid_from", "hwid_valid_from"),
|
||||
("valid_until", "hwid_valid_until"),
|
||||
):
|
||||
value = hwid_quote.get(source_key)
|
||||
if value:
|
||||
metadata[metadata_key] = (
|
||||
value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
)
|
||||
for key in (
|
||||
"pricing_period_months",
|
||||
"proration_ratio",
|
||||
"full_price",
|
||||
):
|
||||
value = hwid_quote.get(key)
|
||||
if value is not None:
|
||||
metadata[f"hwid_{key}"] = str(value)
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency=currency,
|
||||
|
||||
@@ -314,7 +314,7 @@ class TariffMixin:
|
||||
@staticmethod
|
||||
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
||||
if base_limit is None:
|
||||
return None
|
||||
return 0
|
||||
base_int = max(0, int(base_limit))
|
||||
if base_int == 0:
|
||||
return 0
|
||||
|
||||
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
"provider": "trial",
|
||||
# Short trial: only warn a few hours before it ends, not days ahead.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
waiting_for_premium_override_bonus_gb = State()
|
||||
waiting_for_traffic_grant_gb = State()
|
||||
waiting_for_hwid_device_limit = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
from typing import Optional
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
_TELEGRAM_LINK_RE = re.compile(r"^(?:https?://|tg://)", re.IGNORECASE)
|
||||
_TELEGRAM_USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{5,64}$")
|
||||
|
||||
|
||||
def normalize_required_channel_id(value: object) -> Optional[int]:
|
||||
@@ -28,6 +33,66 @@ def normalize_required_channel_id(value: object) -> Optional[int]:
|
||||
return -int(f"100{raw_abs}")
|
||||
|
||||
|
||||
def normalize_required_channel_link(value: object) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
raw = str(value).strip()
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
if _TELEGRAM_LINK_RE.match(raw):
|
||||
return raw
|
||||
|
||||
raw = raw.lstrip("@").strip()
|
||||
if not raw or re.search(r"\s", raw):
|
||||
return None
|
||||
|
||||
if raw.startswith(("t.me/", "telegram.me/")):
|
||||
return f"https://{raw}"
|
||||
|
||||
if raw.startswith(("+", "joinchat/", "c/")):
|
||||
return f"https://t.me/{raw}"
|
||||
|
||||
if _TELEGRAM_USERNAME_RE.fullmatch(raw):
|
||||
return f"https://t.me/{raw}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _required_channel_link_from_chat(chat: Any) -> Optional[str]:
|
||||
username = str(getattr(chat, "username", "") or "").strip().lstrip("@")
|
||||
if username:
|
||||
return f"https://t.me/{username}"
|
||||
|
||||
invite_link = normalize_required_channel_link(getattr(chat, "invite_link", None))
|
||||
if invite_link:
|
||||
return invite_link
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_required_channel_link(
|
||||
bot: Any,
|
||||
required_channel_id: Optional[int],
|
||||
configured_link: object,
|
||||
) -> Optional[str]:
|
||||
if bot is not None and required_channel_id:
|
||||
try:
|
||||
chat = await bot.get_chat(required_channel_id)
|
||||
resolved_link = _required_channel_link_from_chat(chat)
|
||||
if resolved_link:
|
||||
return resolved_link
|
||||
except Exception as error:
|
||||
logging.warning(
|
||||
"Failed to resolve required channel link from chat %s: %s",
|
||||
required_channel_id,
|
||||
error,
|
||||
)
|
||||
|
||||
return normalize_required_channel_link(configured_link)
|
||||
|
||||
|
||||
def is_required_channel_access_error(error: BaseException) -> bool:
|
||||
message = str(error).lower()
|
||||
configuration_markers = (
|
||||
|
||||
@@ -29,6 +29,15 @@ class AsyncTTLCache:
|
||||
return None
|
||||
return value
|
||||
|
||||
def get_stale(self, key: str) -> Optional[Any]:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
_, value = entry
|
||||
if not self._is_cacheable(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _is_cacheable(value: Any) -> bool:
|
||||
if value is None:
|
||||
|
||||
+167
-33
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -25,7 +26,117 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
|
||||
def _split_csv(value: Optional[str]) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
|
||||
|
||||
|
||||
DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
|
||||
[
|
||||
"10minutemail.com",
|
||||
"10minutemail.net",
|
||||
"10minutemail.org",
|
||||
"20minutemail.com",
|
||||
"33mail.com",
|
||||
"anonbox.net",
|
||||
"anonymbox.com",
|
||||
"armyspy.com",
|
||||
"byom.de",
|
||||
"crazymailing.com",
|
||||
"cuvox.de",
|
||||
"dayrep.com",
|
||||
"deadaddress.com",
|
||||
"dispostable.com",
|
||||
"dodgeit.com",
|
||||
"dodgit.com",
|
||||
"dropmail.me",
|
||||
"easytrashmail.com",
|
||||
"emailfake.com",
|
||||
"emailondeck.com",
|
||||
"emailtemporanea.com",
|
||||
"emailtemporanea.net",
|
||||
"einrot.com",
|
||||
"fakeinbox.com",
|
||||
"filzmail.com",
|
||||
"fleckens.hu",
|
||||
"generator.email",
|
||||
"getairmail.com",
|
||||
"getnada.com",
|
||||
"grr.la",
|
||||
"guerrillamail.biz",
|
||||
"guerrillamail.com",
|
||||
"guerrillamail.de",
|
||||
"guerrillamail.info",
|
||||
"guerrillamail.net",
|
||||
"guerrillamail.org",
|
||||
"guerrillamailblock.com",
|
||||
"gustr.com",
|
||||
"hmamail.com",
|
||||
"incognitomail.org",
|
||||
"inboxbear.com",
|
||||
"jetable.org",
|
||||
"jourrapide.com",
|
||||
"kasmail.com",
|
||||
"mail-temp.com",
|
||||
"mailcatch.com",
|
||||
"maildrop.cc",
|
||||
"mailexpire.com",
|
||||
"mailinator.com",
|
||||
"mailinator.net",
|
||||
"mailinator.org",
|
||||
"mailmetrash.com",
|
||||
"mailnesia.com",
|
||||
"mailnull.com",
|
||||
"mailpoof.com",
|
||||
"mailtothis.com",
|
||||
"mail.tm",
|
||||
"mintemail.com",
|
||||
"mohmal.com",
|
||||
"moakt.com",
|
||||
"mytemp.email",
|
||||
"mytrashmail.com",
|
||||
"nada.email",
|
||||
"no-spam.ws",
|
||||
"pookmail.com",
|
||||
"rhyta.com",
|
||||
"sharklasers.com",
|
||||
"sofort-mail.de",
|
||||
"spam4.me",
|
||||
"spambog.com",
|
||||
"spamdecoy.net",
|
||||
"spamfree24.org",
|
||||
"spamgourmet.com",
|
||||
"spamhole.com",
|
||||
"spam.la",
|
||||
"spammotel.com",
|
||||
"superrito.com",
|
||||
"teleworm.us",
|
||||
"tempail.com",
|
||||
"temp-mail.io",
|
||||
"temp-mail.org",
|
||||
"tempmail.com",
|
||||
"tempmail.dev",
|
||||
"tempmail.net",
|
||||
"tempmailo.com",
|
||||
"temporaryemail.net",
|
||||
"temporary-mail.net",
|
||||
"tempr.email",
|
||||
"throwawaymail.com",
|
||||
"trash-mail.com",
|
||||
"trash-mail.de",
|
||||
"trashmail.com",
|
||||
"trashmail.me",
|
||||
"trashmail.net",
|
||||
"trashmailer.com",
|
||||
"trashymail.com",
|
||||
"weg-werf-email.de",
|
||||
"wegwerfmail.de",
|
||||
"wegwerfmail.net",
|
||||
"wegwerfmail.org",
|
||||
"yomail.info",
|
||||
"yopmail.com",
|
||||
"yopmail.fr",
|
||||
"yopmail.net",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DBSettings(BaseModel):
|
||||
@@ -59,9 +170,6 @@ class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_use_emoji: bool
|
||||
logo_emoji: str
|
||||
logo_emoji_font: str
|
||||
favicon_use_custom: bool
|
||||
favicon_url: Optional[str]
|
||||
logo_favicon_url: Optional[str]
|
||||
@@ -100,6 +208,10 @@ class Settings(BaseSettings):
|
||||
PANEL_DEVICES_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
PANEL_ALL_USERS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
PANEL_ALL_USERS_PAGE_SIZE: int = Field(default=1000)
|
||||
PANEL_API_TOTAL_TIMEOUT_SECONDS: float = Field(default=25)
|
||||
PANEL_API_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS: float = Field(default=8)
|
||||
PANEL_API_SOCK_READ_TIMEOUT_SECONDS: float = Field(default=15)
|
||||
ADMIN_PANEL_STATS_CACHE_TTL_SECONDS: int = Field(default=15)
|
||||
ADMIN_DB_STATS_CACHE_TTL_SECONDS: int = Field(default=5)
|
||||
ADMIN_USERS_LIST_CACHE_TTL_SECONDS: int = Field(default=3)
|
||||
@@ -147,7 +259,6 @@ class Settings(BaseSettings):
|
||||
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
||||
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
@@ -194,7 +305,7 @@ class Settings(BaseSettings):
|
||||
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501
|
||||
)
|
||||
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
|
||||
default=True,
|
||||
@@ -282,10 +393,35 @@ class Settings(BaseSettings):
|
||||
default=3,
|
||||
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
|
||||
)
|
||||
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow referral welcome bonus grants for users who have not linked Telegram. "
|
||||
"Disposable email domains are still blocked until Telegram is linked."
|
||||
),
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||
)
|
||||
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
|
||||
),
|
||||
)
|
||||
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Timestamp of the latest Remnashop import run, managed by the import script.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Operator notes for instances migrated from Remnashop.",
|
||||
)
|
||||
|
||||
APP_RUNTIME_MODE: str = Field(
|
||||
default="production",
|
||||
@@ -328,6 +464,13 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow trial activation for users who have not linked Telegram. "
|
||||
"Disposable email domains are still blocked until Telegram is linked."
|
||||
),
|
||||
)
|
||||
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -373,15 +516,6 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
||||
default="system",
|
||||
description=(
|
||||
"Emoji font for logo fallback: system, noto-color, noto-color-animated, "
|
||||
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
|
||||
),
|
||||
)
|
||||
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
|
||||
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
@@ -440,6 +574,13 @@ class Settings(BaseSettings):
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
||||
DISPOSABLE_EMAIL_DOMAINS: str = Field(
|
||||
default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
|
||||
description=(
|
||||
"Disposable email domains treated as requiring Telegram for trial and "
|
||||
"referral welcome bonus abuse protection. Accepts commas or one domain per line."
|
||||
),
|
||||
)
|
||||
SMTP_STARTTLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
@@ -544,9 +685,6 @@ class Settings(BaseSettings):
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
||||
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
|
||||
favicon_url=self.WEBAPP_FAVICON_URL,
|
||||
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
|
||||
@@ -627,6 +765,16 @@ class Settings(BaseSettings):
|
||||
return trial_squads
|
||||
return self.parsed_user_squad_uuids
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def disposable_email_domains(self) -> List[str]:
|
||||
domains: List[str] = []
|
||||
for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
|
||||
normalized = domain.strip().lower().lstrip("@.")
|
||||
if normalized and normalized not in domains:
|
||||
domains.append(normalized)
|
||||
return domains
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
@@ -788,21 +936,6 @@ class Settings(BaseSettings):
|
||||
def ignore_deprecated_webapp_logo_url_env(cls, _value):
|
||||
return None
|
||||
|
||||
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
|
||||
return False
|
||||
|
||||
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
|
||||
return "🫥"
|
||||
|
||||
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
|
||||
return "system"
|
||||
|
||||
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
|
||||
@@ -893,6 +1026,7 @@ class Settings(BaseSettings):
|
||||
"stars",
|
||||
"cryptopay",
|
||||
"heleket",
|
||||
"paykilla",
|
||||
]
|
||||
# Make sure default_order itself includes every registered spec.
|
||||
for sid in spec_ids:
|
||||
|
||||
@@ -54,6 +54,8 @@ class ThemeTokens(BaseModel):
|
||||
font_logo: Optional[str] = None
|
||||
font_mono: Optional[str] = None
|
||||
home_logo_scale: Optional[int] = None
|
||||
home_logo_scale_desktop: Optional[int] = None
|
||||
home_logo_scale_mobile: Optional[int] = None
|
||||
admin_bg: Optional[str] = None
|
||||
admin_surface: Optional[str] = None
|
||||
admin_surface_2: Optional[str] = None
|
||||
@@ -80,14 +82,14 @@ class ThemeTokens(BaseModel):
|
||||
hex_value = "".join(char * 2 for char in hex_value)
|
||||
return f"#{hex_value}"
|
||||
|
||||
@field_validator("home_logo_scale")
|
||||
@field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile")
|
||||
@classmethod
|
||||
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
scale = int(value)
|
||||
if scale < 50 or scale > 300:
|
||||
raise ValueError("home_logo_scale must be between 50 and 300 percent")
|
||||
raise ValueError("home logo scale must be between 50 and 300 percent")
|
||||
return scale
|
||||
|
||||
|
||||
@@ -369,6 +371,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
"--success-text" not in style
|
||||
or ".theme-key-light.app-shell" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
)
|
||||
if key == "ascii":
|
||||
return (
|
||||
@@ -379,6 +382,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
or "Console-style tables" not in style
|
||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
)
|
||||
if key != "windows95":
|
||||
return False
|
||||
@@ -402,6 +406,7 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
or "lucide-qr-code" not in style
|
||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
|
||||
)
|
||||
|
||||
|
||||
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
|
||||
description: str,
|
||||
provider: str,
|
||||
provider_payment_id: str,
|
||||
sale_mode: Optional[str] = None,
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
hwid_valid_from: Optional[Any] = None,
|
||||
hwid_valid_until: Optional[Any] = None,
|
||||
hwid_pricing_period_months: Optional[int] = None,
|
||||
hwid_proration_ratio: Optional[float] = None,
|
||||
hwid_full_price: Optional[float] = None,
|
||||
) -> Payment:
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
|
||||
"provider_payment_id": provider_payment_id,
|
||||
"provider": provider,
|
||||
}
|
||||
optional_fields = {
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_valid_from,
|
||||
"hwid_valid_until": hwid_valid_until,
|
||||
"hwid_pricing_period_months": hwid_pricing_period_months,
|
||||
"hwid_proration_ratio": hwid_proration_ratio,
|
||||
"hwid_full_price": hwid_full_price,
|
||||
}
|
||||
payment_payload.update(
|
||||
{field: value for field, value in optional_fields.items() if value is not None}
|
||||
)
|
||||
return await create_payment_record(session, payment_payload)
|
||||
|
||||
|
||||
|
||||
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
|
||||
code = str(code_str or "").strip()
|
||||
if not code:
|
||||
return []
|
||||
candidates = [code] if preserve_case else []
|
||||
upper_code = code.upper()
|
||||
if upper_code not in candidates:
|
||||
candidates.append(upper_code)
|
||||
return candidates
|
||||
|
||||
|
||||
async def get_promo_code_by_code(
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
"""Get promo code by code string (regardless of active status)"""
|
||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == candidate,
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import inspect
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def _normalize_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
async def extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
subscription_id: int,
|
||||
at: Optional[datetime] = None,
|
||||
subscription_end_before: Optional[datetime] = None,
|
||||
delta: timedelta,
|
||||
) -> int:
|
||||
if delta.total_seconds() <= 0:
|
||||
return 0
|
||||
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
|
||||
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
|
||||
|
||||
target_records: List[HwidDevicePurchase] = []
|
||||
if end_before:
|
||||
tail_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
HwidDevicePurchase.subscription_id == subscription_id,
|
||||
HwidDevicePurchase.purchased_devices > 0,
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
HwidDevicePurchase.valid_until >= end_before,
|
||||
HwidDevicePurchase.valid_until > at,
|
||||
or_(
|
||||
HwidDevicePurchase.valid_from.is_(None),
|
||||
HwidDevicePurchase.valid_from < end_before,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(tail_result.scalars().all())
|
||||
|
||||
if not target_records:
|
||||
active_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
*_hwid_active_conditions(subscription_id, at),
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(active_result.scalars().all())
|
||||
|
||||
for record in target_records:
|
||||
if record.valid_until is not None:
|
||||
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
|
||||
if target_records:
|
||||
await session.flush()
|
||||
return len(target_records)
|
||||
|
||||
|
||||
async def create_tariff_change(
|
||||
session: AsyncSession,
|
||||
change_data: Dict[str, Any],
|
||||
|
||||
+116
-6
@@ -4,7 +4,7 @@ import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, case, delete, desc, func, or_, update
|
||||
from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
@@ -14,6 +14,8 @@ from ..models import (
|
||||
AdAttribution,
|
||||
EmailVerificationCode,
|
||||
HwidDevicePurchase,
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
normalized = user.referral_code.strip()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
user_data["referral_code"] = user_data["referral_code"].strip()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
@@ -567,6 +569,19 @@ async def merge_users(
|
||||
await session.execute(
|
||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyReferralCode)
|
||||
.where(LegacyReferralCode.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyImportMapping)
|
||||
.where(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(source_user_id),
|
||||
)
|
||||
.values(target_id=str(target_user_id))
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
@@ -590,13 +605,60 @@ async def merge_users(
|
||||
return target
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
async def get_user_by_referral_code(
|
||||
session: AsyncSession,
|
||||
referral_code: str,
|
||||
*,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[User]:
|
||||
normalized = referral_code.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
upper_normalized = normalized.upper()
|
||||
if upper_normalized != normalized:
|
||||
stmt = select(User).where(User.referral_code == upper_normalized)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if not include_legacy:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if upper_normalized != normalized:
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(
|
||||
LegacyReferralCode.code == upper_normalized,
|
||||
LegacyReferralCode.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -857,6 +919,29 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who never had any subscription or trial.
|
||||
|
||||
These are users who registered but have no ``Subscription`` rows at all —
|
||||
no active, no expired and no trial history. In other words, accounts that
|
||||
signed up and never did anything.
|
||||
"""
|
||||
any_sub = aliased(Subscription)
|
||||
|
||||
stmt = (
|
||||
select(User.user_id)
|
||||
.outerjoin(any_sub, any_sub.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
any_sub.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
def _expired_subscription_exists_for_user(now: datetime):
|
||||
expired_subs = aliased(Subscription)
|
||||
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
||||
@@ -997,6 +1082,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(LegacyImportMapping).where(
|
||||
or_(
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "subscriptions",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Subscription.subscription_id, String)).where(
|
||||
Subscription.user_id == user_id
|
||||
)
|
||||
),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "payments",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
|
||||
|
||||
@@ -1070,6 +1070,91 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "users" in table_names:
|
||||
columns = {col["name"]: col for col in inspector.get_columns("users")}
|
||||
referral_column = columns.get("referral_code")
|
||||
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
|
||||
if referral_column and (length is None or int(length) < 64):
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
|
||||
legacy_code_id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
|
||||
code VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
for stmt in [
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
|
||||
"ON legacy_referral_codes (source)"
|
||||
),
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
|
||||
"ON legacy_referral_codes (user_id)"
|
||||
),
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
|
||||
"ON legacy_referral_codes (is_active)"
|
||||
),
|
||||
]:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
|
||||
source VARCHAR(64) NOT NULL,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
source_id VARCHAR(128) NOT NULL,
|
||||
target_table VARCHAR(128) NOT NULL,
|
||||
target_id VARCHAR(128) NOT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
PRIMARY KEY (source, entity_type, source_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
|
||||
ON legacy_import_mappings (target_table, target_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
|
||||
if "suppress_early_expiry_notifications" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications "
|
||||
"BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1247,6 +1332,16 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
Migration(
|
||||
id="0034_add_legacy_import_compatibility",
|
||||
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||
upgrade=_migration_0034_add_legacy_import_compatibility,
|
||||
),
|
||||
Migration(
|
||||
id="0035_add_subscription_promo_expiry_flag",
|
||||
description="Suppress multi-day expiry reminders for trial and bonus subscriptions",
|
||||
upgrade=_migration_0035_add_subscription_promo_expiry_flag,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+36
-1
@@ -43,7 +43,7 @@ class User(Base):
|
||||
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -122,6 +122,12 @@ class Subscription(Base):
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
# Trial and registration/referral-bonus subscriptions are only a few days
|
||||
# long, so the multi-day "ending soon" reminders would fire almost as soon
|
||||
# as they are granted. While this is set the worker keeps only the
|
||||
# hours-before reminder plus the expiry/after-expiry notices; a real payment
|
||||
# clears it so the full reminder spectrum resumes.
|
||||
suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False)
|
||||
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||
tariff_key = Column(String, nullable=True, index=True)
|
||||
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
||||
@@ -396,6 +402,35 @@ class PromoCodeActivation(Base):
|
||||
)
|
||||
|
||||
|
||||
class LegacyReferralCode(Base):
|
||||
__tablename__ = "legacy_referral_codes"
|
||||
|
||||
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(64), nullable=False, default="remnashop", index=True)
|
||||
code = Column(String(128), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
__table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),)
|
||||
|
||||
|
||||
class LegacyImportMapping(Base):
|
||||
__tablename__ = "legacy_import_mappings"
|
||||
|
||||
source = Column(String(64), primary_key=True)
|
||||
entity_type = Column(String(64), primary_key=True)
|
||||
source_id = Column(String(128), primary_key=True)
|
||||
target_table = Column(String(128), nullable=False)
|
||||
target_id = Column(String(128), nullable=False)
|
||||
metadata_json = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
aiogram==3.28.2
|
||||
python-dotenv==1.2.2
|
||||
aiohttp==3.13.5
|
||||
aiohttp>=3.13.5,<3.14
|
||||
pydantic==2.13.4
|
||||
yookassa==3.10.1
|
||||
httpx>=0.27.0
|
||||
@@ -9,6 +9,6 @@ email-validator==2.3.0
|
||||
sqlalchemy[asyncio]==2.0.49
|
||||
asyncpg==0.31.0
|
||||
aiocryptopay==0.4.8
|
||||
PyJWT[crypto]==2.12.1
|
||||
PyJWT[crypto]==2.13.0
|
||||
Pillow==12.2.0
|
||||
redis==6.4.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational one-shot scripts shipped with the backend image."""
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user