Files
Solo_bot/services/gifts.py
T

250 lines
8.9 KiB
Python

from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from database import (
add_referral,
get_balance,
get_referral_by_referred_id,
store_gift_link,
update_balance,
update_trial,
)
from database.access.resolution import resolve_user_optional
from database.gifts import (
count_gift_usages,
get_gift_locked,
get_gift_usage,
mark_gift_fully_redeemed,
record_gift_usage,
)
from database.tariffs import get_tariff_by_id
from logger import logger
from services.formatting import format_days, format_months, get_gift_link, get_site_gift_link
from .errors import InsufficientFundsError, NotFoundError, ValidationError
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass
class GiftRedeemResult:
message: str
gift_id: str
tariff_id: int
duration_days: int
@dataclass
class GiftCreateResult:
gift_id: str
gift_link: str
site_gift_link: str
tariff_name: str
duration_days: int
duration_text: str
expiry_time: datetime
price_charged: int
def normalize_gift_code(raw: str) -> str:
s = raw.strip()
if not s:
return ""
if "/gift/" in s:
from_url = s.split("/gift/")[-1]
s = from_url.split("?")[0].split("#")[0].strip()
if s.startswith("gift_"):
s = s[5:].strip()
for prefix in ("start=gift_", "start="):
idx = s.find(prefix)
if idx >= 0:
token = s[idx + len(prefix) :]
return token.split("&")[0].split("?")[0].split("#")[0].strip()
return s.split("?")[0].split("#")[0].strip()
def _format_duration(days: int) -> str:
return format_months(days // 30) if days % 30 == 0 else format_days(days)
async def redeem_gift(
session: AsyncSession,
gift_code: str,
billing_user_ref: int,
) -> GiftRedeemResult:
"""Активирует подарок для пользователя.
Создаёт ключ, записывает usage, привязывает реферала.
Raises: ValidationError, NotFoundError
"""
code = normalize_gift_code(gift_code)
if not code:
raise ValidationError("Укажите ссылку или код подарка")
gift_info = await get_gift_locked(session, code)
if not gift_info:
raise NotFoundError("Подарок не найден или срок ссылки истёк")
wu = await resolve_user_optional(session, billing_user_ref)
if wu is None:
raise NotFoundError("Пользователь не найден")
if gift_info.expiry_time and gift_info.expiry_time < datetime.utcnow():
raise ValidationError("Срок действия подарка истёк")
if gift_info.sender_user_id == wu.id:
raise ValidationError("Нельзя активировать подарок, который вы создали сами")
if await get_gift_usage(session, gift_info.gift_id, wu.id) is not None:
raise ValidationError("Вы уже активировали этот подарок")
if gift_info.recipient_user_id and not gift_info.is_unlimited:
raise ValidationError("Этот подарок уже был активирован другим пользователем")
if not gift_info.is_unlimited:
usage_count = await count_gift_usages(session, gift_info.gift_id)
if (
gift_info.is_used
or (gift_info.max_usages is not None and usage_count >= gift_info.max_usages)
or (gift_info.max_usages is None and gift_info.recipient_user_id is not None)
):
raise ValidationError("Этот подарок уже был использован")
existing_referral = await get_referral_by_referred_id(session, wu.id)
if not existing_referral and gift_info.sender_user_id:
await add_referral(session, wu.id, gift_info.sender_user_id)
await update_trial(session, wu.id, 1)
tariff = await get_tariff_by_id(session, int(gift_info.tariff_id)) if gift_info.tariff_id else None
if not tariff:
raise NotFoundError("Тариф, связанный с подарком, не найден")
from services.keys import create_vpn_key_headless
duration_days = int(tariff["duration_days"] or 0)
expiry_time = datetime.now(timezone.utc) + timedelta(days=duration_days)
selected_device_limit = getattr(gift_info, "selected_device_limit", None)
selected_traffic_gb = getattr(gift_info, "selected_traffic_gb", None)
selected_price_rub = getattr(gift_info, "selected_price_rub", None)
await create_vpn_key_headless(
session=session,
tg_id=wu.id,
expiry_time=expiry_time,
plan=int(tariff["id"]),
selected_device_limit=int(selected_device_limit) if selected_device_limit is not None else None,
selected_traffic_gb=int(selected_traffic_gb) if selected_traffic_gb is not None else None,
selected_price_rub=int(selected_price_rub) if selected_price_rub is not None else None,
skip_balance_charge=True,
)
await record_gift_usage(session, gift_info.gift_id, wu.id, wu.tg_id)
if not gift_info.is_unlimited:
usage_count = await count_gift_usages(session, gift_info.gift_id)
if gift_info.max_usages and usage_count >= gift_info.max_usages:
await mark_gift_fully_redeemed(session, code, wu.id, wu.tg_id)
duration_text = _format_duration(duration_days)
try:
from database.web_notifications import notify_web
if wu.tg_id is not None:
await notify_web(
session,
tg_id=wu.tg_id,
type="gift_received",
template_vars={"name": tariff["name"], "duration": duration_text},
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
)
if gift_info.sender_user_id:
sender = await resolve_user_optional(session, gift_info.sender_user_id)
if sender and sender.tg_id is not None:
await notify_web(
session,
tg_id=int(sender.tg_id),
type="gift_redeemed",
title="Ваш подарок активирован",
message=f"Получатель активировал подарок — подписка на {duration_text}.",
data={"gift_id": gift_info.gift_id, "tariff_id": int(tariff["id"])},
)
except Exception as e:
logger.warning("[Gifts] Ошибка отправки уведомления о подарке: {}", e)
return GiftRedeemResult(
message=f"Подарок активирован — подписка на {duration_text}",
gift_id=gift_info.gift_id,
tariff_id=int(tariff["id"]),
duration_days=duration_days,
)
async def create_gift(
session: AsyncSession,
sender_user_ref: int,
tariff_id: int,
selected_device_limit: int | None = None,
selected_traffic_gb: int | None = None,
selected_price_rub: int | None = None,
) -> GiftCreateResult:
"""Создаёт подарок: списывает баланс, сохраняет в БД.
Raises: NotFoundError, InsufficientFundsError
"""
tariff = await get_tariff_by_id(session, int(tariff_id))
if not tariff or tariff.get("group_code") != "gifts":
raise NotFoundError("Тариф не найден")
price_to_charge = int(selected_price_rub) if selected_price_rub is not None else int(tariff["price_rub"])
balance = await get_balance(session, sender_user_ref)
if balance < price_to_charge:
raise InsufficientFundsError(
"Недостаточно средств для создания подарка",
required=price_to_charge,
balance=balance,
)
await update_balance(session, sender_user_ref, -price_to_charge)
duration_days = int(tariff["duration_days"] or 0)
expiry_time = datetime.utcnow() + timedelta(days=duration_days)
gift_id = uuid.uuid4().hex
gift_link = get_gift_link(sender_user_ref, gift_id)
site_gift_link = get_site_gift_link(gift_id)
await store_gift_link(
session=session,
gift_id=gift_id,
sender_legacy_ref=sender_user_ref,
selected_months=duration_days // 30,
expiry_time=expiry_time,
gift_link=gift_link,
tariff_id=int(tariff["id"]),
max_usages=1,
selected_device_limit=selected_device_limit,
selected_traffic_gb=selected_traffic_gb,
selected_price_rub=price_to_charge,
)
return GiftCreateResult(
gift_id=gift_id,
gift_link=gift_link,
site_gift_link=site_gift_link,
tariff_name=tariff["name"],
duration_days=duration_days,
duration_text=_format_duration(duration_days),
expiry_time=expiry_time,
price_charged=price_to_charge,
)