Merge pull request #2208 from Gy9vin/fix

Исправление уязвимостей и багов в конкурсах
This commit is contained in:
Egor
2025-12-27 16:20:18 +03:00
committed by GitHub
66 changed files with 5859 additions and 515 deletions
+51
View File
@@ -14,6 +14,7 @@ ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
@@ -38,6 +39,7 @@ CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -101,6 +103,9 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
@@ -132,6 +137,26 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ДОКУПКА ТРАФИКА =====
# Включить/выключить функцию докупки трафика к существующей подписке
TRAFFIC_TOPUP_ENABLED=true
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
TRAFFIC_TOPUP_PACKAGES_CONFIG=
# ===== СБРОС ТРАФИКА =====
# Режим расчета цены сброса трафика:
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
# "traffic" - цена = стоимость текущего пакета трафика подписки
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
# Используется как минимальная цена или фиксированная в режиме "period"
TRAFFIC_RESET_BASE_PRICE=0
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
@@ -174,6 +199,16 @@ PRICE_PER_DEVICE=10000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== МОДЕМ =====
# Включить функционал подключения модема
MODEM_ENABLED=false
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
MODEM_PRICE_PER_MONTH=10000
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
@@ -287,6 +322,9 @@ NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
@@ -460,6 +498,12 @@ DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
# false: показывать точные суммы с копейками
PRICE_ROUNDING_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
@@ -513,6 +557,13 @@ BOT_RUN_MODE=polling # polling, webhook или both
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# Умная автоактивация: система сама решает — продлить или создать подписку
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
+183 -7
View File
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
@@ -55,7 +61,8 @@ class Settings(BaseSettings):
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
DATABASE_URL: Optional[str] = None
POSTGRES_HOST: str = "postgres"
@@ -143,12 +150,31 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
# Настройки модема
MODEM_ENABLED: bool = False
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
# Настройки докупки трафика
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
# Настройки сброса трафика
# Режимы расчета цены сброса:
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
# "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
# "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
# Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
@@ -244,6 +270,7 @@ class Settings(BaseSettings):
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
@@ -393,7 +420,10 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
PRICE_ROUNDING_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
@@ -762,7 +792,17 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def is_auto_activate_after_topup_enabled(self) -> bool:
"""Умная автоактивация после пополнения баланса (без корзины)."""
value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
@@ -802,10 +842,32 @@ class Settings(BaseSettings):
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
sign = "-" if price_kopeks < 0 else ""
rubles, kopeks = divmod(abs(price_kopeks), 100)
def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str:
"""
Форматирует цену в копейках для отображения пользователю.
Args:
price_kopeks: Сумма в копейках
round_kopeks: Если True, округляет копейки (≤50 вниз, >50 вверх).
Если None, использует настройку PRICE_ROUNDING_ENABLED.
Returns:
Отформатированная строка цены (например, "150 ₽")
"""
# Используем настройку если не передано явно
should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED
sign = "-" if price_kopeks < 0 else ""
abs_kopeks = abs(price_kopeks)
rubles, kopeks = divmod(abs_kopeks, 100)
if should_round:
# Округление: ≤50 коп вниз, >50 коп вверх
if kopeks > 50:
rubles += 1
return f"{sign}{rubles}"
# Без округления - показываем точное значение
if kopeks:
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
return f"{value}"
@@ -986,6 +1048,57 @@ class Settings(BaseSettings):
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_traffic_topup_enabled(self) -> bool:
return self.TRAFFIC_TOPUP_ENABLED
def get_traffic_topup_packages(self) -> List[Dict]:
"""Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
if not config_str:
# Если не настроены отдельные пакеты для докупки - используем основные
return self.get_traffic_packages()
packages = []
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) >= 2:
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
packages.append({"gb": gb, "price": price, "enabled": enabled})
except (ValueError, IndexError):
continue
return packages if packages else self.get_traffic_packages()
def get_traffic_topup_price(self, gb: Optional[int]) -> int:
"""Возвращает цену докупки для указанного количества ГБ."""
packages = self.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
# Ищем точное совпадение
for pkg in enabled_packages:
if pkg["gb"] == gb:
return pkg["price"]
# Если не нашли - возвращаем 0
return 0
def get_traffic_reset_price_mode(self) -> str:
return self.TRAFFIC_RESET_PRICE_MODE.lower()
def get_traffic_reset_base_price(self) -> int:
return self.TRAFFIC_RESET_BASE_PRICE
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
@@ -1012,7 +1125,70 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def get_modem_price_per_month(self) -> int:
try:
value = int(self.MODEM_PRICE_PER_MONTH)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение MODEM_PRICE_PER_MONTH: %s",
self.MODEM_PRICE_PER_MONTH,
)
return 10000
return max(0, value)
def get_modem_period_discounts(self) -> Dict[int, int]:
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
try:
config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip()
if not config_str:
return {}
discounts: Dict[int, int] = {}
for part in config_str.split(','):
part = part.strip()
if not part:
continue
months_and_discount = part.split(':')
if len(months_and_discount) != 2:
continue
months_str, discount_str = months_and_discount
try:
months = int(months_str.strip())
discount_percent = int(discount_str.strip())
except ValueError:
continue
discounts[months] = max(0, min(100, discount_percent))
return discounts
except Exception:
return {}
def get_modem_period_discount(self, months: int) -> int:
"""Возвращает процент скидки для указанного количества месяцев"""
if months <= 0:
return 0
discounts = self.get_modem_period_discounts()
# Ищем точное совпадение или ближайшее меньшее
applicable_discount = 0
for discount_months, discount_percent in sorted(discounts.items()):
if months >= discount_months:
applicable_discount = discount_percent
return applicable_discount
def is_trial_paid_activation_enabled(self) -> bool:
# Если цена > 0, триал автоматически платный
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
if self.TRIAL_ACTIVATION_PRICE > 0:
return True
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
+20 -2
View File
@@ -40,7 +40,8 @@ async def upsert_template(
slug: str,
name: str,
description: str = "",
prize_days: int = 1,
prize_type: str = "days",
prize_value: str = "1",
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
@@ -56,7 +57,8 @@ async def upsert_template(
template.name = name
template.description = description
template.prize_days = prize_days
template.prize_type = prize_type
template.prize_value = prize_value
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
@@ -189,6 +191,22 @@ async def create_attempt(
return attempt
async def update_attempt(
db: AsyncSession,
attempt: ContestAttempt,
*,
answer: Optional[str] = None,
is_winner: bool = False,
) -> ContestAttempt:
"""Update existing attempt with answer and winner status."""
if answer is not None:
attempt.answer = answer
attempt.is_winner = is_winner
await db.commit()
await db.refresh(attempt)
return attempt
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
+170
View File
@@ -269,6 +269,176 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
}
async def get_top_referrers_by_period(
db: AsyncSession,
period: str = "week", # "week" или "month"
sort_by: str = "earnings", # "earnings" или "invited"
limit: int = 20
) -> list:
"""
Получает топ рефереров за период.
Args:
period: "week" (7 дней) или "month" (30 дней)
sort_by: "earnings" (по заработку) или "invited" (по приглашённым)
limit: количество записей
Returns:
Список словарей с данными рефереров
"""
from app.database.models import Transaction, TransactionType
now = datetime.utcnow()
if period == "week":
start_date = now - timedelta(days=7)
else: # month
start_date = now - timedelta(days=30)
if sort_by == "invited":
# Топ по количеству приглашённых за период
referrals_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('invited_count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= start_date
)
)
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(limit)
)
top_data = []
for row in referrals_result:
# Получаем заработок за период для этого реферера
earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == row.referrer_id,
ReferralEarning.created_at >= start_date
)
)
)
earnings = earnings_result.scalar() or 0
# Добавляем транзакции REFERRAL_REWARD
trans_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id == row.referrer_id,
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= start_date
)
)
)
earnings += trans_earnings_result.scalar() or 0
top_data.append({
'referrer_id': row.referrer_id,
'invited_count': row.invited_count,
'earnings_kopeks': earnings
})
else:
# Топ по заработку за период
# Собираем заработки из ReferralEarning
referral_earnings_result = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('ref_earnings')
)
.where(ReferralEarning.created_at >= start_date)
.group_by(ReferralEarning.user_id)
)
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
# Добавляем транзакции REFERRAL_REWARD
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('trans_earnings')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= start_date
)
)
.group_by(Transaction.user_id)
)
# Объединяем заработки
combined_earnings = dict(referral_earnings)
for row in transaction_earnings_result:
if row.referrer_id in combined_earnings:
combined_earnings[row.referrer_id] += row.trans_earnings or 0
else:
combined_earnings[row.referrer_id] = row.trans_earnings or 0
# Сортируем и берём топ
sorted_referrers = sorted(
combined_earnings.items(),
key=lambda x: x[1],
reverse=True
)[:limit]
top_data = []
for referrer_id, earnings in sorted_referrers:
# Получаем количество приглашённых за период
invited_result = await db.execute(
select(func.count(User.id))
.where(
and_(
User.referred_by_id == referrer_id,
User.created_at >= start_date
)
)
)
invited_count = invited_result.scalar() or 0
top_data.append({
'referrer_id': referrer_id,
'invited_count': invited_count,
'earnings_kopeks': earnings
})
# Добавляем информацию о пользователях
result = []
for data in top_data:
user_result = await db.execute(
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
.where(User.id == data['referrer_id'])
)
user = user_result.first()
if user:
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
result.append({
'user_id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'display_name': display_name,
'invited_count': data['invited_count'],
'earnings_kopeks': data['earnings_kopeks']
})
return result
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
invited_count_result = await db.execute(
+84 -4
View File
@@ -128,17 +128,18 @@ async def create_paid_subscription(
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
is_trial: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=False,
is_trial=is_trial,
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
@@ -222,6 +223,7 @@ async def replace_subscription(
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -339,7 +341,8 @@ async def extend_subscription(
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.updated_at = current_time
@@ -1545,7 +1548,84 @@ async def create_pending_subscription(
subscription.id,
payment_method,
)
return subscription
async def create_pending_trial_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending trial subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
# Обновляем существующую подписку
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = True # Помечаем как триальную
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=True, # Помечаем как триальную
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
+8 -4
View File
@@ -331,13 +331,14 @@ async def add_user_balance(
description: str = "Пополнение баланса",
create_transaction: bool = True,
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
bot = None,
payment_method: Optional[PaymentMethod] = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
@@ -346,7 +347,8 @@ async def add_user_balance(
user_id=user.id,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
description=description,
payment_method=payment_method
)
await db.commit()
@@ -368,19 +370,21 @@ async def add_user_balance_by_id(
amount_kopeks: int,
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
payment_method: Optional[PaymentMethod] = None,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
payment_method=payment_method,
)
except Exception as e:
+4 -1
View File
@@ -755,11 +755,13 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
modem_enabled = Column(Boolean, default=False)
connected_squads = Column(JSON, default=list)
@@ -1107,7 +1109,8 @@ class ContestTemplate(Base):
name = Column(String(100), nullable=False)
slug = Column(String(50), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
prize_days = Column(Integer, nullable=False, default=1)
prize_type = Column(String(20), nullable=False, default="days")
prize_value = Column(String(50), nullable=False, default="1")
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
times_per_day = Column(Integer, nullable=False, default=1)
+158 -1
View File
@@ -4555,6 +4555,130 @@ async def add_promocode_promo_group_column() -> bool:
return False
async def migrate_contest_templates_prize_columns() -> bool:
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
try:
prize_type_exists = await check_column_exists("contest_templates", "prize_type")
prize_value_exists = await check_column_exists("contest_templates", "prize_value")
if prize_type_exists and prize_value_exists:
logger.info("Колонки prize_type и prize_value уже существуют в contest_templates")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
# Добавляем prize_type
if not prize_type_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
else:
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
logger.info("✅ Добавлена колонка prize_type в contest_templates")
# Добавляем prize_value
if not prize_value_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
else:
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
logger.info("✅ Добавлена колонка prize_value в contest_templates")
# Мигрируем данные из prize_days в prize_value (если prize_days существует)
prize_days_exists = await check_column_exists("contest_templates", "prize_days")
if prize_days_exists:
await conn.execute(text(
"UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'"
))
logger.info("✅ Данные из prize_days перенесены в prize_value")
return True
except Exception as error:
logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}")
return False
async def add_subscription_modem_enabled_column() -> bool:
"""Добавить колонку modem_enabled в subscriptions."""
try:
column_exists = await check_column_exists("subscriptions", "modem_enabled")
if column_exists:
logger.info("Колонка modem_enabled уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE"
))
else:
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0"
))
logger.info("✅ Добавлена колонка modem_enabled в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}")
return False
async def add_subscription_purchased_traffic_column() -> bool:
"""Добавить колонку purchased_traffic_gb в subscriptions."""
try:
column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb")
if column_exists:
logger.info("Колонка purchased_traffic_gb уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
))
else:
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0"
))
logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -4742,6 +4866,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей contest_templates")
logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===")
prize_columns_ready = await migrate_contest_templates_prize_columns()
if prize_columns_ready:
logger.info("✅ Колонки prize_type и prize_value готовы")
else:
logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value")
contest_rounds_ready = await create_contest_rounds_table()
if contest_rounds_ready:
logger.info("✅ Таблица contest_rounds готова")
@@ -4897,6 +5028,20 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===")
modem_enabled_added = await add_subscription_modem_enabled_column()
if modem_enabled_added:
logger.info("✅ Колонка modem_enabled готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===")
purchased_traffic_added = await add_subscription_purchased_traffic_column()
if purchased_traffic_added:
logger.info("✅ Колонка purchased_traffic_gb готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -5079,6 +5224,10 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"subscription_crypto_link_column": False,
"subscription_modem_enabled_column": False,
"subscription_purchased_traffic_column": False,
"contest_templates_prize_type_column": False,
"contest_templates_prize_value_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
"discount_offers_extra_column": False,
@@ -5139,7 +5288,11 @@ async def check_migration_status():
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type')
status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
await check_column_exists('broadcast_history', 'media_type') and
@@ -5211,6 +5364,10 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
"contest_templates_prize_type_column": "Колонка prize_type в contest_templates",
"contest_templates_prize_value_column": "Колонка prize_value в contest_templates",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
+9 -4
View File
@@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
clear_attempts,
create_round,
get_template_by_id,
list_templates,
update_template_fields,
create_round,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
@@ -28,7 +29,8 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
"prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
"prize_value": {"type": str, "label": "значение приза"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
@@ -57,7 +59,8 @@ async def show_daily_contests(
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
@@ -101,10 +104,12 @@ async def show_daily_contest(
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines = [
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
f"Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
+134 -4
View File
@@ -10,6 +10,7 @@ from aiogram.exceptions import TelegramBadRequest
from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
@@ -911,11 +912,36 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
# Добавляем информацию о чеках NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
nalogo_section = f"""
🧾 <b>Чеки NaloGO:</b>
Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f}"
text += nalogo_section
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
buttons = []
# Кнопка обработки очереди чеков если есть что обрабатывать
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
if nalogo_status.get("queue_length", 0) > 0:
buttons.append([InlineKeyboardButton(
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
callback_data="admin_mon_nalogo_force_process"
)])
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -925,6 +951,110 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_nalogo_force_process")
@admin_required
async def nalogo_force_process_callback(callback: CallbackQuery):
"""Принудительная отправка чеков из очереди."""
try:
await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
result = await nalogo_queue_service.force_process()
if "error" in result:
await callback.answer(f"{result['error']}", show_alert=True)
return
message = result.get("message", "Готово")
processed = result.get("processed", 0)
remaining = result.get("remaining", 0)
if processed > 0:
text = f"✅ Обработано: {processed} чек(ов)"
if remaining > 0:
text += f"\n⏳ Осталось в очереди: {remaining}"
else:
if remaining > 0:
text = f"⚠️ Сервис nalog.ru недоступен\nВ очереди: {remaining} чек(ов)"
else:
text = "📭 Очередь пуста"
await callback.answer(text, show_alert=True)
# Обновляем страницу статистики
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Перезагружаем статистику
async for db in get_db():
from app.database.crud.subscription import get_subscriptions_statistics
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
week_errors = len(week_logs) - week_success
stats_text = f"""
📊 <b>Статистика мониторинга</b>
📱 <b>Подписки:</b>
Всего: {sub_stats['total_subscriptions']}
Активных: {sub_stats['active_subscriptions']}
Тестовых: {sub_stats['trial_subscriptions']}
Платных: {sub_stats['paid_subscriptions']}
📈 <b>За сегодня:</b>
Успешных операций: {mon_status['stats_24h']['successful']}
Ошибок: {mon_status['stats_24h']['failed']}
Успешность: {mon_status['stats_24h']['success_rate']}%
📊 <b>За неделю:</b>
Всего событий: {len(week_logs)}
Успешных: {week_success}
Ошибок: {week_errors}
Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 <b>Система:</b>
Интервал: {settings.MONITORING_INTERVAL} мин
Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
nalogo_section = f"""
🧾 <b>Чеки NaloGO:</b>
Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f}"
stats_text += nalogo_section
buttons = []
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
if nalogo_status.get("queue_length", 0) > 0:
buttons.append([InlineKeyboardButton(
text=f"🧾 Отправить чеки ({nalogo_status['queue_length']} шт.)",
callback_data="admin_mon_nalogo_force_process"
)])
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка принудительной обработки чеков: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+110 -24
View File
@@ -6,7 +6,11 @@ import datetime
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
from app.database.crud.referral import (
get_referral_statistics,
get_top_referrers_by_period,
get_user_referral_stats,
)
from app.database.crud.user import get_user_by_id
from app.utils.decorators import admin_required, error_handler
@@ -121,6 +125,39 @@ async def show_referral_statistics(
await callback.answer("Произошла ошибка при загрузке статистики")
def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup:
"""Создаёт клавиатуру для выбора периода и сортировки."""
period_week = "✅ Неделя" if period == "week" else "Неделя"
period_month = "✅ Месяц" if period == "month" else "Месяц"
sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку"
sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым"
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text=period_week,
callback_data=f"admin_top_ref:week:{sort_by}"
),
types.InlineKeyboardButton(
text=period_month,
callback_data=f"admin_top_ref:month:{sort_by}"
),
],
[
types.InlineKeyboardButton(
text=sort_earnings,
callback_data=f"admin_top_ref:{period}:earnings"
),
types.InlineKeyboardButton(
text=sort_invited,
callback_data=f"admin_top_ref:{period}:invited"
),
],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")],
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
@admin_required
@error_handler
async def show_top_referrers(
@@ -128,27 +165,66 @@ async def show_top_referrers(
db_user: User,
db: AsyncSession
):
"""Показывает топ рефереров (по умолчанию: неделя, по заработку)."""
await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings")
@admin_required
@error_handler
async def show_top_referrers_filtered(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обрабатывает выбор периода и сортировки."""
# Парсим callback_data: admin_top_ref:period:sort_by
parts = callback.data.split(":")
if len(parts) != 3:
await callback.answer("Ошибка параметров")
return
period = parts[1] # week или month
sort_by = parts[2] # earnings или invited
if period not in ("week", "month"):
period = "week"
if sort_by not in ("earnings", "invited"):
sort_by = "earnings"
await _show_top_referrers_filtered(callback, db, period, sort_by)
async def _show_top_referrers_filtered(
callback: types.CallbackQuery,
db: AsyncSession,
period: str,
sort_by: str
):
"""Внутренняя функция отображения топа с фильтрами."""
try:
stats = await get_referral_statistics(db)
top_referrers = stats.get('top_referrers', [])
text = "🏆 <b>Топ рефереров</b>\n\n"
top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by)
period_text = "за неделю" if period == "week" else "за месяц"
sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым"
text = f"🏆 <b>Топ рефереров {period_text}</b>\n"
text += f"<i>Сортировка: {sort_text}</i>\n\n"
if top_referrers:
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('earnings_kopeks', 0)
count = referrer.get('invited_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
emoji = ""
if i == 1:
emoji = "🥇 "
@@ -156,21 +232,30 @@ async def show_top_referrers(
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
# Выделяем основную метрику в зависимости от сортировки
if sort_by == "invited":
text += f"{emoji}{i}. {display_text}\n"
text += f" 👥 <b>{count} приглашённых</b> | 💰 {settings.format_price(earned)}\n\n"
else:
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 <b>{settings.format_price(earned)}</b> | 👥 {count} приглашённых\n\n"
else:
text += "Нет данных о рефererах\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
text += "Нет данных за выбранный период\n"
keyboard = _get_top_keyboard(period, sort_by)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
except Exception as edit_error:
if "message is not modified" in str(edit_error):
await callback.answer("Данные актуальны")
else:
raise
except Exception as e:
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@@ -210,4 +295,5 @@ async def show_referral_settings(
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
+76
View File
@@ -1241,6 +1241,15 @@ async def _render_user_subscription_overview(
]
]
if settings.is_modem_enabled():
modem_status = "" if getattr(subscription, 'modem_enabled', False) else ""
keyboard.append([
types.InlineKeyboardButton(
text=f"📡 Модем ({modem_status})",
callback_data=f"admin_user_modem_{user_id}"
)
])
if subscription.is_active:
keyboard.append([
types.InlineKeyboardButton(
@@ -3803,6 +3812,68 @@ async def set_user_devices_button(
await callback.answer()
@admin_required
@error_handler
async def toggle_user_modem(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключение модема для пользователя в админке."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
subscription = user.subscription
if not subscription:
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
return
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled:
# Отключаем модем
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
action_text = "отключен"
else:
# Включаем модем
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
action_text = "подключен"
subscription.updated_at = datetime.utcnow()
await db.commit()
# Обновляем в RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}")
await db.refresh(subscription)
modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен"
await callback.message.edit_text(
f"📡 <b>Модем {action_text}</b>\n\n"
f"Статус модема: {modem_status}\n"
f"Лимит устройств: {subscription.device_limit}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
]),
parse_mode="HTML"
)
logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}")
await callback.answer()
@admin_required
@error_handler
async def process_devices_edit_text(
@@ -5198,6 +5269,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_devices_set_")
)
dp.callback_query.register(
toggle_user_modem,
F.data.startswith("admin_user_modem_")
)
dp.message.register(
process_devices_edit_text,
AdminStates.editing_user_devices
+209 -282
View File
@@ -1,41 +1,73 @@
"""Contest handlers for daily games."""
import logging
import random
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
from app.localization.texts import get_texts
from app.services.contest_rotation_service import (
GAME_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
GAME_ANAGRAM,
)
from app.database.crud.contest import get_active_rounds, get_attempt
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import extend_subscription
from app.utils.decorators import auth_required, error_handler
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, SubscriptionStatus
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.contests import (
ContestAttemptService,
GameType,
get_game_strategy,
)
from app.states import ContestStates
from app.utils.decorators import auth_required, error_handler
logger = logging.getLogger(__name__)
# Rate limiting storage
_rate_limits: dict = {}
# Service instance
_attempt_service = ContestAttemptService()
def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
"""Check if user exceeds rate limit for contest actions."""
key = f"{user_id}_{action}"
now = datetime.utcnow().timestamp()
if key not in _rate_limits:
_rate_limits[key] = []
# Clean old entries
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
if len(_rate_limits[key]) >= limit:
return False
_rate_limits[key].append(now)
return True
def _validate_callback_data(data: str) -> Optional[list]:
"""Validate and parse callback data safely."""
if not data or not isinstance(data, str):
return None
parts = data.split("_")
if len(parts) < 2 or parts[0] != "contest":
return None
for part in parts:
if not part or len(part) > 50:
return None
return parts
def _user_allowed(subscription) -> bool:
"""Check if user has active or trial subscription."""
if not subscription:
return False
return subscription.status in {
@@ -44,22 +76,13 @@ def _user_allowed(subscription) -> bool:
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
return ""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
await extend_subscription(db, subscription, prize_days)
texts = get_texts(language)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
"""Reply that user is not eligible to play."""
texts = get_texts(language)
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
show_alert=True,
)
# ---------- Handlers ----------
@@ -68,13 +91,17 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Show menu with available contest games."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
# Group by template, take one round per template
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
@@ -86,19 +113,24 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
buttons.append(
[
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
]
)
buttons.append([
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
])
if not buttons:
buttons.append(
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
)
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
buttons.append([
types.InlineKeyboardButton(
text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
callback_data="noop",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
@@ -110,14 +142,25 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
"""Start playing a specific contest."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
@@ -128,285 +171,169 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user
await callback.answer("Некорректные данные", show_alert=True)
return
slug = "_".join(parts[2:-1])
# reload round with template
# Get round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
show_alert=True,
)
return
if not round_obj.template or not round_obj.template.is_enabled:
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
await callback.answer(
texts.t("CONTEST_DISABLED", "Игра отключена."),
show_alert=True,
)
return
# Check if user already played
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
await callback.answer(
texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
show_alert=True,
)
return
# Get game strategy and render
tpl = round_obj.template
if tpl.slug == GAME_QUEST:
await _render_quest(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_LOCKS:
await _render_locks(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_SERVER:
await _render_server_lottery(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_CIPHER:
await _render_cipher(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_EMOJI:
await _render_emoji(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_ANAGRAM:
await _render_anagram(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_BLITZ:
await _render_blitz(callback, db_user, round_obj, tpl)
else:
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
strategy = get_game_strategy(tpl.slug)
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
keyboard = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
)
if not strategy:
await callback.answer(
texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
show_alert=True,
)
keyboard.append(row_buttons)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
return
render_result = strategy.render(
round_id=round_obj.id,
payload=round_obj.payload or {},
language=db_user.language,
)
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
total = round_obj.payload.get("total", 20)
keyboard = []
row = []
for i in range(total):
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
# For text input games, create pending attempt and set FSM state
if render_result.requires_text_input:
await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
flags = round_obj.payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard = []
row = []
for flag in shuffled_flags:
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
letters = round_obj.payload.get("letters", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
]
)
await callback.message.edit_text(
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
reply_markup=keyboard,
)
await callback.answer()
await callback.message.edit_text(
render_result.text,
reply_markup=render_result.keyboard,
)
await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Handle button pick in contest games."""
texts = get_texts(db_user.language)
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
return
tpl = round_obj.template
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
return
secret_idx = round_obj.payload.get("secret_idx")
correct_flag = ""
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_winner = False
if tpl.slug == GAME_SERVER:
is_winner = pick == correct_flag
elif tpl.slug == GAME_QUEST:
# Format: quest_{idx}
try:
if pick.startswith("quest_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_LOCKS:
# Format: locks_{idx}
try:
if pick.startswith("locks_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = pick == "blitz"
else:
is_winner = False
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
else:
responses = {
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
}.get(tpl.slug, ["Неудача"])
await callback.answer(random.choice(responses), show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
# Re-check subscription
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
show_alert=True,
)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
show_alert=True,
)
return
# Process attempt using service
result = await _attempt_service.process_button_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
pick=pick,
language=db_user.language,
)
await callback.answer(result.message, show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
"""Handle text answer in contest games."""
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
await state.clear()
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
attempt = await get_attempt(db2, round_obj.id, db_user.id)
if attempt:
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
# Process attempt using service
text_answer = (message.text or "").strip()
result = await _attempt_service.process_text_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
text_answer=text_answer,
language=db_user.language,
)
answer = (message.text or "").strip().upper()
tpl = round_obj.template
correct = (round_obj.payload.get("answer") or "").upper()
await message.answer(
result.message,
reply_markup=get_back_keyboard(db_user.language),
)
is_winner = correct and answer == correct
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
else:
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
def register_handlers(dp: Dispatcher):
"""Register contest handlers."""
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
+127 -41
View File
@@ -1238,39 +1238,68 @@ async def handle_activate_button(
db_user: User,
db: AsyncSession
):
"""
Умная кнопка активации система сама решает что делать:
- Если подписка активна ничего не делать
- Если подписка истекла продлить с теми же параметрами
- Если подписки нет создать новую с дефолтными параметрами
Выбирает максимальный период, который можно оплатить из баланса.
"""
texts = get_texts(db_user.language)
# Получить подписку пользователя
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType, PaymentMethod
from app.services.subscription_service import SubscriptionService
from app.services.subscription_renewal_service import SubscriptionRenewalService
subscription = await get_subscription_by_user_id(db, db_user.id)
# Если подписка активна — ничего не делаем
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
await callback.answer(
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
show_alert=True,
)
return
# Параметры из подписки или дефолтные
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
connected_squads = subscription.connected_squads if subscription else []
# Получить IDs серверов из UUIDs
from app.database.crud.server_squad import get_server_ids_by_uuids
# Определяем параметры подписки
if subscription:
# Есть подписка (возможно истекшая) — берём её параметры
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb or 0
connected_squads = subscription.connected_squads or []
else:
# Нет подписки — дефолтные параметры
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = 0
connected_squads = []
# Если серверы не выбраны — берём бесплатные по умолчанию
if not connected_squads:
available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id)
connected_squads = [
s.squad_uuid for s in available_servers
if s.is_available and s.price_kopeks == 0
]
# Если бесплатных нет — берём первый доступный
if not connected_squads and available_servers:
connected_squads = [available_servers[0].squad_uuid]
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = db_user.balance_kopeks
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
subscription_service = SubscriptionService()
# Найти максимальный период <= баланса
best_period = None
best_price = 0
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
# Найти максимальный период, цена которого <= баланса
for period in sorted(available_periods, reverse=True):
for period in available_periods:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
@@ -1283,31 +1312,88 @@ async def handle_activate_button(
best_period = period
best_price = price
break
if best_period:
# Создать новую подписку
from app.database.crud.subscription import create_paid_subscription
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
# Списать деньги
db_user.balance_kopeks -= best_price
await db.commit()
missing = min_price - balance
await callback.answer(
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
texts.t(
"INSUFFICIENT_FUNDS_DETAILED",
f"❌ Недостаточно средств. Не хватает {missing // 100}"
),
show_alert=True,
)
else:
return
try:
if subscription:
# Продление существующей подписки
renewal_service = SubscriptionRenewalService()
pricing = await renewal_service.calculate_pricing(
db, db_user, subscription, best_period
)
result = await renewal_service.finalize(
db, db_user, subscription,
pricing,
description=f"Автоматическое продление на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
await callback.answer(
texts.t(
"ACTIVATION_SUCCESS",
f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!"
),
show_alert=True,
)
else:
# Создание новой подписки
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
)
# Списать баланс правильно
await subtract_user_balance(
db, db_user, best_price,
f"Активация подписки на {best_period} дней"
)
# Создать пользователя в RemnaWave
await subscription_service.create_remnawave_user(db, new_subscription)
# Создать транзакцию
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=best_price,
description=f"Активация подписки на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
await callback.answer(
texts.t(
"ACTIVATION_SUCCESS",
f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!"
),
show_alert=True,
)
except Exception as e:
logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
await callback.answer(
texts.t("INSUFFICIENT_FUNDS", "Недостаточно средств для активации подписки"),
texts.t("ACTIVATION_ERROR", "Ошибка активации. Попробуйте позже."),
show_alert=True,
)
+5 -3
View File
@@ -481,9 +481,11 @@ def register_handlers(dp: Dispatcher):
F.data == "referral_analytics"
)
async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
page = int(callback.data.split('_')[-1])
await show_detailed_referral_list(callback, db_user, db, page)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(
callback, db_user, db, int(callback.data.split('_')[-1])
),
handle_referral_list_page,
F.data.startswith("referral_list_page_")
)
+13
View File
@@ -120,6 +120,14 @@ from .traffic import (
select_traffic,
)
from .modem import (
handle_modem_menu,
handle_modem_enable,
handle_modem_confirm,
handle_modem_disable,
register_modem_handlers,
)
__all__ = [
'activate_trial',
'add_traffic',
@@ -185,6 +193,11 @@ __all__ = [
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'handle_modem_menu',
'handle_modem_enable',
'handle_modem_confirm',
'handle_modem_disable',
'register_modem_handlers',
'load_app_config',
'refresh_traffic_config',
'register_handlers',
+17 -3
View File
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
get_user_active_promo_discount_percent,
)
from .countries import _get_available_countries, _should_show_countries_management
from .countries import (
_build_countries_selection_text,
_get_available_countries,
_get_preselected_free_countries,
_should_show_countries_management,
)
from .pricing import _build_subscription_period_prompt
async def handle_autopay_menu(
@@ -260,9 +265,18 @@ async def _show_previous_configuration_step(
data = await state.get_data()
selected_countries = data.get('countries', [])
# Если страны не выбраны — автоматически предвыбираем бесплатные
if not selected_countries:
selected_countries = _get_preselected_free_countries(countries)
data['countries'] = selected_countries
await state.set_data(data)
# Формируем текст с описаниями сквадов
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
selection_text,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
return
+10 -2
View File
@@ -390,9 +390,15 @@ def get_traffic_switch_keyboard(
language: str = "ru",
subscription_end_date: datetime = None,
discount_percent: int = 0,
base_traffic_gb: int = None,
) -> InlineKeyboardMarkup:
from app.config import settings
# Если базовый трафик не передан, используем текущий
# (для обратной совместимости и случаев без докупленного трафика)
if base_traffic_gb is None:
base_traffic_gb = current_traffic_gb
months_multiplier = 1
period_text = ""
if subscription_end_date:
@@ -403,7 +409,8 @@ def get_traffic_switch_keyboard(
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
# Используем базовый трафик для определения цены текущего пакета
current_price_per_month = settings.get_traffic_price(base_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
@@ -422,7 +429,8 @@ def get_traffic_switch_keyboard(
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
if gb == current_traffic_gb:
# Сравниваем с базовым трафиком (без докупленного)
if gb == base_traffic_gb:
emoji = ""
action_text = " (текущий)"
price_text = ""
+35 -3
View File
@@ -646,7 +646,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
"is_available": server.is_available and not server.is_full
"is_available": server.is_available and not server.is_full,
"description": server.description or ""
})
if not countries:
@@ -675,7 +676,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"uuid": squad["uuid"],
"name": squad_name,
"price_kopeks": 0,
"is_available": True
"is_available": True,
"description": ""
})
await cache.set(cache_key_value, countries, 300)
@@ -684,7 +686,7 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
except Exception as e:
logger.error(f"Ошибка получения списка стран: {e}")
fallback_countries = [
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""},
]
await cache.set(cache_key_value, fallback_countries, 60)
@@ -694,6 +696,36 @@ async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
def _get_preselected_free_countries(countries: List[dict]) -> List[str]:
"""Получить UUID бесплатных серверов для автоматического предвыбора."""
return [
c['uuid'] for c in countries
if c.get('is_available', True) and c.get('price_kopeks', 0) == 0
]
def _build_countries_selection_text(countries: List[dict], base_text: str) -> str:
"""
Формирует текст выбора серверов с описаниями.
Если у серверов есть description добавляет их под базовым текстом.
"""
descriptions = []
for country in countries:
if not country.get('is_available', True):
continue
desc = country.get('description', '').strip()
if desc:
name = country.get('name', '')
descriptions.append(f"<b>{name}</b>\n{desc}")
if not descriptions:
return base_text
return f"{base_text}\n\n" + "\n\n".join(descriptions)
async def handle_add_country_to_subscription(
callback: types.CallbackQuery,
db_user: User,
+53
View File
@@ -355,6 +355,28 @@ async def confirm_change_devices(
).format(count=new_devices_count)
cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
# Проверяем количество подключённых устройств для предупреждения
devices_warning = ""
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if response and 'response' in response:
connected_count = response['response'].get('total', 0)
if connected_count > new_devices_count:
devices_warning = texts.t(
"DEVICE_CHANGE_RESET_WARNING",
(
"\n⚠️ <b>Внимание!</b>\n"
"У вас подключено {connected} устройств.\n"
"При уменьшении лимита до {new} все устройства будут сброшены.\n"
"Вам нужно будет заново подключить нужные устройства.\n"
),
).format(connected=connected_count, new=new_devices_count)
except Exception as e:
logger.error(f"Ошибка проверки устройств: {e}")
confirm_text = texts.t(
"DEVICE_CHANGE_CONFIRMATION",
(
@@ -372,6 +394,9 @@ async def confirm_change_devices(
cost=cost_text,
)
if devices_warning:
confirm_text += devices_warning
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
@@ -431,6 +456,28 @@ async def execute_change_devices(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# При уменьшении лимита - сбросить лишние устройства
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if response and 'response' in response:
devices_list = response['response'].get('devices', [])
connected_count = len(devices_list)
# Если подключённых устройств больше чем новый лимит - сбросить все
if connected_count > new_devices_count:
logger.info(
f"🔧 Сброс устройств при уменьшении лимита: "
f"подключено {connected_count}, новый лимит {new_devices_count}"
)
await api.reset_user_devices(db_user.remnawave_uuid)
devices_reset_count = connected_count
except Exception as reset_error:
logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -466,6 +513,12 @@ async def execute_change_devices(
"DEVICE_CHANGE_RESULT_LINE",
"📱 Было: {old} → Стало: {new}\n",
).format(old=current_devices, new=new_devices_count)
if devices_reset_count > 0:
success_text += texts.t(
"DEVICE_CHANGE_DEVICES_RESET",
"\n🔄 Сброшено устройств: {count}\n"
"💡 Подключите заново нужные устройства (до {limit} шт.)\n\n",
).format(count=devices_reset_count, limit=new_devices_count)
success_text += texts.t(
"DEVICE_CHANGE_NO_REFUND_INFO",
"ℹ️ Возврат средств не производится",
+388
View File
@@ -0,0 +1,388 @@
"""
Хендлеры для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
from app.localization.texts import get_texts
from app.services.modem_service import (
get_modem_service,
ModemError,
MODEM_WARNING_DAYS_CRITICAL,
MODEM_WARNING_DAYS_INFO,
)
from app.utils.decorators import error_handler, modem_available
logger = logging.getLogger(__name__)
def get_modem_keyboard(language: str, modem_enabled: bool):
"""Клавиатура управления модемом."""
texts = get_texts(language)
keyboard = []
if modem_enabled:
keyboard.append([
types.InlineKeyboardButton(
text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"),
callback_data="modem_disable"
)
])
else:
keyboard.append([
types.InlineKeyboardButton(
text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"),
callback_data="modem_enable"
)
])
keyboard.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_settings"
)
])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_modem_confirm_keyboard(language: str):
"""Клавиатура подтверждения подключения модема."""
texts = get_texts(language)
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"),
callback_data="modem_confirm"
)
],
[
types.InlineKeyboardButton(
text=texts.CANCEL,
callback_data="subscription_modem"
)
]
])
@error_handler
@modem_available()
async def handle_modem_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает меню управления модемом."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
modem_enabled = service.get_modem_enabled(subscription)
modem_price = settings.get_modem_price_per_month()
if modem_enabled:
status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен")
info_text = texts.t(
"MODEM_INFO_ENABLED",
(
"<b>Модем</b>\n\n"
"Статус: {status}\n\n"
"Модем подключен к вашей подписке.\n"
"Ежемесячная плата: {price}\n\n"
"При отключении модема возврат средств не производится."
),
).format(
status=status_text,
price=texts.format_price(modem_price),
)
else:
status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен")
info_text = texts.t(
"MODEM_INFO_DISABLED",
(
"<b>Модем</b>\n\n"
"Статус: {status}\n\n"
"Подключите модем к вашей подписке.\n"
"Ежемесячная плата: {price}\n\n"
"При подключении модема будет добавлено дополнительное устройство."
),
).format(
status=status_text,
price=texts.format_price(modem_price),
)
await callback.message.edit_text(
info_text,
reply_markup=get_modem_keyboard(db_user.language, modem_enabled),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_enable=True)
async def handle_modem_enable(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обработчик подключения модема - показывает информацию о цене."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
price_info = service.calculate_price(subscription)
modem_price_per_month = settings.get_modem_price_per_month()
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
if not has_funds:
if price_info.has_discount:
required_text = (
f"{texts.format_price(price_info.final_price)} "
f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)"
)
else:
required_text = (
f"{texts.format_price(price_info.final_price)} "
f"(за {price_info.charged_months} мес)"
)
message_text = texts.t(
"MODEM_INSUFFICIENT_FUNDS",
(
"<b>Недостаточно средств</b>\n\n"
"Стоимость подключения модема: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
warning_level = service.get_period_warning_level(price_info.remaining_days)
if warning_level == "critical":
warning_text = texts.t(
"MODEM_SHORT_PERIOD_WARNING",
"\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n"
"После продления подписки модем нужно будет оплатить заново!"
).format(days=price_info.remaining_days)
elif warning_level == "info":
warning_text = texts.t(
"MODEM_PERIOD_NOTE",
"\nДо окончания подписки: <b>{days} дн.</b>\n"
"После продления модем нужно будет оплатить заново."
).format(days=price_info.remaining_days)
else:
warning_text = ""
if price_info.has_discount:
price_text = texts.t(
"MODEM_PRICE_WITH_DISCOUNT",
"Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n"
"Скидка {discount}%: -{discount_amount}"
).format(
base_price=texts.format_price(price_info.base_price),
final_price=texts.format_price(price_info.final_price),
months=price_info.charged_months,
discount=price_info.discount_percent,
discount_amount=texts.format_price(price_info.discount_amount),
)
else:
price_text = texts.t(
"MODEM_PRICE_NO_DISCOUNT",
"Стоимость: {price} (за {months} мес)"
).format(
price=texts.format_price(price_info.final_price),
months=price_info.charged_months,
)
confirm_text = texts.t(
"MODEM_CONFIRM_ENABLE_BASE",
(
"<b>Подтверждение подключения модема</b>\n\n"
"{price_text}\n\n"
"При подключении модема:\n"
"К подписке добавится дополнительное устройство\n"
"Ежемесячная плата увеличится на {monthly_price}\n\n"
"Подтвердить подключение?"
),
).format(
price_text=price_text,
monthly_price=texts.format_price(modem_price_per_month),
)
end_date_str = price_info.end_date.strftime("%d.%m.%Y")
period_info = texts.t(
"MODEM_PERIOD_INFO",
"\nМодем действует до: <b>{end_date}</b> ({days} дн.)"
).format(end_date=end_date_str, days=price_info.remaining_days)
confirm_text += period_info + warning_text
await callback.message.edit_text(
confirm_text,
reply_markup=get_modem_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_enable=True)
async def handle_modem_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтверждение и активация модема."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
result = await service.enable_modem(db, db_user, subscription)
if not result.success:
error_messages = {
ModemError.INSUFFICIENT_FUNDS: texts.t(
"MODEM_INSUFFICIENT_FUNDS_SHORT",
"Недостаточно средств на балансе"
),
ModemError.CHARGE_ERROR: texts.t(
"PAYMENT_CHARGE_ERROR",
"Ошибка списания средств"
),
ModemError.UPDATE_ERROR: texts.ERROR,
}
error_text = error_messages.get(result.error, texts.ERROR)
if result.error == ModemError.INSUFFICIENT_FUNDS:
await callback.message.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language, "modem_enable"),
parse_mode="HTML"
)
else:
await callback.answer(error_text, show_alert=True)
return
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "modem", False, True, result.charged_amount
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о подключении модема: {e}")
success_text = texts.t(
"MODEM_ENABLED_SUCCESS",
(
"<b>Модем успешно подключен!</b>\n\n"
"Модем активирован\n"
"Добавлено устройство для модема\n"
),
)
if result.charged_amount > 0:
success_text += texts.t(
"MODEM_CHARGED",
"Списано: {amount}",
).format(amount=texts.format_price(result.charged_amount))
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_disable=True)
async def handle_modem_disable(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Отключение модема."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
result = await service.disable_modem(db, db_user, subscription)
if not result.success:
await callback.answer(texts.ERROR, show_alert=True)
return
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "modem", True, False, 0
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об отключении модема: {e}")
success_text = texts.t(
"MODEM_DISABLED_SUCCESS",
(
"<b>Модем отключен</b>\n\n"
"Модем деактивирован\n"
"Возврат средств не производится"
),
)
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
parse_mode="HTML"
)
await callback.answer()
def register_modem_handlers(dp: Dispatcher):
"""Регистрация обработчиков модема."""
dp.callback_query.register(
handle_modem_menu,
F.data == "subscription_modem"
)
dp.callback_query.register(
handle_modem_enable,
F.data == "modem_enable"
)
dp.callback_query.register(
handle_modem_confirm,
F.data == "modem_confirm"
)
dp.callback_query.register(
handle_modem_disable,
F.data == "modem_disable"
)
+850 -21
View File
@@ -17,6 +17,7 @@ from app.database.crud.discount_offer import (
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.subscription import (
create_trial_subscription,
create_pending_trial_subscription,
create_paid_subscription, add_subscription_traffic, add_subscription_devices,
update_subscription_autopay
)
@@ -49,6 +50,7 @@ from app.keyboards.inline import (
)
from app.services.user_cart_service import user_cart_service
from app.localization.texts import get_texts
from app.utils.decorators import error_handler
from app.services.admin_notification_service import AdminNotificationService
from app.services.remnawave_service import RemnaWaveConfigurationError, RemnaWaveService
from app.services.blacklist_service import blacklist_service
@@ -138,7 +140,9 @@ from .autopay import (
toggle_autopay,
)
from .countries import (
_build_countries_selection_text,
_get_available_countries,
_get_preselected_free_countries,
_should_show_countries_management,
apply_countries_changes,
countries_continue,
@@ -346,6 +350,15 @@ async def show_subscription_info(
"",
)
# Формируем отображение лимита устройств с учётом модема
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled and settings.is_modem_enabled():
# Показываем лимит без модема + модем
visible_device_limit = (subscription.device_limit or 1) - 1
device_limit_display = f"{visible_device_limit} + модем"
else:
device_limit_display = str(subscription.device_limit)
message = message_template.format(
full_name=db_user.full_name,
balance=settings.format_price(db_user.balance_kopeks),
@@ -358,7 +371,7 @@ async def show_subscription_info(
traffic=traffic_used_display,
servers=servers_display,
devices_used=devices_used_str,
device_limit=subscription.device_limit,
device_limit=device_limit_display,
)
if show_devices and devices_list:
@@ -489,12 +502,86 @@ async def show_trial_offer(
)
await callback.answer()
def _get_trial_payment_keyboard(language: str, can_pay_from_balance: bool = False) -> types.InlineKeyboardMarkup:
"""Создает клавиатуру с методами оплаты для платного триала."""
texts = get_texts(language)
keyboard = []
# Кнопка оплаты с баланса (если хватает средств)
if can_pay_from_balance:
keyboard.append([types.InlineKeyboardButton(
text="✅ Оплатить с баланса",
callback_data="trial_pay_with_balance"
)])
# Добавляем доступные методы оплаты
if settings.TELEGRAM_STARS_ENABLED:
keyboard.append([types.InlineKeyboardButton(
text="⭐ Telegram Stars",
callback_data="trial_payment_stars"
)])
if settings.is_yookassa_enabled():
yookassa_methods = []
if settings.YOOKASSA_SBP_ENABLED:
yookassa_methods.append(types.InlineKeyboardButton(
text="🏦 YooKassa (СБП)",
callback_data="trial_payment_yookassa_sbp"
))
yookassa_methods.append(types.InlineKeyboardButton(
text="💳 YooKassa (Карта)",
callback_data="trial_payment_yookassa"
))
if yookassa_methods:
keyboard.append(yookassa_methods)
if settings.is_cryptobot_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 CryptoBot",
callback_data="trial_payment_cryptobot"
)])
if settings.is_heleket_enabled():
keyboard.append([types.InlineKeyboardButton(
text="🪙 Heleket",
callback_data="trial_payment_heleket"
)])
if settings.is_mulenpay_enabled():
mulenpay_name = settings.get_mulenpay_display_name()
keyboard.append([types.InlineKeyboardButton(
text=f"💳 {mulenpay_name}",
callback_data="trial_payment_mulenpay"
)])
if settings.is_pal24_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 PayPalych",
callback_data="trial_payment_pal24"
)])
if settings.is_wata_enabled():
keyboard.append([types.InlineKeyboardButton(
text="💳 WATA",
callback_data="trial_payment_wata"
)])
# Кнопка назад
keyboard.append([types.InlineKeyboardButton(
text=texts.BACK,
callback_data="menu_trial"
)])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
async def activate_trial(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
from app.services.admin_notification_service import AdminNotificationService
from app.services.trial_activation_service import get_trial_activation_charge_amount
texts = get_texts(db_user.language)
@@ -506,29 +593,51 @@ async def activate_trial(
await callback.answer()
return
try:
preview_trial_activation_charge(db_user)
except TrialPaymentInsufficientFunds as error:
required_label = settings.format_price(error.required_amount)
balance_label = settings.format_price(error.balance_amount)
missing_label = settings.format_price(error.missing_amount)
message = texts.t(
"TRIAL_PAYMENT_INSUFFICIENT_FUNDS",
"⚠️ Недостаточно средств для активации триала.\n"
"Необходимо: {required}\nНа балансе: {balance}\n"
"Не хватает: {missing}\n\nПополните баланс и попробуйте снова.",
).format(required=required_label, balance=balance_label, missing=missing_label)
# Проверяем, платный ли триал
trial_price_kopeks = get_trial_activation_charge_amount()
if trial_price_kopeks > 0:
# Платный триал - показываем экран с выбором метода оплаты
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
can_pay_from_balance = user_balance_kopeks >= trial_price_kopeks
traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
message_lines = [
texts.t("PAID_TRIAL_HEADER", "⚡ <b>Пробная подписка</b>"),
"",
f"📅 {texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}",
f"📊 {texts.t('TRAFFIC', 'Трафик')}: {traffic_label}",
f"📱 {texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}",
"",
f"💰 {texts.t('PRICE', 'Стоимость')}: {settings.format_price(trial_price_kopeks)}",
f"💳 {texts.t('YOUR_BALANCE', 'Ваш баланс')}: {settings.format_price(user_balance_kopeks)}",
"",
]
if can_pay_from_balance:
message_lines.append(texts.t(
"PAID_TRIAL_CAN_PAY_BALANCE",
"Вы можете оплатить пробную подписку с баланса или выбрать другой способ оплаты."
))
else:
message_lines.append(texts.t(
"PAID_TRIAL_SELECT_PAYMENT",
"Выберите подходящий способ оплаты:"
))
message_text = "\n".join(message_lines)
keyboard = _get_trial_payment_keyboard(db_user.language, can_pay_from_balance)
await callback.message.edit_text(
message,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=error.required_amount,
),
message_text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
return
# Бесплатный триал - текущее поведение
charged_amount = 0
subscription: Optional[Subscription] = None
remnawave_user = None
@@ -1738,9 +1847,16 @@ async def select_period(
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
# Автоматически предвыбираем бесплатные серверы
preselected = _get_preselected_free_countries(countries)
data['countries'] = preselected
await state.set_data(data)
# Формируем текст с описаниями сквадов
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
selection_text,
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -2683,12 +2799,20 @@ async def handle_subscription_settings(
"",
)
# Формируем отображение лимита устройств с учётом модема
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled and settings.is_modem_enabled():
visible_device_limit = (subscription.device_limit or 1) - 1
devices_limit_display = f"{visible_device_limit} + модем"
else:
devices_limit_display = str(subscription.device_limit)
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
devices_used=devices_used,
devices_limit=subscription.device_limit,
devices_limit=devices_limit_display,
)
show_countries = await _should_show_countries_management(db_user)
@@ -2715,6 +2839,696 @@ async def clear_saved_cart(
await callback.answer("🗑️ Корзина очищена")
# ============== ХЕНДЛЕРЫ ПЛАТНОГО ТРИАЛА ==============
@error_handler
async def handle_trial_pay_with_balance(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обрабатывает оплату триала с баланса."""
from app.services.trial_activation_service import get_trial_activation_charge_amount
from app.services.admin_notification_service import AdminNotificationService
texts = get_texts(db_user.language)
# Проверяем права на триал
if db_user.subscription or db_user.has_had_paid_subscription:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
trial_price_kopeks = get_trial_activation_charge_amount()
if trial_price_kopeks <= 0:
await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
return
user_balance_kopeks = getattr(db_user, "balance_kopeks", 0) or 0
if user_balance_kopeks < trial_price_kopeks:
await callback.answer(
texts.t("INSUFFICIENT_BALANCE", "❌ Недостаточно средств на балансе"),
show_alert=True
)
return
# Списываем с баланса
success = await subtract_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t("TRIAL_PAYMENT_DESCRIPTION", "Оплата пробной подписки"),
)
if not success:
await callback.answer(
texts.t("PAYMENT_FAILED", "❌ Не удалось списать средства"),
show_alert=True
)
return
await db.refresh(db_user)
# Создаем триальную подписку
subscription: Optional[Subscription] = None
remnawave_user = None
try:
forced_devices = None
if not settings.is_devices_selection_enabled():
forced_devices = settings.get_disabled_mode_device_limit()
subscription = await create_trial_subscription(
db,
db_user.id,
device_limit=forced_devices,
)
await db.refresh(db_user)
subscription_service = SubscriptionService()
try:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
)
except RemnaWaveConfigurationError as error:
logger.error("RemnaWave update skipped due to configuration error: %s", error)
# Откатываем подписку и возвращаем деньги
await rollback_trial_subscription_activation(db, subscription)
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
transaction_type=TransactionType.REFUND,
)
await db.refresh(db_user)
await callback.message.edit_text(
texts.t(
"TRIAL_PROVISIONING_FAILED",
"Не удалось завершить активацию триала. Средства возвращены на баланс.",
),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
except Exception as error:
logger.error(
"Failed to create RemnaWave user for trial subscription %s: %s",
getattr(subscription, "id", "<unknown>"),
error,
)
# Откатываем подписку и возвращаем деньги
await rollback_trial_subscription_activation(db, subscription)
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
transaction_type=TransactionType.REFUND,
)
await db.refresh(db_user)
await callback.message.edit_text(
texts.t(
"TRIAL_PROVISIONING_FAILED",
"Не удалось завершить активацию триала. Средства возвращены на баланс.",
),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
# Отправляем уведомление админам
try:
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_trial_activation_notification(
db,
db_user,
subscription,
charged_amount_kopeks=trial_price_kopeks,
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о триале: {e}")
# Показываем успешное сообщение с ссылкой
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
payment_note = "\n\n" + texts.t(
"TRIAL_PAYMENT_CHARGED_NOTE",
"💳 С вашего баланса списано {amount}.",
).format(amount=settings.format_price(trial_price_kopeks))
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_HAPP_LINK_PROMPT",
"🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
)
elif hide_subscription_link:
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n"
+ texts.t(
"SUBSCRIPTION_LINK_HIDDEN_NOTICE",
"ℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе \"Моя подписка\".",
)
+ "\n\n"
+ texts.t(
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT",
"📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
)
)
else:
subscription_import_link = texts.t(
"SUBSCRIPTION_IMPORT_LINK_SECTION",
"🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
).format(subscription_url=subscription_link)
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n"
f"{subscription_import_link}\n\n"
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
)
trial_success_text += payment_note
connect_mode = settings.CONNECT_BUTTON_MODE
connect_keyboard = _build_trial_success_keyboard(texts, subscription_link, connect_mode)
await callback.message.edit_text(
trial_success_text,
reply_markup=connect_keyboard,
parse_mode="HTML",
)
else:
trial_success_text = (
f"{texts.TRIAL_ACTIVATED}\n\n⚠️ Ссылка генерируется, попробуйте перейти в раздел 'Моя подписка' через несколько секунд."
)
trial_success_text += payment_note
await callback.message.edit_text(
trial_success_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
await callback.answer()
except Exception as error:
logger.error(
"Unexpected error during paid trial activation for user %s: %s",
db_user.id,
error,
)
# Пытаемся откатить и вернуть деньги
if subscription:
await rollback_trial_subscription_activation(db, subscription)
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t("TRIAL_REFUND_DESCRIPTION", "Возврат за неудачную активацию триала"),
transaction_type=TransactionType.REFUND,
)
await db.refresh(db_user)
await callback.message.edit_text(
texts.t(
"TRIAL_ACTIVATION_ERROR",
"❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
def _build_trial_success_keyboard(texts, subscription_link: str, connect_mode: str) -> InlineKeyboardMarkup:
"""Создает клавиатуру успешной активации триала."""
if connect_mode == "miniapp_subscription":
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=subscription_link),
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
])
elif connect_mode == "miniapp_custom":
if not settings.MINIAPP_CUSTOM_URL:
return get_back_keyboard(texts.language if hasattr(texts, 'language') else 'ru')
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
web_app=types.WebAppInfo(url=settings.MINIAPP_CUSTOM_URL),
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
])
elif connect_mode == "link":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
url=subscription_link,
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append(
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
elif connect_mode == "happ_cryptolink":
rows = [
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="open_subscription_link",
)
]
]
happ_row = get_happ_download_button_row(texts)
if happ_row:
rows.append(happ_row)
rows.append(
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
]
)
return InlineKeyboardMarkup(inline_keyboard=rows)
else:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_TO_MAIN_MENU_BUTTON", "⬅️ В главное меню"),
callback_data="back_to_menu",
)
],
]
)
@error_handler
async def handle_trial_payment_method(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обрабатывает выбор метода оплаты для платного триала."""
from app.services.trial_activation_service import get_trial_activation_charge_amount
from app.services.payment_service import PaymentService
texts = get_texts(db_user.language)
# Проверяем права на триал
if db_user.subscription or db_user.has_had_paid_subscription:
await callback.message.edit_text(
texts.TRIAL_ALREADY_USED,
reply_markup=get_back_keyboard(db_user.language)
)
await callback.answer()
return
trial_price_kopeks = get_trial_activation_charge_amount()
if trial_price_kopeks <= 0:
await callback.answer("❌ Ошибка: триал бесплатный", show_alert=True)
return
# Определяем метод оплаты
payment_method = callback.data.replace("trial_payment_", "")
try:
payment_service = PaymentService(callback.bot)
# Получаем случайный сквад для триала
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
# Создаем pending триальную подписку
pending_subscription = await create_pending_trial_subscription(
db=db,
user_id=db_user.id,
duration_days=settings.TRIAL_DURATION_DAYS,
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
device_limit=settings.TRIAL_DEVICE_LIMIT,
connected_squads=[trial_squad_uuid] if trial_squad_uuid else [],
payment_method=f"trial_{payment_method}",
total_price_kopeks=trial_price_kopeks,
)
if not pending_subscription:
await callback.answer("❌ Не удалось подготовить заказ. Попробуйте позже.", show_alert=True)
return
traffic_label = "Безлимит" if settings.TRIAL_TRAFFIC_LIMIT_GB == 0 else f"{settings.TRIAL_TRAFFIC_LIMIT_GB} ГБ"
if payment_method == "stars":
# Оплата через Telegram Stars
stars_count = settings.rubles_to_stars(settings.kopeks_to_rubles(trial_price_kopeks))
await callback.bot.send_invoice(
chat_id=callback.from_user.id,
title=texts.t("PAID_TRIAL_INVOICE_TITLE", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
description=(
f"{texts.t('PERIOD', 'Период')}: {settings.TRIAL_DURATION_DAYS} {texts.t('DAYS', 'дней')}\n"
f"{texts.t('DEVICES', 'Устройства')}: {settings.TRIAL_DEVICE_LIMIT}\n"
f"{texts.t('TRAFFIC', 'Трафик')}: {traffic_label}"
),
payload=f"trial_{pending_subscription.id}",
provider_token="",
currency="XTR",
prices=[types.LabeledPrice(
label=texts.t("PAID_TRIAL_STARS_LABEL", "Пробная подписка"),
amount=stars_count
)],
)
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_STARS_WAITING",
"⭐ Для оплаты пробной подписки нажмите кнопку оплаты в сообщении выше.\n\n"
"После успешной оплаты подписка будет активирована автоматически."
),
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
elif payment_method == "yookassa_sbp":
# Оплата через YooKassa СБП
payment_result = await payment_service.create_yookassa_sbp_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("confirmation_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
qr_url = payment_result.get("qr_code_url") or payment_result.get("confirmation_url")
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_YOOKASSA_SBP",
"🏦 <b>Оплата через СБП</b>\n\n"
"Отсканируйте QR-код или перейдите по ссылке для оплаты.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=qr_url)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "yookassa":
# Оплата через YooKassa карта
payment_result = await payment_service.create_yookassa_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("confirmation_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_YOOKASSA_CARD",
"💳 <b>Оплата картой</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["confirmation_url"])],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "cryptobot":
# Оплата через CryptoBot
payment_result = await payment_service.create_cryptobot_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("pay_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_CRYPTOBOT",
"🪙 <b>Оплата криптовалютой</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_cryptobot_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "heleket":
# Оплата через Heleket
payment_result = await payment_service.create_heleket_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("pay_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_HELEKET",
"🪙 <b>Оплата криптовалютой (Heleket)</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🪙 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_heleket_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "mulenpay":
# Оплата через MulenPay
payment_result = await payment_service.create_mulenpay_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("pay_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
mulenpay_name = settings.get_mulenpay_display_name()
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_MULENPAY",
"💳 <b>Оплата через {name}</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(name=mulenpay_name, amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_mulenpay_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "pal24":
# Оплата через PAL24
payment_result = await payment_service.create_pal24_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("pay_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_PAL24",
"💳 <b>Оплата через PayPalych</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_pal24_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
elif payment_method == "wata":
# Оплата через WATA
payment_result = await payment_service.create_wata_payment(
amount_kopeks=trial_price_kopeks,
description=texts.t("PAID_TRIAL_PAYMENT_DESC", "Пробная подписка на {days} дней").format(
days=settings.TRIAL_DURATION_DAYS
),
user_id=db_user.id,
metadata={
"type": "trial",
"subscription_id": pending_subscription.id,
"user_id": db_user.id,
},
)
if not payment_result or not payment_result.get("pay_url"):
await callback.answer("❌ Не удалось создать платеж. Попробуйте позже.", show_alert=True)
return
await callback.message.edit_text(
texts.t(
"PAID_TRIAL_WATA",
"💳 <b>Оплата через WATA</b>\n\n"
"Нажмите кнопку ниже для перехода к оплате.\n\n"
"💰 Сумма: {amount}"
).format(amount=settings.format_price(trial_price_kopeks)),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💳 Оплатить", url=payment_result["pay_url"])],
[InlineKeyboardButton(
text=texts.t("CHECK_PAYMENT", "🔄 Проверить оплату"),
callback_data=f"check_trial_wata_{pending_subscription.id}"
)],
[InlineKeyboardButton(text=texts.BACK, callback_data="trial_activate")],
]),
parse_mode="HTML",
)
else:
await callback.answer(f"❌ Неизвестный метод оплаты: {payment_method}", show_alert=True)
return
await callback.answer()
except Exception as error:
logger.error(f"Error processing trial payment method {payment_method}: {error}")
await callback.answer("❌ Произошла ошибка при создании платежа. Попробуйте позже.", show_alert=True)
def register_handlers(dp: Dispatcher):
update_traffic_prices()
@@ -2733,6 +3547,17 @@ def register_handlers(dp: Dispatcher):
F.data == "trial_activate"
)
# Хендлеры платного триала
dp.callback_query.register(
handle_trial_pay_with_balance,
F.data == "trial_pay_with_balance"
)
dp.callback_query.register(
handle_trial_payment_method,
F.data.startswith("trial_payment_")
)
dp.callback_query.register(
start_subscription_purchase,
F.data.in_(["menu_buy", "subscription_upgrade", "subscription_purchase"])
@@ -3001,6 +3826,10 @@ def register_handlers(dp: Dispatcher):
F.data == "device_connection_help"
)
# Регистрируем обработчики модема
from .modem import register_modem_handlers
register_modem_handlers(dp)
# Регистрируем обработчик для простой покупки
dp.callback_query.register(
handle_simple_subscription_purchase,
+118 -14
View File
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
from .countries import (
_build_countries_selection_text,
_get_available_countries,
_get_preselected_free_countries,
_should_show_countries_management,
)
from .summary import present_subscription_summary
async def handle_add_traffic(
@@ -91,6 +96,17 @@ async def handle_add_traffic(
texts = get_texts(db_user.language)
# Проверяем, включена ли функция докупки трафика
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
if settings.is_traffic_fixed():
await callback.answer(
texts.t(
@@ -146,6 +162,43 @@ async def handle_add_traffic(
await callback.answer()
def _calculate_traffic_reset_price(subscription) -> int:
"""Рассчитывает цену сброса трафика в зависимости от настроек."""
mode = settings.get_traffic_reset_price_mode()
base_price = settings.get_traffic_reset_base_price()
# Если базовая цена не задана, используем цену периода 30 дней
if base_price == 0:
base_price = PERIOD_PRICES.get(30, 0)
if mode == "period":
# Старое поведение: фиксированная цена = стоимость периода
return base_price
elif mode == "traffic":
# Цена = стоимость текущего пакета трафика
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
return max(traffic_price, base_price)
elif mode == "traffic_with_purchased":
# Цена = стоимость базового трафика + докупленного
# Базовый трафик = текущий лимит - докупленный
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
# Получаем цену базового трафика
base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
# Получаем цену докупленного трафика
purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
total_price = base_traffic_price + purchased_traffic_price
return max(total_price, base_price)
# Fallback на базовую цену
return base_price
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -168,19 +221,40 @@ async def handle_reset_traffic(
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
return
# Формируем информацию о расчете цены
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
price_info = ""
if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
price_info = (
f"\n\n💡 <i>Расчет цены:</i>\n"
f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
f"• Докупленный: {texts.format_traffic(purchased_gb)}"
)
# Проверяем достаточно ли средств
has_enough_balance = db_user.balance_kopeks >= reset_price
missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
# Формируем текст о балансе
balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
if not has_enough_balance:
balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
await callback.message.edit_text(
f"🔄 <b>Сброс трафика</b>\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
reply_markup=get_reset_traffic_confirm_keyboard(
reset_price,
db_user.language,
has_enough_balance=has_enough_balance,
missing_kopeks=missing_kopeks,
)
)
await callback.answer()
@@ -199,7 +273,7 @@ async def confirm_reset_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
reset_price = PERIOD_PRICES[30]
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
@@ -348,9 +422,16 @@ async def select_traffic(
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
# Автоматически предвыбираем бесплатные серверы
preselected = _get_preselected_free_countries(countries)
data['countries'] = preselected
await state.set_data(data)
# Формируем текст с описаниями сквадов
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
selection_text,
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -388,7 +469,7 @@ async def add_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_price(traffic_gb)
base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
@@ -458,8 +539,13 @@ async def add_traffic(
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
# При переходе на безлимит сбрасываем докупленный трафик
subscription.purchased_traffic_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
# Записываем докупленный трафик для корректного расчета цены сброса
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = current_purchased + traffic_gb
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
@@ -535,6 +621,10 @@ async def handle_switch_traffic(
return
current_traffic = subscription.traffic_limit_gb
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic = current_traffic - purchased_traffic
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
@@ -542,18 +632,25 @@ async def handle_switch_traffic(
period_hint_days,
)
# Показываем информацию о докупленном трафике, если он есть
purchased_info = ""
if purchased_traffic > 0:
purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n Докуплено: {texts.format_traffic(purchased_traffic)}"
await callback.message.edit_text(
f"🔄 <b>Переключение лимита трафика</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 <b>Важно:</b>\n"
f"• При увеличении - доплата за разницу\n"
f"• При уменьшении - возврат средств не производится",
f"• При уменьшении - возврат средств не производится\n"
f"• Докупленный трафик будет сброшен",
reply_markup=get_traffic_switch_keyboard(
current_traffic,
db_user.language,
subscription.end_date,
traffic_discount_percent,
base_traffic_gb=base_traffic,
),
parse_mode="HTML"
)
@@ -571,11 +668,16 @@ async def confirm_switch_traffic(
current_traffic = subscription.traffic_limit_gb
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цены
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic = current_traffic - purchased_traffic
if new_traffic_gb == current_traffic:
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
return
old_price_per_month = settings.get_traffic_price(current_traffic)
# Используем базовый трафик для определения текущей цены пакета
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
@@ -692,6 +794,8 @@ async def execute_switch_traffic(
)
subscription.traffic_limit_gb = new_traffic_gb
# Сбрасываем докупленный трафик при переключении пакета
subscription.purchased_traffic_gb = 0
subscription.updated_at = datetime.utcnow()
await db.commit()
+55 -3
View File
@@ -836,7 +836,7 @@ async def handle_ticket_reply(
)
texts = get_texts(db_user.language)
await message.answer(
texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -850,9 +850,13 @@ async def handle_ticket_reply(
)]
])
)
await state.clear()
# Уведомить админов об ответе пользователя
logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}")
await notify_admins_about_ticket_reply(ticket, reply_text, db)
except Exception as e:
logger.error(f"Error adding ticket reply: {e}")
texts = get_texts(db_user.language)
@@ -1015,6 +1019,54 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
logger.error(f"Error notifying admins about new ticket: {e}")
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
"""Уведомить админов об ответе пользователя на тикет"""
logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}")
try:
from app.config import settings
if not settings.is_admin_notifications_enabled():
logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}")
return
title = (ticket.title or '').strip()
if len(title) > 60:
title = title[:57] + "..."
# Загрузим пользователя
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else "Unknown"
telegram_id_display = user.telegram_id if user else ""
username_display = (user.username or "отсутствует") if user else "отсутствует"
# Обрезаем текст ответа для уведомления
reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text
notification_text = (
f"💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n"
f"🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n\n"
f"📩 <b>Сообщение:</b>\n{reply_preview}\n"
)
from app.services.maintenance_service import maintenance_service
bot = maintenance_service._bot or None
if bot is None:
logger.warning("Bot instance is not available for admin notifications")
return
service = AdminNotificationService(bot)
result = await service.send_ticket_event_notification(notification_text, None)
logger.info(f"Ticket #{ticket.id} reply notification sent: {result}")
except Exception as e:
logger.error(f"Error notifying admins about ticket reply: {e}")
def register_handlers(dp: Dispatcher):
"""Регистрация обработчиков тикетов"""
+36 -9
View File
@@ -510,6 +510,7 @@ def get_main_menu_keyboard(
# Добавляем кнопку докупки трафика для лимитированных подписок
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
and settings.is_traffic_topup_enabled()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
@@ -1699,7 +1700,7 @@ def get_add_traffic_keyboard(
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
packages = settings.get_traffic_packages()
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
if not enabled_packages:
@@ -1855,27 +1856,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
])
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_reset_traffic_confirm_keyboard(
price_kopeks: int,
language: str = DEFAULT_LANGUAGE,
has_enough_balance: bool = True,
missing_kopeks: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
return get_back_keyboard(language)
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
buttons = []
if has_enough_balance:
# Достаточно средств - показываем кнопку сброса
buttons.append([
InlineKeyboardButton(
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
callback_data="confirm_reset_traffic"
)
],
[
])
else:
# Не хватает средств - показываем кнопку пополнения
buttons.append([
InlineKeyboardButton(
text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
callback_data="menu_subscription",
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
callback_data=f"topup_amount_{missing_kopeks}"
)
]
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_settings",
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_manage_countries_keyboard(
countries: List[dict],
@@ -2386,6 +2405,14 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
)
])
if settings.is_modem_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("MODEM_BUTTON", "📡 Модем"),
callback_data="subscription_modem"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
+33 -1
View File
@@ -1520,6 +1520,7 @@
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
@@ -1619,5 +1620,36 @@
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
"CONTEST_START_ANNOUNCEMENT": "Game starts",
"CONTEST_PRIZE": "Prize",
"DAYS": "days subscription",
"CONTEST_WINNERS": "Winners",
"CONTEST_ATTEMPTS": "Attempts/user",
"CONTEST_ELIGIBILITY": "Only users with active or trial subscription can participate",
"REMINDER": "Reminder",
"CONTEST_REMINDER_TEXT": "Don't forget to participate in contests to get bonuses",
"MODEM_BUTTON": "📡 Modem",
"MODEM_ENABLE_BUTTON": "✅ Connect modem",
"MODEM_DISABLE_BUTTON": "❌ Disconnect modem",
"MODEM_CONFIRM_BUTTON": "✅ Confirm connection",
"MODEM_STATUS_ENABLED": "✅ Connected",
"MODEM_STATUS_DISABLED": "❌ Not connected",
"MODEM_PAID_ONLY": "⚠️ Modem is only available for paid subscriptions",
"MODEM_DISABLED": "⚠️ Modem feature is disabled",
"MODEM_ALREADY_ENABLED": "️ Modem is already connected",
"MODEM_NOT_ENABLED": "️ Modem is not connected",
"MODEM_INFO_ENABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nModem is connected to your subscription.\nMonthly fee: {price}\n\nNo refunds are provided when disconnecting the modem.",
"MODEM_INFO_DISABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nConnect a modem to your subscription.\nMonthly fee: {price}\n\nWhen connecting a modem, an additional device will be added.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Insufficient funds</b>\n\nModem connection cost: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Confirm modem connection</b>\n\nCost: {price} (for {months} months)\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Modem successfully connected!</b>\n\n📡 Modem activated\n📱 Device added for modem\n",
"MODEM_CHARGED": "💰 Charged: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Modem disconnected</b>\n\n📡 Modem deactivated\n️ No refunds are provided",
"MODEM_PERIOD_INFO": "\n📅 Modem valid until: <b>{end_date}</b> ({days} days)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Warning!</b> Only <b>{days} days</b> left until subscription ends.\nAfter renewal, modem will need to be paid again!",
"MODEM_PERIOD_NOTE": "\n️ Days until subscription ends: <b>{days}</b>\nAfter renewal, modem will need to be paid again.",
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?"
}
+33 -1
View File
@@ -1535,6 +1535,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
@@ -1634,5 +1635,36 @@
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
"CONTEST_PRIZE": "Приз",
"DAYS": "дн. подписки",
"CONTEST_WINNERS": "Победителей",
"CONTEST_ATTEMPTS": "Попыток/польз",
"CONTEST_ELIGIBILITY": "Участвовать могут только с активной или триальной подпиской",
"REMINDER": "Напоминание",
"CONTEST_REMINDER_TEXT": "Не забудьте участвовать в конкурсах для получения бонусов",
"MODEM_BUTTON": "📡 Модем",
"MODEM_ENABLE_BUTTON": "✅ Подключить модем",
"MODEM_DISABLE_BUTTON": "❌ Отключить модем",
"MODEM_CONFIRM_BUTTON": "✅ Подтвердить подключение",
"MODEM_STATUS_ENABLED": "✅ Подключен",
"MODEM_STATUS_DISABLED": "❌ Не подключен",
"MODEM_PAID_ONLY": "⚠️ Модем доступен только для платных подписок",
"MODEM_DISABLED": "⚠️ Функция модема отключена",
"MODEM_ALREADY_ENABLED": "ℹ️ Модем уже подключен",
"MODEM_NOT_ENABLED": "ℹ️ Модем не подключен",
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем подключен к вашей подписке.\nЕжемесячная плата: {price}\n\nПри отключении модема возврат средств не производится.",
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПодключите модем к вашей подписке.\nЕжемесячная плата: {price}\n\nПри подключении модема будет добавлено дополнительное устройство.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость подключения модема: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Подтверждение подключения модема</b>\n\nСтоимость: {price} (за {months} мес)\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успешно подключен!</b>\n\n📡 Модем активирован\n📱 Добавлено устройство для модема\n",
"MODEM_CHARGED": "💰 Списано: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем отключен</b>\n\n📡 Модем деактивирован\nℹ️ Возврат средств не производится",
"MODEM_PERIOD_INFO": "\n📅 Модем действует до: <b>{end_date}</b> ({days} дн.)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\nПосле продления подписки модем нужно будет оплатить заново!",
"MODEM_PERIOD_NOTE": "\nℹ️ До окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.",
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?"
}
+25 -1
View File
@@ -1454,6 +1454,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
@@ -1551,5 +1552,28 @@
"POLL_EMPTY": "Опитування поки недоступне.",
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс.",
"MODEM_BUTTON": "📡 Модем",
"MODEM_ENABLE_BUTTON": "✅ Підключити модем",
"MODEM_DISABLE_BUTTON": "❌ Відключити модем",
"MODEM_CONFIRM_BUTTON": "✅ Підтвердити підключення",
"MODEM_STATUS_ENABLED": "✅ Підключено",
"MODEM_STATUS_DISABLED": "❌ Не підключено",
"MODEM_PAID_ONLY": "⚠️ Модем доступний тільки для платних підписок",
"MODEM_DISABLED": "⚠️ Функція модема вимкнена",
"MODEM_ALREADY_ENABLED": "ℹ️ Модем вже підключено",
"MODEM_NOT_ENABLED": "ℹ️ Модем не підключено",
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем підключено до вашої підписки.\nЩомісячна плата: {price}\n\nПри відключенні модема повернення коштів не здійснюється.",
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПідключіть модем до вашої підписки.\nЩомісячна плата: {price}\n\nПри підключенні модема буде додано додатковий пристрій.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостатньо коштів</b>\n\nВартість підключення модема: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Підтвердження підключення модема</b>\n\nВартість: {price} (за {months} міс)\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успішно підключено!</b>\n\n📡 Модем активовано\n📱 Додано пристрій для модема\n",
"MODEM_CHARGED": "💰 Списано: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем відключено</b>\n\n📡 Модем деактивовано\nℹ️ Повернення коштів не здійснюється",
"MODEM_PERIOD_INFO": "\n📅 Модем діє до: <b>{end_date}</b> ({days} дн.)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Увага!</b> До закінчення підписки залишилось лише <b>{days} дн.</b>\nПісля продовження підписки модем потрібно буде оплатити знову!",
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
}
+26 -1
View File
@@ -1452,6 +1452,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1779,6 +1780,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1878,5 +1880,28 @@
"POLL_COMPLETED":"🙏感谢您参与民意调查!",
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
"DEVICE_GUIDE_WINDOWS":"💻Windows",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前",
"MODEM_BUTTON":"📡 调制解调器",
"MODEM_ENABLE_BUTTON":"✅ 连接调制解调器",
"MODEM_DISABLE_BUTTON":"❌ 断开调制解调器",
"MODEM_CONFIRM_BUTTON":"✅ 确认连接",
"MODEM_STATUS_ENABLED":"✅ 已连接",
"MODEM_STATUS_DISABLED":"❌ 未连接",
"MODEM_PAID_ONLY":"⚠️ 调制解调器仅适用于付费订阅",
"MODEM_DISABLED":"⚠️ 调制解调器功能已禁用",
"MODEM_ALREADY_ENABLED":"️ 调制解调器已连接",
"MODEM_NOT_ENABLED":"️ 调制解调器未连接",
"MODEM_INFO_ENABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n调制解调器已连接到您的订阅。\n月费:{price}\n\n断开调制解调器时不提供退款。",
"MODEM_INFO_DISABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n将调制解调器连接到您的订阅。\n月费:{price}\n\n连接调制解调器时将添加额外设备。",
"MODEM_INSUFFICIENT_FUNDS":"⚠️ <b>余额不足</b>\n\n调制解调器连接费用:{required}\n余额:{balance}\n差额:{missing}\n\n请选择充值方式。",
"MODEM_CONFIRM_ENABLE":"📡 <b>确认连接调制解调器</b>\n\n费用:{price}{months}个月)\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
"MODEM_ENABLED_SUCCESS":"✅ <b>调制解调器连接成功!</b>\n\n📡 调制解调器已激活\n📱 已添加调制解调器设备\n",
"MODEM_CHARGED":"💰 已扣款:{amount}",
"MODEM_DISABLED_SUCCESS":"✅ <b>调制解调器已断开</b>\n\n📡 调制解调器已停用\nℹ️ 不提供退款",
"MODEM_PERIOD_INFO":"\n📅 调制解调器有效期至:<b>{end_date}</b>{days}天)",
"MODEM_SHORT_PERIOD_WARNING":"\n⚠️ <b>注意!</b>距离订阅结束仅剩<b>{days}天</b>。\n续订后需重新支付调制解调器费用!",
"MODEM_PERIOD_NOTE":"\n️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>{months}个月)\n🎁 折扣{discount}%-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}{months}个月)",
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
}
+19 -9
View File
@@ -108,8 +108,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
await self._deactivate_trial_subscription(telegram_id)
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._deactivate_subscription_on_unsubscribe(telegram_id)
await self._capture_start_payload(state, event, bot)
@@ -253,8 +253,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
finally:
break
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
telegram_id,
@@ -272,19 +272,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
subscription = user.subscription
if (not subscription.is_trial or
subscription.status != SubscriptionStatus.ACTIVE.value):
if subscription.status != SubscriptionStatus.ACTIVE.value:
logger.debug(
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
"ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
telegram_id,
subscription.is_trial,
subscription.status,
)
break
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
logger.debug(
"ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
telegram_id,
)
break
await deactivate_subscription(db, subscription)
sub_type = "Триальная" if subscription.is_trial else "Платная"
logger.info(
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
"🚫 %s подписка пользователя %s отключена после отписки от канала",
sub_type,
telegram_id,
)
@@ -1510,6 +1510,7 @@ class AdminNotificationService:
except Exception:
runtime_enabled = True
if not (self._is_enabled() and runtime_enabled):
logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}")
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
+3
View File
@@ -147,6 +147,9 @@ class AdvertisingCampaignService:
duration_days=duration_days,
traffic_limit_gb=traffic_limit or 0,
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=True,
squad_uuid=squad_uuid,
)
+51 -52
View File
@@ -1,6 +1,5 @@
import asyncio
import logging
import random
from datetime import datetime, timedelta, time, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
@@ -18,17 +17,19 @@ from app.database.crud.contest import (
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestTemplate, SubscriptionStatus, User
from app.services.contests.enums import GameType, PrizeType, RoundStatus
from app.services.contests.games import get_game_strategy
logger = logging.getLogger(__name__)
# Slugs for games
GAME_QUEST = "quest_buttons"
GAME_LOCKS = "lock_hack"
GAME_CIPHER = "letter_cipher"
GAME_SERVER = "server_lottery"
GAME_BLITZ = "blitz_reaction"
GAME_EMOJI = "emoji_guess"
GAME_ANAGRAM = "anagram"
# Legacy aliases for backward compatibility
GAME_QUEST = GameType.QUEST_BUTTONS.value
GAME_LOCKS = GameType.LOCK_HACK.value
GAME_CIPHER = GameType.LETTER_CIPHER.value
GAME_SERVER = GameType.SERVER_LOTTERY.value
GAME_BLITZ = GameType.BLITZ_REACTION.value
GAME_EMOJI = GameType.EMOJI_GUESS.value
GAME_ANAGRAM = GameType.ANAGRAM.value
DEFAULT_TEMPLATES = [
@@ -36,7 +37,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_QUEST,
"name": "Квест-кнопки",
"description": "Найди секретную кнопку 3×3",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 3,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -48,7 +50,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_LOCKS,
"name": "Кнопочный взлом",
"description": "Найди взломанную кнопку среди 20 замков",
"prize_days": 5,
"prize_type": "days",
"prize_value": "5",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -60,7 +63,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_CIPHER,
"name": "Шифр букв",
"description": "Расшифруй слово по номерам",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -72,7 +76,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_SERVER,
"name": "Сервер-лотерея",
"description": "Угадай доступный сервер",
"prize_days": 7,
"prize_type": "days",
"prize_value": "7",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -84,7 +89,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_BLITZ,
"name": "Блиц-реакция",
"description": "Нажми кнопку за 10 секунд",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -96,7 +102,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_EMOJI,
"name": "Угадай сервис по эмодзи",
"description": "Определи сервис по эмодзи",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -108,7 +115,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_ANAGRAM,
"name": "Анаграмма дня",
"description": "Собери слово из букв",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -239,38 +247,12 @@ class ContestRotationService:
return ZoneInfo("UTC")
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
payload = tpl.payload or {}
if tpl.slug == GAME_QUEST:
rows = payload.get("rows", 3)
cols = payload.get("cols", 3)
total = rows * cols
secret_idx = random.randint(0, total - 1)
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
if tpl.slug == GAME_LOCKS:
total = payload.get("buttons", 20)
secret_idx = random.randint(0, max(0, total - 1))
return {"total": total, "secret_idx": secret_idx}
if tpl.slug == GAME_CIPHER:
words = payload.get("words") or ["VPN"]
word = random.choice(words)
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
return {"question": "-".join(codes), "answer": word.upper()}
if tpl.slug == GAME_SERVER:
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
secret_idx = random.randint(0, len(flags) - 1)
return {"flags": flags, "secret_idx": secret_idx}
if tpl.slug == GAME_BLITZ:
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
if tpl.slug == GAME_EMOJI:
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
pair = random.choice(pairs)
return pair
if tpl.slug == GAME_ANAGRAM:
words = payload.get("words") or ["SERVER"]
word = random.choice(words).upper()
shuffled = "".join(random.sample(word, len(word)))
return {"letters": shuffled, "answer": word}
return payload
"""Build round-specific payload using game strategy."""
strategy = get_game_strategy(tpl.slug)
if strategy:
return strategy.build_payload(tpl.payload or {})
# Fallback for unknown game types
return tpl.payload or {}
async def _announce_round_start(
self,
@@ -281,11 +263,28 @@ class ContestRotationService:
if not self.bot:
return
from app.localization.texts import get_texts
texts = get_texts("ru") # Default to ru for announcements
# Format prize display based on prize_type
prize_type = tpl.prize_type or PrizeType.DAYS.value
prize_value = tpl.prize_value or "1"
if prize_type == PrizeType.DAYS.value:
prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
elif prize_type == PrizeType.BALANCE.value:
prize_display = f"{prize_value} коп."
elif prize_type == PrizeType.CUSTOM.value:
prize_display = prize_value
else:
prize_display = prize_value
text = (
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
"Участвовать могут только с активной или триальной подпиской."
f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: <b>{tpl.name}</b>\n"
f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
f"💡 <b>{texts.t('REMINDER', 'Напоминание')}:</b> {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
)
await asyncio.gather(
+14
View File
@@ -0,0 +1,14 @@
"""Contest services module."""
from app.services.contests.enums import GameType, RoundStatus, PrizeType
from app.services.contests.games import get_game_strategy, BaseGameStrategy
from app.services.contests.attempt_service import ContestAttemptService
__all__ = [
"GameType",
"RoundStatus",
"PrizeType",
"get_game_strategy",
"BaseGameStrategy",
"ContestAttemptService",
]
+316
View File
@@ -0,0 +1,316 @@
"""Service for atomic contest attempt operations."""
import logging
from dataclasses import dataclass
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import create_attempt, get_attempt, update_attempt
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
from app.database.crud.user import get_user_by_id
from app.database.models import ContestAttempt, ContestRound, ContestTemplate
from app.services.contests.enums import PrizeType
from app.services.contests.games import get_game_strategy
logger = logging.getLogger(__name__)
@dataclass
class AttemptResult:
"""Result of processing a contest attempt."""
success: bool
is_winner: bool
message: str
already_played: bool = False
round_finished: bool = False
class ContestAttemptService:
"""Service for processing contest attempts with atomic operations."""
async def process_button_attempt(
self,
db: AsyncSession,
round_obj: ContestRound,
user_id: int,
pick: str,
language: str,
) -> AttemptResult:
"""
Process a button-based game attempt atomically.
Args:
db: Database session
round_obj: Contest round
user_id: User ID
pick: User's pick (button callback data)
language: User's language
Returns:
AttemptResult with outcome details
"""
tpl = round_obj.template
if not tpl:
return AttemptResult(
success=False,
is_winner=False,
message="Конкурс не найден",
)
# Check if user already played
existing_attempt = await get_attempt(db, round_obj.id, user_id)
if existing_attempt:
return AttemptResult(
success=False,
is_winner=False,
message="У вас уже была попытка",
already_played=True,
)
# Get game strategy and check answer
strategy = get_game_strategy(tpl.slug)
if not strategy:
return AttemptResult(
success=False,
is_winner=False,
message="Тип игры не поддерживается",
)
check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
is_winner = check_result.is_correct
# Atomic winner check with row lock
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
# Create attempt record
await create_attempt(
db,
round_id=round_obj.id,
user_id=user_id,
answer=str(pick),
is_winner=is_winner,
)
logger.info(
"Contest attempt: user %s, round %s, pick '%s', winner %s",
user_id, round_obj.id, pick, is_winner
)
if is_winner:
prize_msg = await self._award_prize(db, user_id, tpl, language)
return AttemptResult(
success=True,
is_winner=True,
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
)
return AttemptResult(
success=True,
is_winner=False,
message=check_result.response_text or "Неудача",
)
async def process_text_attempt(
self,
db: AsyncSession,
round_obj: ContestRound,
user_id: int,
text_answer: str,
language: str,
) -> AttemptResult:
"""
Process a text-input game attempt atomically.
Args:
db: Database session
round_obj: Contest round
user_id: User ID
text_answer: User's text answer
language: User's language
Returns:
AttemptResult with outcome details
"""
tpl = round_obj.template
if not tpl:
return AttemptResult(
success=False,
is_winner=False,
message="Конкурс не найден",
)
# For text games, attempt should already exist (created in render phase)
attempt = await get_attempt(db, round_obj.id, user_id)
if not attempt:
return AttemptResult(
success=False,
is_winner=False,
message="Сначала начните игру",
)
# Check if already answered
if attempt.answer is not None:
return AttemptResult(
success=False,
is_winner=False,
message="У вас уже была попытка",
already_played=True,
)
# Get game strategy and check answer
strategy = get_game_strategy(tpl.slug)
if not strategy:
return AttemptResult(
success=False,
is_winner=False,
message="Тип игры не поддерживается",
)
check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
is_winner = check_result.is_correct
# Atomic winner check with row lock
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
# Update attempt with answer
await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
logger.info(
"Contest text attempt: user %s, round %s, answer '%s', winner %s",
user_id, round_obj.id, text_answer, is_winner
)
if is_winner:
prize_msg = await self._award_prize(db, user_id, tpl, language)
return AttemptResult(
success=True,
is_winner=True,
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
)
return AttemptResult(
success=True,
is_winner=False,
message=check_result.response_text or "Неверно, попробуй в следующем раунде",
)
async def create_pending_attempt(
self,
db: AsyncSession,
round_id: int,
user_id: int,
) -> Optional[ContestAttempt]:
"""
Create a pending attempt for text-input games.
This blocks re-entry while user is answering.
Args:
db: Database session
round_id: Round ID
user_id: User ID
Returns:
Created attempt or None if already exists
"""
existing = await get_attempt(db, round_id, user_id)
if existing:
return None
return await create_attempt(
db,
round_id=round_id,
user_id=user_id,
answer=None,
is_winner=False,
)
async def _atomic_winner_check(
self,
db: AsyncSession,
round_id: int,
is_winner: bool,
) -> bool:
"""
Atomically check and increment winner count.
Uses SELECT FOR UPDATE to prevent race conditions.
Args:
db: Database session
round_id: Round ID
is_winner: Whether user answered correctly
Returns:
True if user is a winner, False if max winners reached
"""
if not is_winner:
return False
stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
result = await db.execute(stmt)
round_obj = result.scalar_one()
if round_obj.winners_count >= round_obj.max_winners:
return False
round_obj.winners_count += 1
await db.commit()
return True
async def _award_prize(
self,
db: AsyncSession,
user_id: int,
template: ContestTemplate,
language: str,
) -> str:
"""
Award prize to winner.
Args:
db: Database session
user_id: Winner user ID
template: Contest template with prize info
language: User's language
Returns:
Prize notification message
"""
from app.localization.texts import get_texts
texts = get_texts(language)
prize_type = template.prize_type or PrizeType.DAYS.value
prize_value = template.prize_value or "1"
if prize_type == PrizeType.DAYS.value:
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
days = int(prize_value) if prize_value.isdigit() else 1
await extend_subscription(db, subscription, days)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
elif prize_type == PrizeType.BALANCE.value:
user = await get_user_by_id(db, user_id)
if not user:
return ""
kopeks = int(prize_value) if prize_value.isdigit() else 0
if kopeks > 0:
user.balance_kopeks += kopeks
await db.commit()
return texts.t(
"CONTEST_BALANCE_GRANTED",
"Бонус {amount} зачислен!"
).format(amount=settings.format_price(kopeks))
elif prize_type == PrizeType.CUSTOM.value:
return f"🎁 {prize_value}"
return ""
# Singleton instance
contest_attempt_service = ContestAttemptService()
+45
View File
@@ -0,0 +1,45 @@
"""Enum classes for contest system."""
from enum import Enum
class GameType(str, Enum):
"""Types of daily contest games."""
QUEST_BUTTONS = "quest_buttons"
LOCK_HACK = "lock_hack"
LETTER_CIPHER = "letter_cipher"
SERVER_LOTTERY = "server_lottery"
BLITZ_REACTION = "blitz_reaction"
EMOJI_GUESS = "emoji_guess"
ANAGRAM = "anagram"
@classmethod
def is_text_input(cls, game_type: "GameType") -> bool:
"""Check if game requires text input from user."""
return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM}
@classmethod
def is_button_pick(cls, game_type: "GameType") -> bool:
"""Check if game uses button selection."""
return game_type in {
cls.QUEST_BUTTONS,
cls.LOCK_HACK,
cls.SERVER_LOTTERY,
cls.BLITZ_REACTION,
}
class RoundStatus(str, Enum):
"""Contest round status."""
ACTIVE = "active"
FINISHED = "finished"
class PrizeType(str, Enum):
"""Types of prizes for contests."""
DAYS = "days"
BALANCE = "balance"
CUSTOM = "custom"
+473
View File
@@ -0,0 +1,473 @@
"""Game strategies for different contest types."""
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from aiogram import types
from app.services.contests.enums import GameType
@dataclass
class GameRenderResult:
"""Result of rendering a game."""
text: str
keyboard: types.InlineKeyboardMarkup
requires_text_input: bool = False
@dataclass
class AnswerCheckResult:
"""Result of checking user's answer."""
is_correct: bool
response_text: str
class BaseGameStrategy(ABC):
"""Base class for game strategies."""
game_type: GameType
@abstractmethod
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Build round-specific payload from template config."""
pass
@abstractmethod
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
"""Render game UI for user."""
pass
@abstractmethod
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
"""Check if user's answer is correct."""
pass
def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
from app.localization.texts import get_texts
texts = get_texts(language)
return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
def _get_texts(self, language: str):
from app.localization.texts import get_texts
return get_texts(language)
class QuestButtonsStrategy(BaseGameStrategy):
"""3x3 grid game - find the secret button."""
game_type = GameType.QUEST_BUTTONS
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
rows = template_payload.get("rows", 3)
cols = template_payload.get("cols", 3)
total = rows * cols
secret_idx = random.randint(0, total - 1)
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
rows = payload.get("rows", 3)
cols = payload.get("cols", 3)
keyboard_rows = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_id}_quest_{idx}",
)
)
keyboard_rows.append(row_buttons)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
try:
if user_answer.startswith("quest_"):
idx = int(user_answer.split("_")[1])
is_correct = secret_idx is not None and idx == secret_idx
else:
is_correct = False
except (ValueError, IndexError):
is_correct = False
responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class LockHackStrategy(BaseGameStrategy):
"""20 locks game - find the hacked one."""
game_type = GameType.LOCK_HACK
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
total = template_payload.get("buttons", 20)
secret_idx = random.randint(0, max(0, total - 1))
return {"total": total, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
total = payload.get("total", 20)
keyboard_rows = []
row = []
for i in range(total):
row.append(
types.InlineKeyboardButton(
text="🔒",
callback_data=f"contest_pick_{round_id}_locks_{i}",
)
)
if len(row) == 5:
keyboard_rows.append(row)
row = []
if row:
keyboard_rows.append(row)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
try:
if user_answer.startswith("locks_"):
idx = int(user_answer.split("_")[1])
is_correct = secret_idx is not None and idx == secret_idx
else:
is_correct = False
except (ValueError, IndexError):
is_correct = False
responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class ServerLotteryStrategy(BaseGameStrategy):
"""Flag lottery game - pick the correct server flag."""
game_type = GameType.SERVER_LOTTERY
DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
flags = template_payload.get("flags") or self.DEFAULT_FLAGS
secret_idx = random.randint(0, len(flags) - 1)
return {"flags": flags, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
flags = payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard_rows = []
row = []
for flag in shuffled_flags:
row.append(
types.InlineKeyboardButton(
text=flag,
callback_data=f"contest_pick_{round_id}_{flag}",
)
)
if len(row) == 5:
keyboard_rows.append(row)
row = []
if row:
keyboard_rows.append(row)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
flags = payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_correct = user_answer == correct_flag
responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class BlitzReactionStrategy(BaseGameStrategy):
"""Blitz reaction game - press button quickly."""
game_type = GameType.BLITZ_REACTION
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
callback_data=f"contest_pick_{round_id}_blitz",
)
],
[self._get_back_button(language, back_callback)],
]
)
return GameRenderResult(
text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
keyboard=keyboard,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
is_correct = user_answer == "blitz"
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Время вышло",
)
class LetterCipherStrategy(BaseGameStrategy):
"""Letter cipher game - decode word from letter codes."""
game_type = GameType.LETTER_CIPHER
DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
words = template_payload.get("words") or self.DEFAULT_WORDS
word = random.choice(words)
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
return {"question": "-".join(codes), "answer": word.upper()}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
question = payload.get("question", "")
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
class EmojiGuessStrategy(BaseGameStrategy):
"""Emoji guess game - guess service by emoji."""
game_type = GameType.EMOJI_GUESS
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
pair = random.choice(pairs)
return pair
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
question = payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
q=shuffled_question
),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
class AnagramStrategy(BaseGameStrategy):
"""Anagram game - unscramble letters to form a word."""
game_type = GameType.ANAGRAM
DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
words = template_payload.get("words") or self.DEFAULT_WORDS
word = random.choice(words).upper()
shuffled = "".join(random.sample(word, len(word)))
return {"letters": shuffled, "answer": word}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
letters = payload.get("letters", "")
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
letters=letters
),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
# Registry of game strategies
_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
GameType.LOCK_HACK: LockHackStrategy(),
GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
GameType.BLITZ_REACTION: BlitzReactionStrategy(),
GameType.LETTER_CIPHER: LetterCipherStrategy(),
GameType.EMOJI_GUESS: EmojiGuessStrategy(),
GameType.ANAGRAM: AnagramStrategy(),
}
def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
"""Get game strategy by type."""
if isinstance(game_type, str):
try:
game_type = GameType(game_type)
except ValueError:
return None
return _GAME_STRATEGIES.get(game_type)
def get_all_game_types() -> List[GameType]:
"""Get list of all supported game types."""
return list(_GAME_STRATEGIES.keys())
+1 -1
View File
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
"action": "buy_traffic",
"enabled": True,
"visibility": "subscribers",
"conditions": {"has_traffic_limit": True},
"conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
"dynamic_text": False,
},
"balance": {
+5
View File
@@ -715,6 +715,11 @@ class MenuLayoutService:
if is_trial or traffic_limit <= 0:
return False
# traffic_topup_enabled - функция докупки трафика включена
if conditions.get("traffic_topup_enabled") is True:
if not settings.is_traffic_topup_enabled():
return False
# is_admin
if conditions.get("is_admin") is True:
if not context.is_admin:
+392
View File
@@ -0,0 +1,392 @@
"""
Сервис для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, TransactionType
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
logger = logging.getLogger(__name__)
class ModemError(Enum):
"""Типы ошибок при работе с модемом."""
NO_SUBSCRIPTION = "no_subscription"
TRIAL_SUBSCRIPTION = "trial_subscription"
MODEM_DISABLED = "modem_disabled"
ALREADY_ENABLED = "already_enabled"
NOT_ENABLED = "not_enabled"
INSUFFICIENT_FUNDS = "insufficient_funds"
CHARGE_ERROR = "charge_error"
UPDATE_ERROR = "update_error"
@dataclass
class ModemAvailabilityResult:
"""Результат проверки доступности модема."""
available: bool
error: Optional[ModemError] = None
modem_enabled: bool = False
@dataclass
class ModemPriceResult:
"""Результат расчёта цены модема."""
base_price: int
final_price: int
discount_percent: int
discount_amount: int
charged_months: int
remaining_days: int
end_date: datetime
@property
def has_discount(self) -> bool:
return self.discount_percent > 0
@dataclass
class ModemEnableResult:
"""Результат подключения модема."""
success: bool
error: Optional[ModemError] = None
charged_amount: int = 0
new_device_limit: int = 0
@dataclass
class ModemDisableResult:
"""Результат отключения модема."""
success: bool
error: Optional[ModemError] = None
new_device_limit: int = 0
# Константы для предупреждений о сроке действия
MODEM_WARNING_DAYS_CRITICAL = 7
MODEM_WARNING_DAYS_INFO = 30
class ModemService:
"""
Сервис для управления модемом в подписке.
Инкапсулирует всю бизнес-логику:
- Проверки доступности
- Расчёт цен и скидок
- Подключение/отключение модема
- Синхронизация с RemnaWave
"""
def __init__(self):
self._subscription_service = SubscriptionService()
@staticmethod
def is_modem_feature_enabled() -> bool:
"""Проверяет, включена ли функция модема в настройках."""
return settings.is_modem_enabled()
@staticmethod
def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
"""Безопасно получает статус модема из подписки."""
if subscription is None:
return False
return getattr(subscription, 'modem_enabled', False) or False
def check_availability(
self,
user: User,
for_enable: bool = False,
for_disable: bool = False
) -> ModemAvailabilityResult:
"""
Проверяет доступность модема для пользователя.
Args:
user: Пользователь
for_enable: Проверка для подключения (модем должен быть отключен)
for_disable: Проверка для отключения (модем должен быть включен)
Returns:
ModemAvailabilityResult с результатом проверки
"""
subscription = user.subscription
modem_enabled = self.get_modem_enabled(subscription)
if not subscription:
return ModemAvailabilityResult(
available=False,
error=ModemError.NO_SUBSCRIPTION,
modem_enabled=modem_enabled
)
if subscription.is_trial:
return ModemAvailabilityResult(
available=False,
error=ModemError.TRIAL_SUBSCRIPTION,
modem_enabled=modem_enabled
)
if not self.is_modem_feature_enabled():
return ModemAvailabilityResult(
available=False,
error=ModemError.MODEM_DISABLED,
modem_enabled=modem_enabled
)
if for_enable and modem_enabled:
return ModemAvailabilityResult(
available=False,
error=ModemError.ALREADY_ENABLED,
modem_enabled=modem_enabled
)
if for_disable and not modem_enabled:
return ModemAvailabilityResult(
available=False,
error=ModemError.NOT_ENABLED,
modem_enabled=modem_enabled
)
return ModemAvailabilityResult(
available=True,
modem_enabled=modem_enabled
)
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
"""
Рассчитывает стоимость подключения модема.
Использует пропорциональную цену на основе оставшегося времени подписки
и применяет скидки в зависимости от периода.
Args:
subscription: Подписка пользователя
Returns:
ModemPriceResult с детализацией цены
"""
modem_price_per_month = settings.get_modem_price_per_month()
base_price, charged_months = calculate_prorated_price(
modem_price_per_month,
subscription.end_date,
)
now = datetime.utcnow()
remaining_days = max(0, (subscription.end_date - now).days)
discount_percent = settings.get_modem_period_discount(charged_months)
if discount_percent > 0:
discount_amount = base_price * discount_percent // 100
final_price = base_price - discount_amount
else:
discount_amount = 0
final_price = base_price
return ModemPriceResult(
base_price=base_price,
final_price=final_price,
discount_percent=discount_percent,
discount_amount=discount_amount,
charged_months=charged_months,
remaining_days=remaining_days,
end_date=subscription.end_date
)
def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
"""
Проверяет достаточность баланса.
Args:
user: Пользователь
price: Требуемая сумма
Returns:
Tuple[достаточно ли средств, недостающая сумма]
"""
if price <= 0:
return True, 0
if user.balance_kopeks >= price:
return True, 0
missing = price - user.balance_kopeks
return False, missing
async def enable_modem(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> ModemEnableResult:
"""
Подключает модем к подписке.
Выполняет:
1. Расчёт цены
2. Проверку баланса
3. Списание средств
4. Создание транзакции
5. Обновление подписки
6. Синхронизацию с RemnaWave
Args:
db: Сессия базы данных
user: Пользователь
subscription: Подписка
Returns:
ModemEnableResult с результатом операции
"""
price_info = self.calculate_price(subscription)
price = price_info.final_price
has_funds, _ = self.check_balance(user, price)
if not has_funds:
return ModemEnableResult(
success=False,
error=ModemError.INSUFFICIENT_FUNDS
)
try:
if price > 0:
success = await subtract_user_balance(
db, user, price,
"Подключение модема"
)
if not success:
return ModemEnableResult(
success=False,
error=ModemError.CHARGE_ERROR
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Подключение модема на {price_info.charged_months} мес"
)
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
subscription.updated_at = datetime.utcnow()
await db.commit()
await self._subscription_service.update_remnawave_user(db, subscription)
await db.refresh(user)
await db.refresh(subscription)
logger.info(
f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}"
)
return ModemEnableResult(
success=True,
charged_amount=price,
new_device_limit=subscription.device_limit
)
except Exception as e:
logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
await db.rollback()
return ModemEnableResult(
success=False,
error=ModemError.UPDATE_ERROR
)
async def disable_modem(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> ModemDisableResult:
"""
Отключает модем от подписки.
Возврат средств не производится.
Args:
db: Сессия базы данных
user: Пользователь
subscription: Подписка
Returns:
ModemDisableResult с результатом операции
"""
try:
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
subscription.updated_at = datetime.utcnow()
await db.commit()
await self._subscription_service.update_remnawave_user(db, subscription)
await db.refresh(user)
await db.refresh(subscription)
logger.info(f"Пользователь {user.telegram_id} отключил модем")
return ModemDisableResult(
success=True,
new_device_limit=subscription.device_limit
)
except Exception as e:
logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
await db.rollback()
return ModemDisableResult(
success=False,
error=ModemError.UPDATE_ERROR
)
@staticmethod
def get_period_warning_level(remaining_days: int) -> Optional[str]:
"""
Определяет уровень предупреждения о сроке действия.
Args:
remaining_days: Оставшиеся дни подписки
Returns:
"critical" если <= 7 дней
"info" если <= 30 дней
None если больше 30 дней
"""
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
return "critical"
if remaining_days <= MODEM_WARNING_DAYS_INFO:
return "info"
return None
# Singleton instance для использования в хендлерах
_modem_service: Optional[ModemService] = None
def get_modem_service() -> ModemService:
"""Возвращает singleton экземпляр ModemService."""
global _modem_service
if _modem_service is None:
_modem_service = ModemService()
return _modem_service
+285
View File
@@ -0,0 +1,285 @@
"""Фоновый сервис для обработки очереди чеков NaloGO.
При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
и отправляются позже этим сервисом.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional
from aiogram import Bot
from app.config import settings
from app.services.nalogo_service import NaloGoService
logger = logging.getLogger(__name__)
class NalogoQueueService:
"""Сервис фоновой обработки очереди чеков NaloGO."""
def __init__(self, nalogo_service: Optional[NaloGoService] = None):
self._nalogo_service = nalogo_service
self._bot: Optional[Bot] = None
self._task: Optional[asyncio.Task] = None
self._running = False
self._last_notification_time: Optional[datetime] = None
self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
def set_nalogo_service(self, service: NaloGoService) -> None:
"""Установить сервис NaloGO."""
self._nalogo_service = service
def set_bot(self, bot: Bot) -> None:
"""Установить бота для отправки уведомлений."""
self._bot = bot
def is_running(self) -> bool:
"""Проверка, запущен ли сервис."""
return self._running and self._task is not None and not self._task.done()
@property
def _check_interval(self) -> int:
"""Интервал проверки очереди в секундах."""
return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300)
@property
def _receipt_delay(self) -> int:
"""Задержка между отправкой чеков в секундах."""
return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3)
@property
def _max_attempts(self) -> int:
"""Максимальное количество попыток отправки чека."""
return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10)
async def start(self) -> None:
"""Запустить фоновую обработку очереди."""
if not self._nalogo_service or not self._nalogo_service.configured:
logger.info("NaloGO не настроен, сервис очереди чеков не запущен")
return
if self.is_running():
logger.warning("Сервис очереди чеков уже запущен")
return
self._running = True
self._task = asyncio.create_task(self._process_queue_loop())
logger.info(
f"Сервис очереди чеков NaloGO запущен "
f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)"
)
async def stop(self) -> None:
"""Остановить фоновую обработку."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Сервис очереди чеков NaloGO остановлен")
async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
"""Отправить уведомление админам о чеках."""
if not self._bot:
return
chat_id = settings.get_admin_notifications_chat_id()
if not chat_id:
return
topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
# Проверяем cooldown (можно пропустить для важных уведомлений)
if not skip_cooldown:
now = datetime.now()
if self._last_notification_time:
if now - self._last_notification_time < self._notification_cooldown:
logger.debug("Уведомление о чеках пропущено (cooldown)")
return
try:
await self._bot.send_message(
chat_id=chat_id,
message_thread_id=topic_id,
text=message,
parse_mode="HTML",
)
self._last_notification_time = datetime.now()
logger.info("Отправлено уведомление о чеках NaloGO")
except Exception as error:
logger.error(f"Ошибка отправки уведомления о чеках: {error}")
async def _process_queue_loop(self) -> None:
"""Основной цикл обработки очереди."""
while self._running:
try:
await self._process_pending_receipts()
except Exception as error:
logger.error(f"Ошибка в цикле обработки очереди чеков: {error}")
await asyncio.sleep(self._check_interval)
async def _process_pending_receipts(self) -> None:
"""Обработать все ожидающие чеки в очереди."""
if not self._nalogo_service:
return
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return
logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.")
self._had_pending_receipts = True
processed = 0
failed = 0
skipped = 0
total_processed_amount = 0.0
service_unavailable = False
while True:
receipt_data = await self._nalogo_service.pop_receipt_from_queue()
if not receipt_data:
break
attempts = receipt_data.get("attempts", 0)
payment_id = receipt_data.get("payment_id", "unknown")
amount = receipt_data.get("amount", 0)
# Проверяем количество попыток
if attempts >= self._max_attempts:
logger.error(
f"Чек {payment_id} превысил лимит попыток ({self._max_attempts}), "
f"удален из очереди"
)
skipped += 1
continue
# Пытаемся отправить чек
try:
receipt_uuid = await self._nalogo_service.create_receipt(
name=receipt_data.get("name", "Интернет-сервис - Пополнение баланса"),
amount=amount,
quantity=receipt_data.get("quantity", 1),
client_info=receipt_data.get("client_info"),
payment_id=payment_id,
queue_on_failure=False, # Не добавлять в очередь повторно автоматически
)
if receipt_uuid:
processed += 1
total_processed_amount += amount
logger.info(
f"Чек из очереди успешно создан: {receipt_uuid} "
f"(payment_id={payment_id}, попытка {attempts + 1})"
)
else:
# Вернуть в очередь с увеличенным счетчиком попыток
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
service_unavailable = True
logger.warning(
f"Не удалось создать чек из очереди (payment_id={payment_id}), "
f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})"
)
# Если сервис недоступен, прекращаем попытки до следующего цикла
break
except Exception as error:
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
logger.error(
f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}"
)
# Прекращаем попытки при ошибке
break
# Задержка между чеками чтобы не долбить API
await asyncio.sleep(self._receipt_delay)
if processed > 0 or failed > 0 or skipped > 0:
logger.info(
f"Обработка очереди завершена: "
f"успешно={processed}, неудачно={failed}, пропущено={skipped}"
)
# Проверяем остаток в очереди
remaining = await self._nalogo_service.get_queue_length()
# Отправляем уведомление если есть проблемы
if service_unavailable or failed > 0:
if remaining > 0:
queued = await self._nalogo_service.get_queued_receipts()
total_queued_amount = sum(r.get("amount", 0) for r in queued)
message = (
f"<b>⚠️ Проблема с отправкой чеков NaloGO</b>\n\n"
f"Сервис nalog.ru временно недоступен.\n\n"
f"📋 <b>В очереди:</b> {remaining} чек(ов)\n"
f"💰 <b>На сумму:</b> {total_queued_amount:,.2f}\n\n"
f"Чеки будут отправлены автоматически когда сервис восстановится."
)
await self._send_admin_notification(message)
# Уведомление об успешной разгрузке очереди
elif remaining == 0 and self._had_pending_receipts and processed > 0:
self._had_pending_receipts = False
message = (
f"<b>✅ Очередь чеков NaloGO разгружена</b>\n\n"
f"Все отложенные чеки успешно отправлены!\n\n"
f"📋 <b>Отправлено:</b> {processed} чек(ов)\n"
f"💰 <b>На сумму:</b> {total_processed_amount:,.2f}"
)
await self._send_admin_notification(message, skip_cooldown=True)
async def force_process(self) -> dict:
"""Принудительно обработать очередь (для ручного запуска)."""
if not self._nalogo_service:
return {"error": "NaloGO сервис не настроен"}
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return {"message": "Очередь пуста", "processed": 0}
await self._process_pending_receipts()
new_length = await self._nalogo_service.get_queue_length()
return {
"message": "Обработка завершена",
"was_in_queue": queue_length,
"remaining": new_length,
"processed": queue_length - new_length,
}
async def get_status(self) -> dict:
"""Получить статус сервиса и очереди."""
queue_length = 0
total_amount = 0.0
queued_receipts = []
if self._nalogo_service:
queue_length = await self._nalogo_service.get_queue_length()
if queue_length > 0:
queued_receipts = await self._nalogo_service.get_queued_receipts()
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
return {
"running": self.is_running(),
"check_interval_seconds": self._check_interval,
"receipt_delay_seconds": self._receipt_delay,
"queue_length": queue_length,
"total_amount": total_amount,
"max_attempts": self._max_attempts,
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
}
# Глобальный экземпляр сервиса
nalogo_queue_service = NalogoQueueService()
+98 -3
View File
@@ -1,4 +1,5 @@
import logging
from datetime import datetime
from typing import Optional, Dict, Any
from decimal import Decimal
@@ -6,9 +7,12 @@ from nalogo import Client
from nalogo.dto.income import IncomeClient, IncomeType
from app.config import settings
from app.utils.cache import cache
logger = logging.getLogger(__name__)
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
class NaloGoService:
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
@@ -49,6 +53,53 @@ class NaloGoService:
)
self.configured = False
@staticmethod
def _is_service_unavailable(error: Exception) -> bool:
"""Проверяет, является ли ошибка временной недоступностью сервиса."""
error_str = str(error).lower()
error_type = type(error).__name__.lower()
return (
"503" in error_str
or "service temporarily unavailable" in error_str
or "service unavailable" in error_str
or "ведутся работы" in error_str
or ("health" in error_str and "false" in error_str)
# Таймауты и сетевые ошибки — временные проблемы
or "timeout" in error_type
or "timeout" in error_str
or "readtimeout" in error_type
or "connecttimeout" in error_type
or "connectionerror" in error_type
or "connecterror" in error_type
)
async def _queue_receipt(
self,
name: str,
amount: float,
quantity: int,
client_info: Optional[Dict[str, Any]],
payment_id: Optional[str] = None,
) -> bool:
"""Добавить чек в очередь для отложенной отправки."""
receipt_data = {
"name": name,
"amount": amount,
"quantity": quantity,
"client_info": client_info,
"payment_id": payment_id,
"created_at": datetime.now().isoformat(),
"attempts": 0,
}
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
if success:
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
logger.info(
f"Чек добавлен в очередь (payment_id={payment_id}, "
f"сумма={amount}₽, в очереди: {queue_len})"
)
return success
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
@@ -60,10 +111,24 @@ class NaloGoService:
logger.info("Успешная аутентификация в NaloGO")
return True
except Exception as error:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
if self._is_service_unavailable(error):
logger.warning(
"NaloGO временно недоступен (техработы): %s",
str(error)[:200]
)
else:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
return False
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
async def create_receipt(
self,
name: str,
amount: float,
quantity: int = 1,
client_info: Optional[Dict[str, Any]] = None,
payment_id: Optional[str] = None,
queue_on_failure: bool = True,
) -> Optional[str]:
"""Создание чека о доходе.
Args:
@@ -71,6 +136,8 @@ class NaloGoService:
amount: Сумма в рублях
quantity: Количество
client_info: Информация о клиенте (опционально)
payment_id: ID платежа для логирования
queue_on_failure: Добавить в очередь при временной недоступности
Returns:
UUID чека или None при ошибке
@@ -84,6 +151,9 @@ class NaloGoService:
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
# Если сервис недоступен — добавляем в очередь
if queue_on_failure:
await self._queue_receipt(name, amount, quantity, client_info, payment_id)
return None
income_api = self.client.income()
@@ -114,5 +184,30 @@ class NaloGoService:
return None
except Exception as error:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
if self._is_service_unavailable(error):
logger.warning(
"NaloGO временно недоступен, чек будет отправлен позже "
f"(payment_id={payment_id}, сумма={amount}₽)"
)
if queue_on_failure:
await self._queue_receipt(name, amount, quantity, client_info, payment_id)
else:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None
async def get_queue_length(self) -> int:
"""Получить количество чеков в очереди."""
return await cache.llen(NALOGO_QUEUE_KEY)
async def get_queued_receipts(self) -> list:
"""Получить список чеков в очереди (без удаления)."""
return await cache.lrange(NALOGO_QUEUE_KEY)
async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]:
"""Извлечь следующий чек из очереди."""
return await cache.rpop(NALOGO_QUEUE_KEY)
async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool:
"""Вернуть чек обратно в очередь (при неудачной отправке)."""
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
+10 -1
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
@@ -259,11 +260,19 @@ class CloudPaymentsPaymentMixin:
logger.exception("Ошибка отправки уведомления CloudPayments: %s", error)
# Auto-purchase if enabled
auto_purchase_success = False
try:
await auto_purchase_saved_cart_after_topup(db, user)
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user)
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as error:
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
return True
async def process_cloudpayments_fail_webhook(
+17
View File
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.subscription_renewal_service import (
@@ -376,6 +377,22 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(
db,
user,
bot=bot_instance,
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
+14 -1
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -396,11 +397,23 @@ class MulenPayPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
+13
View File
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -499,6 +500,18 @@ class Pal24PaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
+13
View File
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -485,6 +486,18 @@ class PlategaPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
+17
View File
@@ -21,6 +21,7 @@ from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, TransactionType
from app.external.telegram_stars import TelegramStarsService
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -534,6 +535,22 @@ class TelegramStarsMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
texts = get_texts(user.language)
cart_message = texts.t(
+13
View File
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.wata_service import WataAPIError, WataService
@@ -569,6 +570,18 @@ class WataPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
+20 -3
View File
@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -741,6 +742,22 @@ class YooKassaPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
@@ -1029,13 +1046,13 @@ class YooKassaPaymentMixin:
receipt_uuid = await self.nalogo_service.create_receipt(
name=receipt_name,
amount=amount_rubles,
quantity=1
quantity=1,
payment_id=payment.yookassa_payment_id,
)
if receipt_uuid:
logger.info(f"Чек NaloGO создан для платежа {payment.yookassa_payment_id}: {receipt_uuid}")
else:
logger.warning(f"Не удалось создать чек NaloGO для платежа {payment.yookassa_payment_id}")
# При временной недоступности чек добавляется в очередь автоматически
except Exception as error:
logger.error(
+6
View File
@@ -18,6 +18,7 @@ from app.config import settings
from app.database.crud.subscription import get_subscriptions_statistics
from app.database.database import AsyncSessionLocal
from app.database.models import (
PaymentMethod,
Subscription,
SubscriptionConversion,
SubscriptionStatus,
@@ -499,6 +500,11 @@ class ReportingService:
Transaction.created_at >= start_utc,
Transaction.created_at < end_utc,
self._exclude_referral_deposits_condition(),
# Исключаем ручные (админские) пополнения из статистики
or_(
Transaction.payment_method.is_(None),
Transaction.payment_method != PaymentMethod.MANUAL.value,
),
)
async def _get_top_referrers(
@@ -624,4 +624,282 @@ async def auto_purchase_saved_cart_after_topup(
return True
__all__ = ["auto_purchase_saved_cart_after_topup"]
async def auto_activate_subscription_after_topup(
db: AsyncSession,
user: User,
*,
bot: Optional[Bot] = None,
) -> bool:
"""
Умная автоактивация после пополнения баланса.
Работает БЕЗ сохранённой корзины:
- Если подписка активна ничего не делает
- Если подписка истекла продлевает с теми же параметрами
- Если подписки нет создаёт новую с дефолтными параметрами
Выбирает максимальный период, который можно оплатить из баланса.
"""
from datetime import datetime
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType, PaymentMethod
from app.services.subscription_service import SubscriptionService
from app.services.subscription_renewal_service import SubscriptionRenewalService
from app.services.admin_notification_service import AdminNotificationService
if not settings.is_auto_activate_after_topup_enabled():
return False
if not user or not getattr(user, "id", None):
return False
subscription = await get_subscription_by_user_id(db, user.id)
# Если подписка активна — ничего не делаем
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
logger.info(
"🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем",
user.telegram_id,
)
return False
# Определяем параметры подписки
if subscription:
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb or 0
connected_squads = subscription.connected_squads or []
else:
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = 0
connected_squads = []
# Если серверы не выбраны — берём бесплатные по умолчанию
if not connected_squads:
available_servers = await get_available_server_squads(db, promo_group_id=user.promo_group_id)
connected_squads = [
s.squad_uuid for s in available_servers
if s.is_available and s.price_kopeks == 0
]
if not connected_squads and available_servers:
connected_squads = [available_servers[0].squad_uuid]
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = user.balance_kopeks
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
if not available_periods:
logger.warning("🔁 Автоактивация: нет доступных периодов подписки")
return False
subscription_service = SubscriptionService()
# Найти максимальный период <= баланса
best_period = None
best_price = 0
for period in available_periods:
try:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
server_ids,
device_limit,
db,
user=user
)
if price <= balance:
best_period = period
best_price = price
break
except Exception as calc_error:
logger.warning(
"🔁 Автоактивация: ошибка расчёта цены для периода %s: %s",
period,
calc_error,
)
continue
if not best_period:
logger.info(
"🔁 Автоактивация: у пользователя %s недостаточно средств (%s) для любого периода",
user.telegram_id,
balance,
)
return False
texts = get_texts(getattr(user, "language", "ru"))
try:
if subscription:
# Продление существующей подписки
renewal_service = SubscriptionRenewalService()
pricing = await renewal_service.calculate_pricing(
db, user, subscription, best_period
)
old_end_date = subscription.end_date
result = await renewal_service.finalize(
db, user, subscription,
pricing,
description=f"Автоматическое продление на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
logger.info(
"✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.",
user.telegram_id,
best_period,
best_price,
)
# Уведомление пользователю
if bot:
try:
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
new_end_date = result.subscription.end_date
end_date_str = new_end_date.strftime("%d.%m.%Y") if new_end_date else ""
message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED",
"✅ Подписка автоматически продлена на {period}.",
).format(period=period_label)
details = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS",
"⏰ Новая дата окончания: {date}.",
).format(date=end_date_str)
hint = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=f"{message}\n{details}\n\n{hint}",
reply_markup=keyboard,
parse_mode="HTML",
)
except Exception as notify_error:
logger.warning(
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
user.telegram_id,
notify_error,
)
else:
# Создание новой подписки
new_subscription = await create_paid_subscription(
db,
user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
)
await subtract_user_balance(
db, user, best_price,
f"Активация подписки на {best_period} дней"
)
await subscription_service.create_remnawave_user(db, new_subscription)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=best_price,
description=f"Активация подписки на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
logger.info(
"✅ Автоактивация: новая подписка на %s дней создана для пользователя %s за %s коп.",
best_period,
user.telegram_id,
best_price,
)
# Уведомление пользователю
if bot:
try:
period_label = format_period_description(best_period, getattr(user, "language", "ru"))
message = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_SUCCESS",
"✅ Подписка на {period} автоматически оформлена после пополнения баланса.",
).format(period=period_label)
hint = texts.t(
"AUTO_PURCHASE_SUBSCRIPTION_HINT",
"Перейдите в раздел «Моя подписка», чтобы получить ссылку.",
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)],
]
)
await bot.send_message(
chat_id=user.telegram_id,
text=f"{message}\n\n{hint}",
reply_markup=keyboard,
parse_mode="HTML",
)
# Уведомление админам
try:
notification_service = AdminNotificationService(bot)
await notification_service.send_subscription_purchase_notification(
db,
user,
new_subscription,
None, # transaction
best_period,
False, # was_trial_conversion
)
except Exception as admin_error:
logger.warning(
"⚠️ Автоактивация: не удалось уведомить админов: %s",
admin_error,
)
except Exception as notify_error:
logger.warning(
"⚠️ Автоактивация: не удалось уведомить пользователя %s: %s",
user.telegram_id,
notify_error,
)
return True
except Exception as e:
logger.error(
"❌ Автоактивация: ошибка для пользователя %s: %s",
user.telegram_id,
e,
exc_info=True,
)
return False
__all__ = ["auto_purchase_saved_cart_after_topup", "auto_activate_subscription_after_topup"]
+13
View File
@@ -15,6 +15,7 @@ from app.database.crud.user import get_user_by_telegram_id
from app.external.tribute import TributeService as TributeAPI
from app.services.payment_service import PaymentService
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
@@ -314,6 +315,18 @@ class TributeService:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(session, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and self.bot:
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
+5 -1
View File
@@ -395,7 +395,11 @@ class UserService:
old_balance = user.balance_kopeks
if amount_kopeks > 0:
await add_user_balance(db, user, amount_kopeks, description=description)
await add_user_balance(
db, user, amount_kopeks,
description=description,
payment_method=PaymentMethod.MANUAL
)
logger.info(f"Админ {admin_id} пополнил баланс пользователя {user_id} на {amount_kopeks/100}")
success = True
else:
+51 -1
View File
@@ -159,7 +159,7 @@ class CacheService:
async def get_hash(self, name: str, key: str = None) -> Optional[Union[dict, str]]:
if not self._connected:
return None
try:
if key:
value = await self.redis_client.hget(name, key)
@@ -171,6 +171,56 @@ class CacheService:
logger.error(f"Ошибка получения хеша {name}: {e}")
return None
async def lpush(self, key: str, value: Any) -> bool:
"""Добавить элемент в начало списка (очереди)."""
if not self._connected:
return False
try:
serialized = json.dumps(value, default=str)
await self.redis_client.lpush(key, serialized)
return True
except Exception as e:
logger.error(f"Ошибка добавления в очередь {key}: {e}")
return False
async def rpop(self, key: str) -> Optional[Any]:
"""Извлечь элемент из конца списка (FIFO очередь)."""
if not self._connected:
return None
try:
value = await self.redis_client.rpop(key)
if value:
return json.loads(value)
return None
except Exception as e:
logger.error(f"Ошибка извлечения из очереди {key}: {e}")
return None
async def llen(self, key: str) -> int:
"""Получить длину списка (очереди)."""
if not self._connected:
return 0
try:
return await self.redis_client.llen(key)
except Exception as e:
logger.error(f"Ошибка получения длины очереди {key}: {e}")
return 0
async def lrange(self, key: str, start: int = 0, end: int = -1) -> list:
"""Получить элементы списка без удаления."""
if not self._connected:
return []
try:
items = await self.redis_client.lrange(key, start, end)
return [json.loads(item) for item in items]
except Exception as e:
logger.error(f"Ошибка чтения очереди {key}: {e}")
return []
cache = CacheService()
+94 -3
View File
@@ -173,7 +173,7 @@ def typing_action(func: Callable) -> Callable:
def rate_limit(rate: float = 1.0, key: str = None):
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(
event: types.Update,
@@ -181,7 +181,98 @@ def rate_limit(rate: float = 1.0, key: str = None):
**kwargs
) -> Any:
return await func(event, *args, **kwargs)
return wrapper
return decorator
def modem_available(for_enable: bool = False, for_disable: bool = False):
"""
Декоратор для проверки доступности модема.
Проверяет:
- Наличие подписки
- Подписка не триальная
- Функция модема включена в настройках
- (опционально) Модем ещё не подключен (for_enable=True)
- (опционально) Модем уже подключен (for_disable=True)
Args:
for_enable: Проверять, что модем ещё не подключен
for_disable: Проверять, что модем подключен
Usage:
@modem_available()
async def handle_modem_menu(callback, db_user, db): ...
@modem_available(for_enable=True)
async def handle_modem_enable(callback, db_user, db): ...
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(
event: types.Update,
*args,
**kwargs
) -> Any:
db_user = kwargs.get('db_user')
if not db_user:
logger.warning("modem_available: нет db_user в kwargs")
return
from app.services.modem_service import get_modem_service, ModemError
service = get_modem_service()
result = service.check_availability(
db_user,
for_enable=for_enable,
for_disable=for_disable
)
if not result.available:
texts = get_texts(db_user.language if db_user else 'ru')
error_messages = {
ModemError.NO_SUBSCRIPTION: texts.t(
"MODEM_PAID_ONLY",
"Модем доступен только для платных подписок"
),
ModemError.TRIAL_SUBSCRIPTION: texts.t(
"MODEM_PAID_ONLY",
"Модем доступен только для платных подписок"
),
ModemError.MODEM_DISABLED: texts.t(
"MODEM_DISABLED",
"Функция модема отключена"
),
ModemError.ALREADY_ENABLED: texts.t(
"MODEM_ALREADY_ENABLED",
"Модем уже подключен"
),
ModemError.NOT_ENABLED: texts.t(
"MODEM_NOT_ENABLED",
"Модем не подключен"
),
}
error_text = error_messages.get(result.error, texts.ERROR)
try:
if isinstance(event, types.CallbackQuery):
await event.answer(error_text, show_alert=True)
elif isinstance(event, types.Message):
await event.answer(error_text)
except TelegramBadRequest as e:
if "query is too old" not in str(e).lower():
raise
return None
return await func(event, *args, **kwargs)
return wrapper
return decorator
+2 -1
View File
@@ -82,7 +82,8 @@ def _serialize_template(tpl: ContestTemplate) -> ContestTemplateResponse:
name=tpl.name,
slug=tpl.slug,
description=tpl.description,
prize_days=tpl.prize_days,
prize_type=tpl.prize_type,
prize_value=tpl.prize_value,
max_winners=tpl.max_winners,
attempts_per_user=tpl.attempts_per_user,
times_per_day=tpl.times_per_day,
+40
View File
@@ -30,6 +30,7 @@ from ..schemas.subscriptions import (
SubscriptionCreateRequest,
SubscriptionDevicesRequest,
SubscriptionExtendRequest,
SubscriptionModemRequest,
SubscriptionResponse,
SubscriptionSquadRequest,
SubscriptionTrafficRequest,
@@ -52,6 +53,7 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
@@ -302,3 +304,41 @@ async def remove_subscription_squad_endpoint(
subscription = await remove_subscription_squad(db, subscription, squad_uuid)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post("/{subscription_id}/modem", response_model=SubscriptionResponse)
async def set_subscription_modem(
subscription_id: int,
payload: SubscriptionModemRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
"""Включить или выключить модем для подписки."""
subscription = await _get_subscription(db, subscription_id)
if subscription.is_trial:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem is not available for trial subscriptions")
if not settings.is_modem_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Modem feature is disabled")
current_modem = getattr(subscription, 'modem_enabled', False) or False
if payload.enabled == current_modem:
return _serialize_subscription(subscription)
if payload.enabled:
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
else:
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
+1
View File
@@ -59,6 +59,7 @@ def _serialize_subscription(subscription: Optional[Subscription]) -> Optional[Su
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
+4 -2
View File
@@ -11,7 +11,8 @@ class ContestTemplateResponse(BaseModel):
name: str
slug: str
description: Optional[str] = None
prize_days: int
prize_type: str
prize_value: str
max_winners: int
attempts_per_user: int
times_per_day: int
@@ -30,7 +31,8 @@ class ContestTemplateListResponse(BaseModel):
class ContestTemplateUpdateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
prize_days: Optional[int] = Field(None, ge=1)
prize_type: Optional[str] = None
prize_value: Optional[str] = None
max_winners: Optional[int] = Field(None, ge=1)
attempts_per_user: Optional[int] = Field(None, ge=1)
times_per_day: Optional[int] = Field(None, ge=1)
+5
View File
@@ -17,6 +17,7 @@ class SubscriptionResponse(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: Optional[int] = None
subscription_url: Optional[str] = None
@@ -51,3 +52,7 @@ class SubscriptionDevicesRequest(BaseModel):
class SubscriptionSquadRequest(BaseModel):
squad_uuid: str
class SubscriptionModemRequest(BaseModel):
enabled: bool
+1
View File
@@ -25,6 +25,7 @@ class SubscriptionSummary(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: Optional[int] = None
subscription_url: Optional[str] = None
+33
View File
@@ -34,6 +34,7 @@ from app.services.external_admin_service import ensure_external_admin_token
from app.services.broadcast_service import broadcast_service
from app.services.referral_contest_service import referral_contest_service
from app.services.contest_rotation_service import contest_rotation_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.startup_timeline import StartupTimeline
from app.utils.timezone import TimezoneAwareFormatter
@@ -271,6 +272,11 @@ async def main():
payment_service = PaymentService(bot)
auto_payment_verification_service.set_payment_service(payment_service)
# Настройка сервиса очереди чеков NaloGO
if payment_service.nalogo_service:
nalogo_queue_service.set_nalogo_service(payment_service.nalogo_service)
nalogo_queue_service.set_bot(bot)
verification_providers: list[str] = []
auto_verification_active = False
async with timeline.stage(
@@ -331,6 +337,27 @@ async def main():
if auto_verification_active:
stage.log("Фоновая автопроверка запущена")
async with timeline.stage(
"Очередь чеков NaloGO",
"🧾",
success_message="Сервис очереди чеков запущен",
) as stage:
if settings.is_nalogo_enabled():
try:
await nalogo_queue_service.start()
if nalogo_queue_service.is_running():
queue_len = await payment_service.nalogo_service.get_queue_length()
if queue_len > 0:
stage.log(f"В очереди ожидает {queue_len} чек(ов)")
stage.success("Фоновая обработка чеков активна")
else:
stage.skip("Сервис не запущен")
except Exception as e:
stage.warning(f"Ошибка запуска очереди чеков: {e}")
logger.error(f"❌ Ошибка запуска очереди чеков NaloGO: {e}")
else:
stage.skip("NaloGO отключен настройками")
async with timeline.stage(
"Внешняя админка",
"🛡️",
@@ -646,6 +673,12 @@ async def main():
except Exception as e:
logger.error(f"Ошибка остановки ротации игр: {e}")
logger.info("ℹ️ Остановка очереди чеков NaloGO...")
try:
await nalogo_queue_service.stop()
except Exception as e:
logger.error(f"Ошибка остановки очереди чеков NaloGO: {e}")
logger.info("ℹ️ Остановка сервиса бекапов...")
try:
await backup_service.stop_auto_backup()
@@ -0,0 +1,28 @@
"""add purchased_traffic_gb to subscriptions
Revision ID: a1b2c3d4e5f6
Revises: f4a5b6c7d8e9
Create Date: 2024-12-25 14:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a1b2c3d4e5f6'
down_revision = 'f4a5b6c7d8e9'
branch_labels = None
depends_on = None
def upgrade():
# Добавляем колонку purchased_traffic_gb для отслеживания докупленного трафика
op.add_column('subscriptions', sa.Column('purchased_traffic_gb', sa.Integer(), nullable=True, server_default='0'))
# Устанавливаем NOT NULL после добавления значения по умолчанию
op.alter_column('subscriptions', 'purchased_traffic_gb', nullable=False, server_default=None)
def downgrade():
op.drop_column('subscriptions', 'purchased_traffic_gb')
@@ -0,0 +1,19 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f4a5b6c7d8e9"
down_revision: Union[str, None] = "e3c1e0b5b4a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("subscriptions", sa.Column("modem_enabled", sa.Boolean(), nullable=True, server_default="false"))
def downgrade() -> None:
op.drop_column("subscriptions", "modem_enabled")
+418
View File
@@ -0,0 +1,418 @@
"""
Тесты для ModemService - управление модемом в подписке.
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from app.services.modem_service import (
ModemService,
ModemError,
ModemAvailabilityResult,
ModemPriceResult,
ModemEnableResult,
ModemDisableResult,
get_modem_service,
MODEM_WARNING_DAYS_CRITICAL,
MODEM_WARNING_DAYS_INFO,
)
def create_mock_settings():
"""Создаёт мок настроек приложения."""
settings = MagicMock()
settings.is_modem_enabled.return_value = True
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
settings.get_modem_period_discount.return_value = 0
return settings
def create_sample_user():
"""Создаёт пример пользователя."""
user = SimpleNamespace(
id=1,
telegram_id=123456789,
balance_kopeks=50000, # 500 рублей
language="ru",
subscription=None,
)
return user
def create_sample_subscription():
"""Создаёт пример подписки."""
subscription = SimpleNamespace(
id=1,
user_id=1,
is_trial=False,
modem_enabled=False,
device_limit=2,
end_date=datetime.utcnow() + timedelta(days=30),
updated_at=datetime.utcnow(),
)
return subscription
def create_trial_subscription():
"""Создаёт триальную подписку."""
subscription = SimpleNamespace(
id=2,
user_id=1,
is_trial=True,
modem_enabled=False,
device_limit=1,
end_date=datetime.utcnow() + timedelta(days=7),
updated_at=datetime.utcnow(),
)
return subscription
def create_modem_service(monkeypatch):
"""Создаёт ModemService с замоканными настройками."""
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
return ModemService(), mock_settings
class TestModemServiceAvailability:
"""Тесты проверки доступности модема."""
def test_check_availability_no_subscription(self, monkeypatch):
"""Модем недоступен без подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.subscription = None
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.NO_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_trial_subscription(self, monkeypatch):
"""Модем недоступен для триальной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
trial_subscription = create_trial_subscription()
sample_user.subscription = trial_subscription
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.TRIAL_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
"""Модем недоступен, если отключён в настройках."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
mock_settings.is_modem_enabled.return_value = False
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.MODEM_DISABLED
def test_check_availability_success(self, monkeypatch):
"""Модем доступен для платной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user)
assert result.available
assert result.error is None
assert not result.modem_enabled
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
"""Нельзя подключить уже подключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_enable=True)
assert not result.available
assert result.error == ModemError.ALREADY_ENABLED
assert result.modem_enabled
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
"""Нельзя отключить неподключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_disable=True)
assert not result.available
assert result.error == ModemError.NOT_ENABLED
assert not result.modem_enabled
class TestModemServicePricing:
"""Тесты расчёта цены модема."""
def test_calculate_price_one_month(self, monkeypatch):
"""Расчёт цены на 1 месяц."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 10000
assert result.final_price == 10000
assert result.charged_months == 1
assert result.discount_percent == 0
assert not result.has_discount
def test_calculate_price_three_months(self, monkeypatch):
"""Расчёт цены на 3 месяца."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000 # 3 * 10000
assert result.charged_months == 3
def test_calculate_price_with_discount(self, monkeypatch):
"""Расчёт цены со скидкой."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000
assert result.discount_percent == 10
assert result.discount_amount == 3000
assert result.final_price == 27000
assert result.has_discount
class TestModemServiceBalance:
"""Тесты проверки баланса."""
def test_check_balance_sufficient(self, monkeypatch):
"""Баланса достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 50000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert has_funds
assert missing == 0
def test_check_balance_insufficient(self, monkeypatch):
"""Баланса недостаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 5000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert not has_funds
assert missing == 5000
def test_check_balance_zero_price(self, monkeypatch):
"""Нулевая цена - всегда достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 0
has_funds, missing = modem_service.check_balance(sample_user, 0)
assert has_funds
assert missing == 0
class TestModemServicePeriodWarning:
"""Тесты предупреждений о сроке действия."""
def test_warning_critical(self, monkeypatch):
"""Критическое предупреждение при <= 7 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(7) == "critical"
assert modem_service.get_period_warning_level(5) == "critical"
assert modem_service.get_period_warning_level(1) == "critical"
def test_warning_info(self, monkeypatch):
"""Информационное предупреждение при <= 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(30) == "info"
assert modem_service.get_period_warning_level(15) == "info"
assert modem_service.get_period_warning_level(8) == "info"
def test_warning_none(self, monkeypatch):
"""Нет предупреждения при > 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(31) is None
assert modem_service.get_period_warning_level(60) is None
assert modem_service.get_period_warning_level(90) is None
class TestModemServiceEnable:
"""Тесты подключения модема."""
async def test_enable_modem_success(self, monkeypatch):
"""Успешное подключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=True)
mock_create_transaction = AsyncMock()
mock_update_remnawave = AsyncMock()
monkeypatch.setattr(
'app.services.modem_service.subtract_user_balance',
mock_subtract
)
monkeypatch.setattr(
'app.services.modem_service.create_transaction',
mock_create_transaction
)
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert result.success
assert result.error is None
assert result.charged_amount == 10000
assert sample_subscription.modem_enabled is True
assert sample_subscription.device_limit == 3 # было 2, стало 3
async def test_enable_modem_insufficient_funds(self, monkeypatch):
"""Недостаточно средств для подключения."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 1000 # недостаточно
mock_db = AsyncMock()
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert not result.success
assert result.error == ModemError.INSUFFICIENT_FUNDS
async def test_enable_modem_charge_error(self, monkeypatch):
"""Ошибка списания средств."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=False) # ошибка списания
monkeypatch.setattr(
'app.services.modem_service.subtract_user_balance',
mock_subtract
)
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert not result.success
assert result.error == ModemError.CHARGE_ERROR
class TestModemServiceDisable:
"""Тесты отключения модема."""
async def test_disable_modem_success(self, monkeypatch):
"""Успешное отключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_subscription.device_limit = 3
sample_user.subscription = sample_subscription
mock_db = AsyncMock()
mock_update_remnawave = AsyncMock()
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.disable_modem(
mock_db, sample_user, sample_subscription
)
assert result.success
assert result.error is None
assert sample_subscription.modem_enabled is False
assert sample_subscription.device_limit == 2 # было 3, стало 2
class TestModemServiceSingleton:
"""Тесты singleton паттерна."""
def test_get_modem_service_returns_same_instance(self, monkeypatch):
"""get_modem_service возвращает один и тот же экземпляр."""
# Сбрасываем глобальный экземпляр
import app.services.modem_service as modem_module
modem_module._modem_service = None
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
service1 = get_modem_service()
service2 = get_modem_service()
assert service1 is service2
class TestModemEnabledGetter:
"""Тесты безопасного получения статуса модема."""
def test_get_modem_enabled_true(self, monkeypatch):
"""Модем включён."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
assert modem_service.get_modem_enabled(sample_subscription) is True
def test_get_modem_enabled_false(self, monkeypatch):
"""Модем выключен."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
assert modem_service.get_modem_enabled(sample_subscription) is False
def test_get_modem_enabled_none_subscription(self, monkeypatch):
"""Подписка None."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_modem_enabled(None) is False
def test_get_modem_enabled_no_attribute(self, monkeypatch):
"""У подписки нет атрибута modem_enabled."""
modem_service, _ = create_modem_service(monkeypatch)
subscription = SimpleNamespace(id=1) # без modem_enabled
assert modem_service.get_modem_enabled(subscription) is False