Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6488dcfcb2 | |||
| 9ec5f7f59e | |||
| 0621a3febc | |||
| ebd6bee05e | |||
| 119f463c36 | |||
| a3903a252e | |||
| 65ba50c2cf | |||
| cc54a7ad2f | |||
| 7b0403a307 | |||
| 142ff14a50 | |||
| 29a3b395b6 | |||
| 49871f82f3 | |||
| efa3a5d457 | |||
| 0b86f379b4 | |||
| 1cae7130bc | |||
| 45410168af | |||
| 5482e609f8 | |||
| e79f598d17 | |||
| 916ad9d567 |
+4
-11
@@ -152,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
|
||||
|
||||
# Тип авторизации: "api_key", "basic_auth", "caddy"
|
||||
REMNAWAVE_AUTH_TYPE=api_key
|
||||
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
|
||||
REMNAWAVE_CADDY_TOKEN=
|
||||
|
||||
# Для панелей с Basic Auth (опционально)
|
||||
REMNAWAVE_USERNAME=
|
||||
@@ -544,7 +544,6 @@ PAL24_SHOP_ID=
|
||||
PAL24_SIGNATURE_TOKEN=
|
||||
PAL24_BASE_URL=https://pal24.pro/api/v1/
|
||||
PAL24_WEBHOOK_PATH=/pal24-webhook
|
||||
PAL24_WEBHOOK_PORT=8084
|
||||
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
|
||||
PAL24_MIN_AMOUNT_KOPEKS=10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS=100000000
|
||||
@@ -741,7 +740,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
|
||||
# ===== ЛОКАЛИЗАЦИЯ =====
|
||||
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
|
||||
DEFAULT_LANGUAGE=ru
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh
|
||||
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
|
||||
# Включить выбор языка при старте и отображение кнопки в меню
|
||||
LANGUAGE_SELECTION_ENABLED=true
|
||||
|
||||
@@ -830,7 +829,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
|
||||
WEBHOOK_WORKERS=4
|
||||
WEBHOOK_ENQUEUE_TIMEOUT=0.1
|
||||
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
|
||||
BOT_RUN_MODE=polling # polling, webhook или both
|
||||
BOT_RUN_MODE=polling # polling или webhook
|
||||
|
||||
# ===== КОНКУРСНАЯ СИСТЕМА =====
|
||||
CONTESTS_ENABLED=false
|
||||
@@ -838,15 +837,9 @@ CONTESTS_BUTTON_VISIBLE=false
|
||||
# Реферальные конкурсы (турниры среди рефералов)
|
||||
REFERRAL_CONTESTS_ENABLED=false
|
||||
|
||||
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
|
||||
# Автоматическая покупка из сохранённой корзины после пополнения баланса
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
|
||||
# Умная автоактивация: система сама решает — продлить или создать подписку
|
||||
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
|
||||
|
||||
# ===== КНОПКА АКТИВАЦИИ =====
|
||||
ACTIVATE_BUTTON_VISIBLE=false
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.8.0"
|
||||
".": "3.9.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
|
||||
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
|
||||
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
|
||||
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
|
||||
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
|
||||
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
|
||||
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
|
||||
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
|
||||
|
||||
|
||||
### Performance
|
||||
|
||||
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
|
||||
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
|
||||
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
|
||||
|
||||
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.8.0" # x-release-please-version
|
||||
ARG VERSION="v3.9.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ docker compose logs
|
||||
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
|
||||
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
|
||||
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
|
||||
|
||||
### 2. Минимальные настройки для webhook
|
||||
|
||||
@@ -1012,7 +1011,7 @@ curl -I https://miniapp.domain.com
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
|
||||
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
|
||||
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
|
||||
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
|
||||
|
||||
[Полный список доступных параметров:](.env.example)
|
||||
|
||||
@@ -1022,7 +1021,7 @@ curl -I https://miniapp.domain.com
|
||||
|
||||
### 🤖 Режимы запуска бота
|
||||
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
|
||||
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
|
||||
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
|
||||
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
|
||||
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
|
||||
@@ -1343,7 +1342,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 Автоплатёж с настройкой дня списания
|
||||
- 🎁 Реферальные и промо-бонусы
|
||||
- ⚡ **Быстрое пополнение** с кнопками быстрых сумм
|
||||
- 🔄 **Умная автоактивация** подписки после пополнения баланса
|
||||
|
||||
📱 **Управление подписками**
|
||||
|
||||
@@ -1530,7 +1528,6 @@ CONTEST_BUTTON_VISIBLE=true
|
||||
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
|
||||
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
|
||||
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
|
||||
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
|
||||
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
|
||||
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
|
||||
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
|
||||
}
|
||||
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
|
||||
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
|
||||
if existing:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=admin.id,
|
||||
)
|
||||
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -412,21 +412,14 @@ async def delete_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Check if tariff has subscriptions
|
||||
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
|
||||
if subs_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
|
||||
)
|
||||
|
||||
await delete_tariff(db, tariff)
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
|
||||
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
|
||||
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
return {'message': 'Tariff deleted successfully'}
|
||||
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
|
||||
|
||||
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
|
||||
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
|
||||
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
|
||||
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
|
||||
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
|
||||
|
||||
# Allowed image types
|
||||
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
|
||||
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class LiteModeEnabledResponse(BaseModel):
|
||||
"""Lite mode enabled setting."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class LiteModeEnabledUpdate(BaseModel):
|
||||
"""Request to update lite mode setting."""
|
||||
|
||||
enabled: bool
|
||||
|
||||
|
||||
class AnalyticsCountersResponse(BaseModel):
|
||||
"""Analytics counter settings."""
|
||||
|
||||
@@ -718,3 +731,39 @@ async def update_analytics_counters(
|
||||
google_ads_id=google_id,
|
||||
google_ads_label=google_label,
|
||||
)
|
||||
|
||||
|
||||
# ============ Lite Mode Routes ============
|
||||
|
||||
|
||||
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def get_lite_mode_enabled(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""
|
||||
Get lite mode enabled setting.
|
||||
This is a public endpoint - no authentication required.
|
||||
When enabled, shows simplified dashboard with minimal features.
|
||||
"""
|
||||
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
|
||||
|
||||
if lite_mode_value is not None:
|
||||
enabled = lite_mode_value.lower() == 'true'
|
||||
return LiteModeEnabledResponse(enabled=enabled)
|
||||
|
||||
# Default: disabled
|
||||
return LiteModeEnabledResponse(enabled=False)
|
||||
|
||||
|
||||
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
|
||||
async def update_lite_mode_enabled(
|
||||
payload: LiteModeEnabledUpdate,
|
||||
admin: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update lite mode enabled setting. Admin only."""
|
||||
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
|
||||
|
||||
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
|
||||
|
||||
return LiteModeEnabledResponse(enabled=payload.enabled)
|
||||
|
||||
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
|
||||
|
||||
_LANGUAGE_META: dict[str, tuple[str, str]] = {
|
||||
'ru': ('Русский', '🇷🇺'),
|
||||
'en': ('English', '🇬🇧'),
|
||||
'ua': ('Українська', '🇺🇦'),
|
||||
'zh': ('中文', '🇨🇳'),
|
||||
'fa': ('فارسی', '🇮🇷'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_language_code(value: str | None) -> str:
|
||||
return (value or '').strip().lower().split('-', 1)[0]
|
||||
|
||||
|
||||
def _get_available_language_codes() -> list[str]:
|
||||
codes: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for code in settings.get_available_languages():
|
||||
normalized = _normalize_language_code(code)
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
codes.append(normalized)
|
||||
return codes
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
@@ -212,12 +236,19 @@ async def get_service_info():
|
||||
@router.get('/languages')
|
||||
async def get_available_languages():
|
||||
"""Get list of available languages."""
|
||||
codes = _get_available_language_codes()
|
||||
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
|
||||
|
||||
return {
|
||||
'languages': [
|
||||
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
|
||||
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
|
||||
{
|
||||
'code': code,
|
||||
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
|
||||
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
|
||||
}
|
||||
for code in codes
|
||||
],
|
||||
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
|
||||
'default': default_language,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,16 +267,15 @@ async def update_user_language(
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Update user's language preference."""
|
||||
language = request.get('language', 'ru')
|
||||
|
||||
valid_languages = ['ru', 'en']
|
||||
if language not in valid_languages:
|
||||
requested_language = _normalize_language_code(request.get('language', 'ru'))
|
||||
available_languages = _get_available_language_codes()
|
||||
if requested_language not in available_languages:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
|
||||
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
|
||||
)
|
||||
|
||||
user.language = language
|
||||
user.language = requested_language
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
|
||||
@@ -1662,7 +1662,7 @@ async def submit_purchase(
|
||||
user=user,
|
||||
subscription=subscription,
|
||||
transaction=None,
|
||||
period_days=selection.period_days,
|
||||
period_days=selection.period.days,
|
||||
was_trial_conversion=result.get('was_trial_conversion', False),
|
||||
amount_kopeks=pricing.final_total,
|
||||
purchase_type='renewal' if not is_new_subscription else None,
|
||||
|
||||
@@ -119,7 +119,7 @@ class EmailService:
|
||||
verification_token: Verification token
|
||||
verification_url: Base URL for verification (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -174,6 +174,16 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'تایید آدرس ایمیل',
|
||||
'intro': 'از ثبتنام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
|
||||
'button': 'تایید ایمیل',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'ignore': 'اگر شما این حساب را ایجاد نکردهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -236,7 +246,7 @@ class EmailService:
|
||||
reset_token: Password reset token
|
||||
reset_url: Base URL for password reset (token will be appended)
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template (already wrapped in base template)
|
||||
|
||||
@@ -291,6 +301,16 @@ class EmailService:
|
||||
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'بازنشانی رمز عبور',
|
||||
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
|
||||
'button': 'بازنشانی رمز عبور',
|
||||
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
|
||||
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
|
||||
'warning': 'اگر شما درخواست بازنشانی رمز عبور ندادهاید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
@@ -352,7 +372,7 @@ class EmailService:
|
||||
to_email: New email address
|
||||
code: 6-digit verification code
|
||||
username: User's name for personalization
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
custom_subject: Override subject from admin template
|
||||
custom_body_html: Override body HTML from admin template
|
||||
|
||||
@@ -401,6 +421,15 @@ class EmailService:
|
||||
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
|
||||
'regards': 'З повагою,',
|
||||
},
|
||||
'fa': {
|
||||
'greeting': f'سلام{", " + username if username else ""}!',
|
||||
'subject': 'کد تایید تغییر ایمیل',
|
||||
'intro': 'شما درخواست تغییر ایمیل دادهاید. برای تایید از کد زیر استفاده کنید:',
|
||||
'code_label': 'کد تایید شما:',
|
||||
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
|
||||
'ignore': 'اگر شما درخواست تغییر ایمیل ندادهاید، این ایمیل را نادیده بگیرید.',
|
||||
'regards': 'با احترام،',
|
||||
},
|
||||
}
|
||||
|
||||
t = texts.get(language, texts['ru'])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Email notification templates for different notification types.
|
||||
|
||||
Supports multiple languages: ru, en, zh, ua
|
||||
Supports multiple languages: ru, en, zh, ua, fa
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
|
||||
|
||||
Args:
|
||||
notification_type: Type of notification
|
||||
language: Language code (ru, en, zh, ua)
|
||||
language: Language code (ru, en, zh, ua, fa)
|
||||
context: Context data for template rendering
|
||||
|
||||
Returns:
|
||||
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'This is an automated message. Please do not reply to this email.',
|
||||
'zh': '这是一封自动发送的邮件,请勿回复。',
|
||||
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
|
||||
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
|
||||
}
|
||||
footer_text = footer_texts.get(language, footer_texts['ru'])
|
||||
|
||||
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
|
||||
'en': 'Open Dashboard',
|
||||
'zh': '打开控制面板',
|
||||
'ua': 'Відкрити особистий кабінет',
|
||||
'fa': 'باز کردن پنل کاربری',
|
||||
}
|
||||
text = texts.get(language, texts['en'])
|
||||
|
||||
|
||||
+3
-20
@@ -339,12 +339,6 @@ class Settings(BaseSettings):
|
||||
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
|
||||
|
||||
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
|
||||
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
|
||||
|
||||
# Показывать предупреждение об активации подписки после пополнения баланса
|
||||
# Если True - после пополнения показывает большое сообщение с кнопками:
|
||||
# "Активировать", "Продлить", "Добавить устройства"
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
|
||||
|
||||
# Отключение превью ссылок в сообщениях бота
|
||||
DISABLE_WEB_PAGE_PREVIEW: bool = False
|
||||
@@ -409,7 +403,6 @@ class Settings(BaseSettings):
|
||||
PAL24_SIGNATURE_TOKEN: str | None = None
|
||||
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
|
||||
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
|
||||
PAL24_WEBHOOK_PORT: int = 8084
|
||||
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
|
||||
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
|
||||
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
|
||||
@@ -531,7 +524,7 @@ class Settings(BaseSettings):
|
||||
SKIP_REFERRAL_CODE: bool = False
|
||||
|
||||
DEFAULT_LANGUAGE: str = 'ru'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en'
|
||||
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
|
||||
LANGUAGE_SELECTION_ENABLED: bool = True
|
||||
|
||||
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
|
||||
@@ -1183,22 +1176,12 @@ class Settings(BaseSettings):
|
||||
|
||||
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 is_quick_amount_buttons_enabled(self) -> bool:
|
||||
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
|
||||
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
|
||||
|
||||
def get_available_languages(self) -> list[str]:
|
||||
defaults = ['ru', 'en', 'ua', 'zh']
|
||||
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
|
||||
|
||||
try:
|
||||
langs = self.AVAILABLE_LANGUAGES
|
||||
@@ -2449,7 +2432,7 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_bot_run_mode(self) -> str:
|
||||
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
|
||||
if mode not in {'polling', 'webhook', 'both'}:
|
||||
if mode not in {'polling', 'webhook'}:
|
||||
return 'polling'
|
||||
return mode
|
||||
|
||||
|
||||
@@ -95,6 +95,25 @@ async def create_trial_subscription(
|
||||
|
||||
end_date = datetime.utcnow() + timedelta(days=duration_days)
|
||||
|
||||
# Check for existing PENDING trial subscription (retry after failed payment)
|
||||
existing = await get_subscription_by_user_id(db, user_id)
|
||||
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
|
||||
existing.status = SubscriptionStatus.ACTIVE.value
|
||||
existing.start_date = datetime.utcnow()
|
||||
existing.end_date = end_date
|
||||
existing.traffic_limit_gb = traffic_limit_gb
|
||||
existing.device_limit = device_limit
|
||||
existing.connected_squads = final_squads
|
||||
existing.tariff_id = tariff_id
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
logger.info(
|
||||
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
|
||||
existing.id,
|
||||
user_id,
|
||||
)
|
||||
return existing
|
||||
|
||||
subscription = Subscription(
|
||||
user_id=user_id,
|
||||
status=SubscriptionStatus.ACTIVE.value,
|
||||
|
||||
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
|
||||
normalized = (language or '').strip().lower()
|
||||
if '-' in normalized:
|
||||
normalized = normalized.split('-', 1)[0]
|
||||
return normalized or fallback
|
||||
|
||||
|
||||
def _build_spending_stats_select():
|
||||
"""
|
||||
Возвращает базовый SELECT для статистики трат пользователей.
|
||||
@@ -232,6 +239,7 @@ async def create_user_no_commit(
|
||||
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
promo_group_id = default_group.id
|
||||
@@ -243,7 +251,7 @@ async def create_user_no_commit(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -277,6 +285,7 @@ async def create_user(
|
||||
) -> User:
|
||||
if not referral_code:
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
|
||||
attempts = 3
|
||||
|
||||
@@ -291,7 +300,7 @@ async def create_user(
|
||||
username=username,
|
||||
first_name=safe_first,
|
||||
last_name=safe_last,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
|
||||
for field, value in kwargs.items():
|
||||
if field in ('first_name', 'last_name'):
|
||||
value = sanitize_telegram_name(value)
|
||||
if field == 'language':
|
||||
value = _normalize_language_code(value)
|
||||
if hasattr(user, field):
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -1060,6 +1071,7 @@ async def create_user_by_email(
|
||||
Created User object
|
||||
"""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
user = User(
|
||||
@@ -1071,7 +1083,7 @@ async def create_user_by_email(
|
||||
username=None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referred_by_id=referred_by_id,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
@@ -1283,6 +1295,7 @@ async def create_user_by_oauth(
|
||||
) -> User:
|
||||
"""Create a new user via OAuth provider."""
|
||||
referral_code = await create_unique_referral_code(db)
|
||||
normalized_language = _normalize_language_code(language)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
|
||||
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
|
||||
@@ -1297,7 +1310,7 @@ async def create_user_by_oauth(
|
||||
username=sanitize_telegram_name(username) if username else None,
|
||||
first_name=sanitize_telegram_name(first_name) if first_name else None,
|
||||
last_name=sanitize_telegram_name(last_name) if last_name else None,
|
||||
language=language,
|
||||
language=normalized_language,
|
||||
referral_code=referral_code,
|
||||
balance_kopeks=0,
|
||||
has_had_paid_subscription=False,
|
||||
|
||||
Vendored
-166
@@ -1,166 +0,0 @@
|
||||
"""Flask webhook server for PayPalych callbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from asyncio import AbstractEventLoop
|
||||
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
||||
from typing import Any
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
from werkzeug.serving import make_server
|
||||
|
||||
from app.config import settings
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.pal24_service import Pal24APIError, Pal24Service
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_payload() -> dict[str, str]:
|
||||
if request.is_json:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload)
|
||||
return {}
|
||||
|
||||
if request.form:
|
||||
return {k: v for k, v in request.form.items()}
|
||||
|
||||
try:
|
||||
raw_body = request.data.decode('utf-8')
|
||||
if raw_body:
|
||||
payload = json.loads(raw_body)
|
||||
if isinstance(payload, dict):
|
||||
return {k: str(v) for k, v in payload.items()}
|
||||
except json.JSONDecodeError:
|
||||
logger.debug('Pal24 webhook body не удалось распарсить как JSON')
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def create_pal24_flask_app(
|
||||
payment_service: PaymentService,
|
||||
loop: AbstractEventLoop,
|
||||
) -> Flask:
|
||||
pal24_service = Pal24Service()
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST'])
|
||||
def pal24_webhook() -> tuple:
|
||||
if not pal24_service.is_configured:
|
||||
logger.error('Pal24 webhook получен, но сервис не настроен')
|
||||
return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503
|
||||
|
||||
logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers))
|
||||
|
||||
payload = _normalize_payload()
|
||||
if not payload:
|
||||
logger.warning('Пустой Pal24 webhook')
|
||||
return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400
|
||||
|
||||
try:
|
||||
parsed_payload = pal24_service.parse_callback(payload)
|
||||
except Pal24APIError as error:
|
||||
logger.error('Ошибка валидации Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': str(error)}), 400
|
||||
|
||||
async def process() -> bool:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
return await payment_service.process_pal24_callback(db, parsed_payload)
|
||||
except Exception:
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
try:
|
||||
future = asyncio.run_coroutine_threadsafe(process(), loop)
|
||||
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
|
||||
except FuturesTimeoutError:
|
||||
logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT)
|
||||
return jsonify({'status': 'error', 'reason': 'timeout'}), 504
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error)
|
||||
return jsonify({'status': 'error', 'reason': 'internal_error'}), 500
|
||||
|
||||
if processed:
|
||||
return jsonify({'status': 'ok'}), 200
|
||||
return jsonify({'status': 'error', 'reason': 'not_processed'}), 400
|
||||
|
||||
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET'])
|
||||
def pal24_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'enabled': settings.is_pal24_enabled(),
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route('/pal24/health', methods=['GET'])
|
||||
def pal24_additional_health() -> tuple:
|
||||
return jsonify(
|
||||
{
|
||||
'status': 'ok',
|
||||
'service': 'pal24_webhook',
|
||||
'path': settings.PAL24_WEBHOOK_PATH,
|
||||
}
|
||||
), 200
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class Pal24WebhookServer:
|
||||
"""Threaded Flask server for Pal24 callbacks."""
|
||||
|
||||
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
|
||||
self.app = create_pal24_flask_app(payment_service, loop)
|
||||
self._server: Any | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._server:
|
||||
logger.warning('Pal24 webhook server уже запущен')
|
||||
return
|
||||
|
||||
self._server = make_server(
|
||||
host='0.0.0.0',
|
||||
port=settings.PAL24_WEBHOOK_PORT,
|
||||
app=self.app,
|
||||
threaded=True,
|
||||
)
|
||||
|
||||
def _serve() -> None:
|
||||
logger.info(
|
||||
'Pal24 webhook сервер запущен на %s:%s%s',
|
||||
'0.0.0.0',
|
||||
settings.PAL24_WEBHOOK_PORT,
|
||||
settings.PAL24_WEBHOOK_PATH,
|
||||
)
|
||||
self._server.serve_forever()
|
||||
|
||||
self._thread = threading.Thread(target=_serve, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server:
|
||||
logger.info('Останавливаем Pal24 webhook сервер')
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
|
||||
loop = asyncio.get_running_loop()
|
||||
server = Pal24WebhookServer(payment_service, loop)
|
||||
await loop.run_in_executor(None, server.start)
|
||||
return server
|
||||
@@ -2602,8 +2602,15 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
with_active_sub = sum(1 for u in inactive_users if u.subscription and u.subscription.is_active)
|
||||
will_delete = len(inactive_users) - with_active_sub
|
||||
|
||||
text = '🗑️ <b>Неактивные пользователи</b>\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n'
|
||||
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n'
|
||||
if with_active_sub > 0:
|
||||
text += f'🛡️ С активной подпиской (не будут удалены): {with_active_sub}\n'
|
||||
text += f'🗑️ Будет удалено: {will_delete}\n'
|
||||
text += '\n'
|
||||
|
||||
for user in inactive_users[:10]:
|
||||
if user.telegram_id:
|
||||
@@ -2612,7 +2619,9 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
|
||||
else:
|
||||
user_link = f'<b>{user.full_name}</b>'
|
||||
user_id_display = user.email or f'#{user.id}'
|
||||
text += f'👤 {user_link}\n'
|
||||
has_active = user.subscription and user.subscription.is_active
|
||||
sub_badge = ' 🛡️' if has_active else ''
|
||||
text += f'👤 {user_link}{sub_badge}\n'
|
||||
text += f'🆔 <code>{user_id_display}</code>\n'
|
||||
last_activity_display = (
|
||||
format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда'
|
||||
@@ -4255,10 +4264,14 @@ async def _calculate_subscription_period_price(
|
||||
@error_handler
|
||||
async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
user_service = UserService()
|
||||
deleted_count = await user_service.cleanup_inactive_users(db)
|
||||
deleted_count, skipped_count = await user_service.cleanup_inactive_users(db)
|
||||
|
||||
text = f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}'
|
||||
if skipped_count > 0:
|
||||
text += f'\n⏭️ Пропущено (активная подписка): {skipped_count}'
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}',
|
||||
text,
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
|
||||
),
|
||||
|
||||
+80
-19
@@ -22,6 +22,7 @@ from app.database.crud.user import (
|
||||
from app.database.crud.user_message import get_random_active_message
|
||||
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
|
||||
from app.keyboards.inline import (
|
||||
get_back_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_main_menu_keyboard_async,
|
||||
get_post_registration_keyboard,
|
||||
@@ -485,9 +486,24 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию')
|
||||
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy import delete, update as sa_update
|
||||
|
||||
from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
PromoCodeUse,
|
||||
ReferralEarning,
|
||||
SubscriptionServer,
|
||||
Transaction,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
if user.subscription:
|
||||
await decrement_subscription_server_counts(db, user.subscription)
|
||||
@@ -502,9 +518,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
|
||||
|
||||
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
|
||||
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.user_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(
|
||||
sa_update(ReferralEarning)
|
||||
.where(ReferralEarning.referral_id == user.id)
|
||||
.values(referral_transaction_id=None)
|
||||
)
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
|
||||
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
|
||||
|
||||
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
|
||||
payment_models = [
|
||||
YooKassaPayment,
|
||||
CryptoBotPayment,
|
||||
HeleketPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
WataPayment,
|
||||
PlategaPayment,
|
||||
CloudPaymentsPayment,
|
||||
FreekassaPayment,
|
||||
KassaAiPayment,
|
||||
]
|
||||
for payment_model in payment_models:
|
||||
await db.execute(
|
||||
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
|
||||
)
|
||||
|
||||
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
|
||||
|
||||
user.status = UserStatus.ACTIVE.value
|
||||
@@ -1450,9 +1494,16 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
|
||||
|
||||
if offer_text and not skip_welcome_offer:
|
||||
try:
|
||||
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
|
||||
user_has_subscription = user.subscription and getattr(user.subscription, 'is_active', False)
|
||||
if user_has_subscription:
|
||||
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
|
||||
else:
|
||||
keyboard = get_post_registration_keyboard(user.language)
|
||||
|
||||
await message.answer(
|
||||
offer_text,
|
||||
reply_markup=get_post_registration_keyboard(user.language),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
|
||||
await _send_pinned_message(message.bot, db, user)
|
||||
@@ -1829,9 +1880,7 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1858,13 +1907,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1924,9 +1974,7 @@ async def required_sub_channel_check(
|
||||
|
||||
menu_text = await get_main_menu_text(user, texts, db)
|
||||
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
is_admin = settings.is_admin(user.telegram_id)
|
||||
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
|
||||
@@ -1953,13 +2001,14 @@ async def required_sub_channel_check(
|
||||
)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=menu_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -1979,19 +2028,18 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_referral_code)
|
||||
else:
|
||||
from aiogram.types import FSInputFile
|
||||
|
||||
from app.utils.message_patch import LOGO_PATH
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE:
|
||||
await bot.send_photo(
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=rules_text,
|
||||
reply_markup=get_rules_keyboard(language),
|
||||
)
|
||||
_cache_logo_file_id(_result)
|
||||
else:
|
||||
await bot.send_message(
|
||||
chat_id=query.from_user.id,
|
||||
@@ -2000,9 +2048,22 @@ async def required_sub_channel_check(
|
||||
)
|
||||
await state.set_state(RegistrationStates.waiting_for_rules_accept)
|
||||
|
||||
except TelegramBadRequest as e:
|
||||
error_msg = str(e).lower()
|
||||
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
|
||||
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
|
||||
else:
|
||||
logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}')
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка в required_sub_channel_check: {e}')
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
try:
|
||||
await query.answer(f'{texts.ERROR}!', show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
|
||||
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
buttons.append(
|
||||
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
|
||||
callback_data='subscription_settings',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
@@ -416,6 +416,7 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
).format(
|
||||
amount=texts.format_price(price),
|
||||
period=period_label,
|
||||
months=period_label,
|
||||
)
|
||||
if total_discount > 0:
|
||||
cost_text += texts.t(
|
||||
|
||||
@@ -3227,6 +3227,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
|
||||
await db.refresh(db_user)
|
||||
|
||||
# Сохраняем ID до начала транзакции (на случай detached session)
|
||||
user_id_snapshot = db_user.id
|
||||
|
||||
# Создаем триальную подписку
|
||||
subscription: Subscription | None = None
|
||||
remnawave_user = None
|
||||
@@ -3388,22 +3391,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Unexpected error during paid trial activation for user %s: %s',
|
||||
db_user.id,
|
||||
user_id_snapshot,
|
||||
error,
|
||||
)
|
||||
# Пытаемся откатить и вернуть деньги
|
||||
if subscription:
|
||||
await rollback_trial_subscription_activation(db, subscription)
|
||||
from app.database.crud.user import add_user_balance
|
||||
# Откатываем сессию чтобы очистить PendingRollbackError
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await add_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
trial_price_kopeks,
|
||||
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
|
||||
transaction_type=TransactionType.REFUND,
|
||||
)
|
||||
await db.refresh(db_user)
|
||||
# Пытаемся вернуть деньги
|
||||
try:
|
||||
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)
|
||||
except Exception as refund_error:
|
||||
logger.error(
|
||||
'Failed to refund trial payment for user %s: %s',
|
||||
user_id_snapshot,
|
||||
refund_error,
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t(
|
||||
|
||||
+18
-6
@@ -247,6 +247,8 @@ _LANGUAGE_DISPLAY_NAMES = {
|
||||
'zh-hant': '🇹🇼 中文 (繁體)',
|
||||
'vi': '🇻🇳 Tiếng Việt',
|
||||
'vi-vn': '🇻🇳 Tiếng Việt',
|
||||
'fa': '🇮🇷 فارسی',
|
||||
'fa-ir': '🇮🇷 فارسی',
|
||||
}
|
||||
|
||||
|
||||
@@ -1789,6 +1791,8 @@ def get_add_traffic_keyboard(
|
||||
from app.utils.pricing_utils import get_remaining_months
|
||||
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
months_multiplier = 1
|
||||
period_text = ''
|
||||
@@ -1826,17 +1830,20 @@ def get_add_traffic_keyboard(
|
||||
total_discount = discount_per_month * months_multiplier
|
||||
|
||||
if gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'♾️ Безлимитный трафик - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'♾️ Unlimited traffic - {total_price // 100} ₽{period_text}'
|
||||
elif language == 'ru':
|
||||
elif use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {total_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {total_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and total_discount > 0:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
@@ -1861,6 +1868,8 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent: Процент скидки
|
||||
"""
|
||||
texts = get_texts(language)
|
||||
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not packages:
|
||||
return InlineKeyboardMarkup(
|
||||
@@ -1888,15 +1897,18 @@ def get_add_traffic_keyboard_from_tariff(
|
||||
discount_percent,
|
||||
)
|
||||
|
||||
period_text = ' /мес' if language == 'ru' else ' /mo'
|
||||
period_text = ' /мес' if use_russian_fallback else ' /mo'
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100} ₽{period_text}'
|
||||
else:
|
||||
text = f'📊 +{gb} GB traffic - {discounted_price // 100} ₽{period_text}'
|
||||
|
||||
if discount_percent > 0 and discount_value > 0:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
if use_russian_fallback:
|
||||
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
|
||||
else:
|
||||
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
|
||||
_copy_locale(template, destination / template.name)
|
||||
return
|
||||
|
||||
for locale_code in ('ru', 'en'):
|
||||
for locale_code in ('ru', 'en', 'fa'):
|
||||
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
|
||||
target_path = destination / f'{locale_code}.json'
|
||||
|
||||
|
||||
@@ -1548,6 +1548,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
|
||||
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
|
||||
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Trial subscription payment",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1569,6 +1569,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
|
||||
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
|
||||
@@ -1479,6 +1479,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.",
|
||||
"TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.",
|
||||
"TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.",
|
||||
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки",
|
||||
"TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу",
|
||||
"TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 З вашого балансу списано {amount}.",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ призупинено</b>\n\nМи не знайшли вашу підписку на наш канал, тому тестову підписку вимкнено.\n\nПідпишіться на канал і натисніть «{check_button}», щоб повернути доступ.",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестова підписка скоро закінчиться!</b>\n\nВаша тестова підписка закінчується через декілька годин.\n\n💎 <b>Не хочете залишитися без VPN?</b>\nПереходьте на повну підписку!\n\n🔥 <b>Спеціальна пропозиція:</b>\n• 30 днів усього за {price}\n• Безлімітний трафік \n• Всі сервери доступні\n• Швидкість до 1ГБіт/сек\n\n⚡️ Встигніть оформити до закінчення тестового періоду!\n",
|
||||
|
||||
@@ -1477,6 +1477,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
@@ -1807,6 +1810,9 @@
|
||||
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
|
||||
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
|
||||
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
|
||||
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
|
||||
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
|
||||
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
|
||||
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
|
||||
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
|
||||
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
|
||||
|
||||
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
|
||||
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
|
||||
),
|
||||
},
|
||||
'fa': {
|
||||
'traffic_pattern': '📊 {size} گیگابایت - {price}',
|
||||
'unlimited_pattern': '📊 نامحدود - {price}',
|
||||
'support_info': (
|
||||
'\n🛟 <b>پشتیبانی</b>\n\n'
|
||||
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
|
||||
'👤 {support_username}\n\n'
|
||||
'• 🎫 ایجاد تیکت\n'
|
||||
'• 📋 تیکتهای من\n'
|
||||
'• 💬 تماس مستقیم\n'
|
||||
),
|
||||
},
|
||||
'en': {
|
||||
'traffic_pattern': '📊 {size} GB - {price}',
|
||||
'unlimited_pattern': '📊 Unlimited - {price}',
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from aiogram.enums import ChatMemberStatus
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from aiogram.types import FSInputFile
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -108,13 +107,17 @@ class MonitoringService:
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
|
||||
try:
|
||||
return await self.bot.send_photo(
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
result = await self.bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
photo=get_logo_media(),
|
||||
caption=text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
_cache_logo_file_id(result)
|
||||
return result
|
||||
except TelegramBadRequest as exc:
|
||||
logger.warning(
|
||||
'Не удалось отправить сообщение с логотипом пользователю %s: %s. Отправляем текстовое сообщение.',
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.cloudpayments_service import CloudPaymentsAPIError
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -262,22 +261,11 @@ class CloudPaymentsPaymentMixin:
|
||||
logger.exception('Ошибка отправки уведомления CloudPayments: %s', error)
|
||||
|
||||
# Auto-purchase if enabled
|
||||
auto_purchase_success = False
|
||||
try:
|
||||
auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка автопокупки после CloudPayments: %s', error)
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
# Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений
|
||||
await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as error:
|
||||
logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error)
|
||||
|
||||
return True
|
||||
|
||||
async def process_cloudpayments_fail_webhook(
|
||||
|
||||
@@ -171,79 +171,18 @@ class PaymentCommonMixin:
|
||||
try:
|
||||
payment_method = payment_method_title or 'Банковская карта (YooKassa)'
|
||||
|
||||
# Проверяем, нужно ли показывать яркое предупреждение об активации
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Определяем статус подписки для выбора правильной кнопки
|
||||
has_active_subscription = False
|
||||
if user_snapshot:
|
||||
try:
|
||||
subscription = user_snapshot.subscription
|
||||
has_active_subscription = bool(
|
||||
subscription
|
||||
and not getattr(subscription, 'is_trial', False)
|
||||
and getattr(subscription, 'is_active', False)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Яркое сообщение с восклицательными знаками
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
|
||||
# Формируем клавиатуру с кнопками действий
|
||||
keyboard_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
# Кнопка активации или продления в зависимости от статуса
|
||||
if has_active_subscription:
|
||||
# Активная платная подписка - показываем продление и изменение устройств
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔄 ПРОДЛИТЬ ПОДПИСКУ',
|
||||
callback_data='subscription_extend',
|
||||
)
|
||||
]
|
||||
)
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='📱 Изменить количество устройств',
|
||||
callback_data='subscription_change_devices',
|
||||
)
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Нет подписки или истекла - показываем только активацию
|
||||
keyboard_rows.append(
|
||||
[
|
||||
build_miniapp_or_callback_button(
|
||||
text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ',
|
||||
callback_data='menu_buy',
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
|
||||
else:
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
# Стандартное сообщение с полной клавиатурой
|
||||
keyboard = await self.build_topup_success_keyboard(user_snapshot)
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(amount_kopeks)}\n'
|
||||
f'💳 Способ: {payment_method}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=telegram_id,
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 (
|
||||
@@ -361,26 +360,7 @@ class CryptoBotPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=bot_instance,
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
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 and not activation_notification_sent:
|
||||
if has_saved_cart and bot_instance:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.freekassa_service import freekassa_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -388,23 +387,7 @@ class FreekassaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -452,22 +451,6 @@ class HeleketPaymentMixin:
|
||||
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),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'Ошибка при работе с автоактивацией для пользователя %s: %s',
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.config import settings
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.kassa_ai_service import kassa_ai_service
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_activate_subscription_after_topup,
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
)
|
||||
from app.utils.payment_logger import payment_logger as logger
|
||||
@@ -339,34 +338,14 @@ class KassaAiPaymentMixin:
|
||||
try:
|
||||
display_name = settings.get_kassa_ai_display_name()
|
||||
|
||||
if settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
# Яркое сообщение для тупых
|
||||
from aiogram import types
|
||||
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔥 АКТИВИРОВАТЬ ПОДПИСКУ', callback_data='menu_buy')],
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Стандартное сообщение (как было раньше)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
keyboard = await self.build_topup_success_keyboard(user)
|
||||
message = (
|
||||
'✅ <b>Пополнение успешно!</b>\n\n'
|
||||
f'💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n'
|
||||
f'💳 Способ: {display_name}\n'
|
||||
f'🆔 Транзакция: {transaction.id}\n\n'
|
||||
'Баланс пополнен автоматически!'
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
user.telegram_id,
|
||||
@@ -404,23 +383,7 @@ class KassaAiPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -390,28 +389,7 @@ class MulenPayPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if (
|
||||
has_saved_cart
|
||||
and getattr(self, 'bot', None)
|
||||
and not activation_notification_sent
|
||||
and user.telegram_id
|
||||
):
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -489,23 +488,7 @@ class Pal24PaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -470,23 +469,7 @@ class PlategaPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -19,7 +19,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -534,26 +533,7 @@ class TelegramStarsMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db,
|
||||
user,
|
||||
bot=getattr(self, 'bot', None),
|
||||
topup_amount=amount_kopeks,
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.t(
|
||||
'BALANCE_TOPUP_CART_REMINDER_DETAILED',
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -575,23 +574,7 @@ class WataPaymentMixin:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
db, user, bot=getattr(self, 'bot', None), topup_amount=payment.amount_kopeks
|
||||
)
|
||||
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) and not activation_notification_sent and user.telegram_id:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
texts = get_texts(user.language)
|
||||
|
||||
@@ -16,7 +16,6 @@ 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.payment_logger import payment_logger as logger
|
||||
@@ -847,78 +846,59 @@ 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) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
# Если включен яркий промпт активации, пропускаем старое уведомление
|
||||
# т.к. оно будет отправлено через _send_payment_success_notification
|
||||
if not settings.SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
if has_saved_cart and getattr(self, 'bot', None) and user.telegram_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
from app.localization.texts import get_texts
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
|
||||
texts = get_texts(user.language)
|
||||
cart_message = texts.BALANCE_TOPUP_CART_REMINDER_DETAILED.format(
|
||||
total_amount=settings.format_price(payment.amount_kopeks)
|
||||
)
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏠 Главное меню',
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
# Создаем клавиатуру с кнопками
|
||||
keyboard = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text=texts.RETURN_TO_SUBSCRIPTION_CHECKOUT,
|
||||
callback_data='return_to_saved_cart',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='💰 Мой баланс',
|
||||
callback_data='menu_balance',
|
||||
)
|
||||
],
|
||||
[
|
||||
types.InlineKeyboardButton(
|
||||
text='🏠 Главное меню',
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n'
|
||||
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=f'✅ Баланс пополнен на {settings.format_price(payment.amount_kopeks)}!\n\n'
|
||||
f'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
f'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
logger.info(
|
||||
f'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю {user.id}'
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'У пользователя %s нет сохраненной корзины, бот недоступен или покупка уже выполнена',
|
||||
user.id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Критическая ошибка при работе с сохраненной корзиной для пользователя {user.id}: {e}',
|
||||
|
||||
@@ -1682,38 +1682,47 @@ class RemnaWaveService:
|
||||
# expire_at приходит в UTC (naive) из _parse_remnawave_date
|
||||
expire_at = self._parse_remnawave_date(expire_at_str)
|
||||
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
# subscription.end_date хранится в локальной таймзоне (MSK)
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
# Обновляем end_date только если пользователь ACTIVE в панели.
|
||||
# Для EXPIRED/DISABLED панель может содержать искусственную дату
|
||||
# (установленную _safe_expire_at_for_panel при sync_users_to_panel),
|
||||
# которая не должна перезаписывать реальную дату окончания подписки.
|
||||
if panel_status == 'ACTIVE':
|
||||
# Конвертируем локальную дату из БД в UTC для корректного сравнения
|
||||
local_end_date_utc = self._local_to_utc(subscription.end_date)
|
||||
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
# КРИТИЧНО: НЕ перезаписываем end_date если локальная дата ПОЗЖЕ
|
||||
# Это защищает от ситуации когда подписка была продлена в боте,
|
||||
# но RemnaWave ещё не получил обновление или вернул старую дату
|
||||
time_diff = abs((local_end_date_utc - expire_at).total_seconds())
|
||||
if time_diff > 60:
|
||||
if expire_at > local_end_date_utc:
|
||||
# RemnaWave имеет более позднюю дату - обновляем
|
||||
# Конвертируем UTC обратно в локальное время для сохранения в БД
|
||||
new_end_date_local = (
|
||||
expire_at.replace(tzinfo=self._utc_timezone)
|
||||
.astimezone(self._panel_timezone)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
logger.info(
|
||||
f'✅ Sync: обновлена end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'{subscription.end_date} -> {new_end_date_local} (разница: {time_diff:.0f}с)'
|
||||
)
|
||||
subscription.end_date = new_end_date_local
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
)
|
||||
else:
|
||||
# Локальная дата позже - НЕ перезаписываем
|
||||
logger.debug(
|
||||
f'⏭️ Sync: end_date для user {getattr(user, "telegram_id", "?")} актуальна: '
|
||||
f'локальная ({subscription.end_date} / UTC: {local_end_date_utc}) >= RemnaWave ({expire_at} UTC)'
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f'⏭️ Sync: пропускаем обновление end_date для user {getattr(user, "telegram_id", "?")}: '
|
||||
f'разница слишком мала ({time_diff:.0f}с < 60с)'
|
||||
f'панель не ACTIVE (статус: {panel_status})'
|
||||
)
|
||||
|
||||
current_time = self._now_utc()
|
||||
|
||||
@@ -1814,340 +1814,4 @@ async def auto_purchase_saved_cart_after_topup(
|
||||
return True
|
||||
|
||||
|
||||
async def auto_activate_subscription_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
topup_amount: int | None = None,
|
||||
) -> tuple[bool, bool]:
|
||||
"""
|
||||
Умная автоактивация после пополнения баланса.
|
||||
|
||||
Работает БЕЗ сохранённой корзины:
|
||||
- Если подписка активна — ничего не делает
|
||||
- Если подписка истекла — продлевает с теми же параметрами
|
||||
- Если подписки нет — создаёт новую с дефолтными параметрами
|
||||
|
||||
Выбирает максимальный период, который можно оплатить из баланса.
|
||||
|
||||
Args:
|
||||
topup_amount: Сумма пополнения в копейках (для отображения в уведомлении)
|
||||
|
||||
Returns:
|
||||
tuple[bool, bool]: (success, notification_sent)
|
||||
- success: True если подписка активирована
|
||||
- notification_sent: True если уведомление отправлено пользователю
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Lazy imports to avoid circular dependency
|
||||
from app.cabinet.routes.websocket import (
|
||||
notify_user_subscription_activated,
|
||||
notify_user_subscription_renewed,
|
||||
)
|
||||
from app.database.crud.server_squad import get_available_server_squads, get_server_ids_by_uuids
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import PaymentMethod, TransactionType
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_renewal_service import SubscriptionRenewalService
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
if not user or not getattr(user, 'id', None):
|
||||
return (False, False)
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Если автоактивация отключена - уведомление отправится из _send_payment_success_notification
|
||||
if not settings.is_auto_activate_after_topup_enabled():
|
||||
logger.info(
|
||||
'⚠️ Автоактивация отключена для пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Если подписка активна — ничего не делаем (автоактивация включена, но подписка уже есть)
|
||||
if subscription and subscription.status == 'ACTIVE' and subscription.end_date > datetime.utcnow():
|
||||
logger.info(
|
||||
'🔁 Автоактивация: у пользователя %s уже активная подписка, пропускаем',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, False)
|
||||
|
||||
# Определяем параметры подписки
|
||||
if subscription:
|
||||
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
traffic_limit_gb = subscription.traffic_limit_gb or 0
|
||||
connected_squads = subscription.connected_squads or []
|
||||
else:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
# В режиме fixed_with_topup при автоактивации используем фиксированный лимит
|
||||
if settings.is_traffic_fixed():
|
||||
traffic_limit_gb = settings.get_fixed_traffic_limit()
|
||||
else:
|
||||
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(settings.get_available_subscription_periods(), reverse=True)
|
||||
|
||||
if not available_periods:
|
||||
logger.warning('🔁 Автоактивация: нет доступных периодов подписки')
|
||||
return (False, 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) для любого периода',
|
||||
_format_user_id(user),
|
||||
balance,
|
||||
)
|
||||
# Уведомление отправится из _send_payment_success_notification
|
||||
logger.info(
|
||||
'⚠️ Недостаточно средств для автоактивации пользователя %s, уведомление будет отправлено из payment service',
|
||||
_format_user_id(user),
|
||||
)
|
||||
return (False, 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)
|
||||
|
||||
result = await renewal_service.finalize(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
pricing,
|
||||
description=f'Автоматическое продление на {best_period} дней',
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автоактивация: подписка пользователя %s продлена на %s дней за %s коп.',
|
||||
_format_user_id(user),
|
||||
best_period,
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=result.subscription.end_date.isoformat() if result.subscription.end_date else '',
|
||||
amount_kopeks=best_price,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление о продлении для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
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 or user.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,
|
||||
_format_user_id(user),
|
||||
best_price,
|
||||
)
|
||||
|
||||
# Send WebSocket notification to cabinet frontend
|
||||
try:
|
||||
await notify_user_subscription_activated(
|
||||
user_id=user.id,
|
||||
expires_at=new_subscription.end_date.isoformat() if new_subscription.end_date else '',
|
||||
tariff_name='',
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось отправить WS уведомление об активации для %s: %s',
|
||||
_format_user_id(user),
|
||||
ws_error,
|
||||
)
|
||||
|
||||
# Уведомление пользователю (только для Telegram-пользователей)
|
||||
if bot and user.telegram_id:
|
||||
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',
|
||||
)
|
||||
|
||||
except Exception as notify_error:
|
||||
logger.warning(
|
||||
'⚠️ Автоактивация: не удалось уведомить пользователя %s: %s',
|
||||
user.telegram_id or user.id,
|
||||
notify_error,
|
||||
)
|
||||
|
||||
# Уведомление админам (независимо от telegram_id)
|
||||
if bot:
|
||||
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,
|
||||
)
|
||||
|
||||
return (True, True) # success=True, notification_sent=True (об активации)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'❌ Автоактивация: ошибка для пользователя %s: %s',
|
||||
_format_user_id(user),
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
await db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return (False, False)
|
||||
|
||||
|
||||
__all__ = ['auto_activate_subscription_after_topup', 'auto_purchase_saved_cart_after_topup']
|
||||
__all__ = ['auto_purchase_saved_cart_after_topup']
|
||||
|
||||
@@ -260,7 +260,6 @@ class BotConfigurationService:
|
||||
'PAYMENT_BALANCE_TEMPLATE': 'PAYMENT',
|
||||
'PAYMENT_SUBSCRIPTION_TEMPLATE': 'PAYMENT',
|
||||
'AUTO_PURCHASE_AFTER_TOPUP_ENABLED': 'PAYMENT',
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': 'PAYMENT',
|
||||
'SIMPLE_SUBSCRIPTION_ENABLED': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_PERIOD_DAYS': 'SIMPLE_SUBSCRIPTION',
|
||||
'SIMPLE_SUBSCRIPTION_DEVICE_LIMIT': 'SIMPLE_SUBSCRIPTION',
|
||||
@@ -585,19 +584,6 @@ class BotConfigurationService:
|
||||
'example': 'true',
|
||||
'warning': ('Используйте с осторожностью: средства будут списаны мгновенно, если корзина найдена.'),
|
||||
},
|
||||
'SHOW_ACTIVATION_PROMPT_AFTER_TOPUP': {
|
||||
'description': (
|
||||
'Включает режим яркого промпта активации подписки после пополнения баланса. '
|
||||
'Вместо обычного уведомления пользователь получит яркое сообщение с восклицательными знаками '
|
||||
'и кнопками для активации/продления подписки или изменения количества устройств.'
|
||||
),
|
||||
'format': 'Булево значение.',
|
||||
'example': 'true',
|
||||
'warning': (
|
||||
'При включении пользователи будут получать только яркое уведомление без кнопок баланса и главного меню. '
|
||||
'Эти кнопки появятся после выполнения действия (активация/продление/изменение устройств).'
|
||||
),
|
||||
},
|
||||
'SUPPORT_TICKET_SLA_MINUTES': {
|
||||
'description': 'Лимит времени для ответа модераторов на тикет в минутах.',
|
||||
'format': 'Целое число от 1 до 1440.',
|
||||
|
||||
@@ -14,7 +14,6 @@ from app.database.models import PaymentMethod, TransactionType
|
||||
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
|
||||
@@ -307,23 +306,8 @@ class TributeService:
|
||||
if auto_purchase_success:
|
||||
has_saved_cart = False
|
||||
|
||||
# Умная автоактивация если автопокупка не сработала
|
||||
activation_notification_sent = False
|
||||
if not auto_purchase_success:
|
||||
try:
|
||||
_, activation_notification_sent = await auto_activate_subscription_after_topup(
|
||||
session, user, bot=self.bot, topup_amount=amount_kopeks
|
||||
)
|
||||
except Exception as auto_activate_error:
|
||||
logger.error(
|
||||
'Ошибка умной автоактивации для пользователя %s: %s',
|
||||
user.id,
|
||||
auto_activate_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Отправляем уведомление только если его ещё не отправили и есть telegram_id
|
||||
if has_saved_cart and self.bot and not activation_notification_sent and user_id:
|
||||
# Отправляем уведомление только если есть сохранённая корзина и telegram_id
|
||||
if has_saved_cart and self.bot and user_id:
|
||||
# Если у пользователя есть сохраненная корзина,
|
||||
# отправляем ему уведомление с кнопкой вернуться к оформлению
|
||||
from aiogram import types
|
||||
|
||||
@@ -1145,25 +1145,41 @@ class UserService:
|
||||
'new_month': 0,
|
||||
}
|
||||
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> int:
|
||||
async def cleanup_inactive_users(self, db: AsyncSession, months: int = None) -> tuple[int, int]:
|
||||
"""Clean up inactive users, skipping those with active subscriptions.
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_count, skipped_active_sub_count).
|
||||
"""
|
||||
try:
|
||||
if months is None:
|
||||
months = settings.INACTIVE_USER_DELETE_MONTHS
|
||||
|
||||
inactive_users = await get_inactive_users(db, months)
|
||||
deleted_count = 0
|
||||
skipped_active_sub = 0
|
||||
|
||||
for user in inactive_users:
|
||||
# Skip users with active paid subscriptions
|
||||
if user.subscription and user.subscription.is_active:
|
||||
skipped_active_sub += 1
|
||||
continue
|
||||
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
deleted_count += 1
|
||||
|
||||
logger.info(f'Удалено {deleted_count} неактивных пользователей')
|
||||
return deleted_count
|
||||
if skipped_active_sub > 0:
|
||||
logger.info(
|
||||
'Пропущено %d неактивных пользователей с активной подпиской',
|
||||
skipped_active_sub,
|
||||
)
|
||||
logger.info('Удалено %d неактивных пользователей', deleted_count)
|
||||
return deleted_count, skipped_active_sub
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Ошибка очистки неактивных пользователей: {e}')
|
||||
return 0
|
||||
logger.error('Ошибка очистки неактивных пользователей: %s', e)
|
||||
return 0, 0
|
||||
|
||||
async def get_user_activity_summary(self, db: AsyncSession, user_id: int) -> dict[str, Any]:
|
||||
try:
|
||||
|
||||
+18
-10
@@ -87,7 +87,8 @@ def format_time_ago(dt: datetime | str, language: str = 'ru') -> str:
|
||||
|
||||
|
||||
def format_days_declension(days: int, language: str = 'ru') -> str:
|
||||
if language != 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code not in {'ru', 'fa'}:
|
||||
return f'{days} day{"s" if days != 1 else ""}'
|
||||
|
||||
if days % 10 == 1 and days % 100 != 11:
|
||||
@@ -180,42 +181,49 @@ def format_subscription_status(is_active: bool, is_trial: bool, end_date: dateti
|
||||
except (ValueError, AttributeError):
|
||||
end_date = datetime.now()
|
||||
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if not is_active:
|
||||
return '❌ Неактивна' if language == 'ru' else '❌ Inactive'
|
||||
return '❌ Неактивна' if use_russian_fallback else '❌ Inactive'
|
||||
|
||||
if is_trial:
|
||||
status = '🎁 Тестовая' if language == 'ru' else '🎁 Trial'
|
||||
status = '🎁 Тестовая' if use_russian_fallback else '🎁 Trial'
|
||||
else:
|
||||
status = '✅ Активна' if language == 'ru' else '✅ Active'
|
||||
status = '✅ Активна' if use_russian_fallback else '✅ Active'
|
||||
|
||||
now = datetime.utcnow()
|
||||
if end_date > now:
|
||||
days_left = (end_date - now).days
|
||||
if days_left > 0:
|
||||
status += f' ({days_left} дн.)' if language == 'ru' else f' ({days_left} days)'
|
||||
status += f' ({days_left} дн.)' if use_russian_fallback else f' ({days_left} days)'
|
||||
else:
|
||||
hours_left = (end_date - now).seconds // 3600
|
||||
status += f' ({hours_left} ч.)' if language == 'ru' else f' ({hours_left} hrs)'
|
||||
status += f' ({hours_left} ч.)' if use_russian_fallback else f' ({hours_left} hrs)'
|
||||
else:
|
||||
status = '⏰ Истекла' if language == 'ru' else '⏰ Expired'
|
||||
status = '⏰ Истекла' if use_russian_fallback else '⏰ Expired'
|
||||
|
||||
return status
|
||||
|
||||
|
||||
def format_traffic_usage(used_gb: float, limit_gb: int, language: str = 'ru') -> str:
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
use_russian_fallback = language_code in {'ru', 'fa'}
|
||||
|
||||
if limit_gb == 0:
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / ∞'
|
||||
return f'{used_gb:.1f} GB / ∞'
|
||||
|
||||
percentage = (used_gb / limit_gb) * 100 if limit_gb > 0 else 0
|
||||
|
||||
if language == 'ru':
|
||||
if use_russian_fallback:
|
||||
return f'{used_gb:.1f} ГБ / {limit_gb} ГБ ({percentage:.1f}%)'
|
||||
return f'{used_gb:.1f} GB / {limit_gb} GB ({percentage:.1f}%)'
|
||||
|
||||
|
||||
def format_boolean(value: bool, language: str = 'ru') -> str:
|
||||
if language == 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code in {'ru', 'fa'}:
|
||||
return '✅ Да' if value else '❌ Нет'
|
||||
return '✅ Yes' if value else '❌ No'
|
||||
|
||||
@@ -81,7 +81,6 @@ class PaymentLogFilter(logging.Filter):
|
||||
'app.external.heleket',
|
||||
'app.external.tribute',
|
||||
'app.external.yookassa_webhook',
|
||||
'app.external.pal24_webhook',
|
||||
'app.external.wata_webhook',
|
||||
'app.external.heleket_webhook',
|
||||
)
|
||||
|
||||
@@ -10,6 +10,28 @@ from app.localization.texts import get_texts
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
_PRIVACY_RESTRICTED_CODE = 'BUTTON_USER_PRIVACY_RESTRICTED'
|
||||
|
||||
# Кеш file_id логотипа: после первой загрузки Telegram возвращает file_id,
|
||||
# который можно переиспользовать без повторной загрузки файла (экономит 3-4 сек)
|
||||
_logo_file_id: str | None = None
|
||||
|
||||
|
||||
def get_logo_media():
|
||||
"""Возвращает кешированный file_id или FSInputFile для логотипа."""
|
||||
if _logo_file_id:
|
||||
return _logo_file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
|
||||
|
||||
def _cache_logo_file_id(result: Message | None) -> None:
|
||||
"""Извлекает и кеширует file_id логотипа из ответа Telegram."""
|
||||
global _logo_file_id
|
||||
if _logo_file_id or result is None:
|
||||
return
|
||||
if hasattr(result, 'photo') and result.photo:
|
||||
_logo_file_id = result.photo[-1].file_id
|
||||
|
||||
|
||||
_TOPIC_REQUIRED_ERRORS = (
|
||||
'topic must be specified',
|
||||
'TOPIC_CLOSED',
|
||||
@@ -110,8 +132,9 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
|
||||
if LOGO_PATH.exists():
|
||||
try:
|
||||
# Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
result = await self.answer_photo(get_logo_media(), caption=text, **kwargs)
|
||||
_cache_logo_file_id(result)
|
||||
return result
|
||||
except TelegramBadRequest as error:
|
||||
if is_topic_required_error(error):
|
||||
# Канал с топиками — просто игнорируем, нельзя ответить без message_thread_id
|
||||
@@ -163,12 +186,8 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if (settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self)) or (
|
||||
is_qr_message(self) and LOGO_PATH.exists()
|
||||
):
|
||||
media = FSInputFile(LOGO_PATH)
|
||||
if LOGO_PATH.exists():
|
||||
media = get_logo_media()
|
||||
else:
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {'media': media, 'caption': text}
|
||||
|
||||
+22
-14
@@ -2,14 +2,16 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from aiogram import types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramNetworkError
|
||||
from aiogram.types import FSInputFile, InaccessibleMessage, InputMediaPhoto
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||
from aiogram.types import InaccessibleMessage, InputMediaPhoto
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from .message_patch import (
|
||||
LOGO_PATH,
|
||||
_cache_logo_file_id,
|
||||
append_privacy_hint,
|
||||
get_logo_media,
|
||||
is_privacy_restricted_error,
|
||||
is_qr_message,
|
||||
prepare_privacy_safe_kwargs,
|
||||
@@ -23,17 +25,13 @@ RETRY_DELAY = 0.5
|
||||
|
||||
|
||||
def _resolve_media(message: types.Message):
|
||||
# Если сообщение недоступно, возвращаем логотип по умолчанию
|
||||
if isinstance(message, InaccessibleMessage):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
return get_logo_media()
|
||||
if settings.ENABLE_LOGO_MODE and not is_qr_message(message):
|
||||
return FSInputFile(LOGO_PATH)
|
||||
# Только если режим логотипа выключен, используем фото из сообщения
|
||||
return get_logo_media()
|
||||
if message.photo:
|
||||
return message.photo[-1].file_id
|
||||
return FSInputFile(LOGO_PATH)
|
||||
return get_logo_media()
|
||||
|
||||
|
||||
def _get_language(callback: types.CallbackQuery) -> str | None:
|
||||
@@ -91,12 +89,13 @@ async def edit_or_answer_photo(
|
||||
if isinstance(callback.message, InaccessibleMessage):
|
||||
try:
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists():
|
||||
await callback.message.answer_photo(
|
||||
photo=FSInputFile(LOGO_PATH),
|
||||
result = await callback.message.answer_photo(
|
||||
photo=get_logo_media(),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
_cache_logo_file_id(result)
|
||||
else:
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
@@ -127,6 +126,8 @@ async def edit_or_answer_photo(
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramForbiddenError:
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем')
|
||||
except TelegramBadRequest as error:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
@@ -141,6 +142,8 @@ async def edit_or_answer_photo(
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
except TelegramForbiddenError:
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем')
|
||||
except TelegramBadRequest as error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, error)
|
||||
return
|
||||
@@ -168,6 +171,10 @@ async def edit_or_answer_photo(
|
||||
pass
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode)
|
||||
return
|
||||
except TelegramForbiddenError:
|
||||
# Пользователь заблокировал бота — молча игнорируем
|
||||
logger.debug('Пользователь заблокировал бота, пропускаем edit_media')
|
||||
return
|
||||
except TelegramBadRequest as error:
|
||||
if is_privacy_restricted_error(error):
|
||||
try:
|
||||
@@ -183,13 +190,14 @@ async def edit_or_answer_photo(
|
||||
pass
|
||||
try:
|
||||
# Отправим как фото с логотипом
|
||||
await callback.message.answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
result = await callback.message.answer_photo(
|
||||
photo=get_logo_media(),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=resolved_parse_mode,
|
||||
)
|
||||
except TelegramBadRequest as photo_error:
|
||||
_cache_logo_file_id(result)
|
||||
except (TelegramBadRequest, TelegramForbiddenError) as photo_error:
|
||||
await _answer_text(callback, caption, keyboard, resolved_parse_mode, photo_error)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
|
||||
@@ -307,7 +307,8 @@ def _pluralize_days_ru(n: int) -> str:
|
||||
|
||||
|
||||
def format_period_description(days: int, language: str = 'ru') -> str:
|
||||
if language == 'ru':
|
||||
language_code = (language or 'ru').split('-')[0].lower()
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if days == 30:
|
||||
return '1 месяц'
|
||||
if days == 60:
|
||||
|
||||
@@ -4054,7 +4054,7 @@ async def activate_subscription_trial_endpoint(
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
charged_amount_label = settings.format_price(charged_amount) if charged_amount > 0 else None
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if duration_days:
|
||||
message = f'Триал активирован на {duration_days} дн. Приятного пользования!'
|
||||
else:
|
||||
@@ -4065,7 +4065,7 @@ async def activate_subscription_trial_endpoint(
|
||||
message = 'Trial activated successfully. Enjoy!'
|
||||
|
||||
if charged_amount_label:
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
message = f'{message}\n\n💳 С вашего баланса списано {charged_amount_label}.'
|
||||
else:
|
||||
message = f'{message}\n\n💳 {charged_amount_label} has been deducted from your balance.'
|
||||
@@ -4476,7 +4476,7 @@ def _normalize_language_code(user: User | None) -> str:
|
||||
|
||||
def _build_renewal_status_message(user: User | None) -> str:
|
||||
language_code = _normalize_language_code(user)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
return 'Стоимость указана с учётом ваших текущих серверов, трафика и устройств.'
|
||||
return 'Prices already include your current servers, traffic, and devices.'
|
||||
|
||||
@@ -4493,7 +4493,7 @@ def _build_promo_offer_payload(user: User | None) -> dict[str, Any] | None:
|
||||
payload['expires_at'] = expires_at
|
||||
|
||||
language_code = _normalize_language_code(user)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
payload['message'] = 'Дополнительная скидка применяется автоматически.'
|
||||
else:
|
||||
payload['message'] = 'Extra discount is applied automatically.'
|
||||
@@ -4527,7 +4527,7 @@ def _build_renewal_success_message(
|
||||
amount_label = settings.format_price(max(0, charged_amount))
|
||||
date_label = format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M') if subscription.end_date else ''
|
||||
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if charged_amount > 0:
|
||||
message = (
|
||||
f'Подписка продлена до {date_label}. ' if date_label else 'Подписка продлена. '
|
||||
@@ -4543,7 +4543,7 @@ def _build_renewal_success_message(
|
||||
|
||||
if promo_discount_value > 0:
|
||||
discount_label = settings.format_price(promo_discount_value)
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
message += f' Применена дополнительная скидка {discount_label}.'
|
||||
else:
|
||||
message += f' Promo discount applied: {discount_label}.'
|
||||
@@ -4560,7 +4560,7 @@ def _build_renewal_pending_message(
|
||||
amount_label = settings.format_price(max(0, missing_amount))
|
||||
method_title = _format_payment_method_title(method)
|
||||
|
||||
if language_code == 'ru':
|
||||
if language_code in {'ru', 'fa'}:
|
||||
if method_title:
|
||||
return (
|
||||
f'Недостаточно средств на балансе. Доплатите {amount_label} через {method_title}, '
|
||||
|
||||
@@ -190,13 +190,16 @@ async def create_promocode_endpoint(
|
||||
|
||||
creator_id = payload.created_by if payload.created_by is not None and payload.created_by > 0 else None
|
||||
|
||||
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
|
||||
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
promocode = await create_promocode(
|
||||
db,
|
||||
code=normalized_code,
|
||||
type=payload.type,
|
||||
balance_bonus_kopeks=payload.balance_bonus_kopeks,
|
||||
subscription_days=payload.subscription_days,
|
||||
max_uses=payload.max_uses,
|
||||
max_uses=effective_max_uses,
|
||||
valid_until=normalized_valid_until,
|
||||
created_by=creator_id,
|
||||
)
|
||||
@@ -248,7 +251,7 @@ async def update_promocode_endpoint(
|
||||
updates['subscription_days'] = payload.subscription_days
|
||||
|
||||
if payload.max_uses is not None:
|
||||
updates['max_uses'] = payload.max_uses
|
||||
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
|
||||
|
||||
if payload.valid_from is not None:
|
||||
updates['valid_from'] = _normalize_datetime(payload.valid_from)
|
||||
|
||||
@@ -170,9 +170,6 @@
|
||||
- `app/external/pal24_client.py` — Async client for PayPalych (Pal24) API.
|
||||
Классы: `Pal24APIError` — Base error for Pal24 API operations., `Pal24Response` (2 методов) — Wrapper for Pal24 API responses., `Pal24Client` (5 методов) — Async client implementing PayPalych API methods.
|
||||
Функции: нет
|
||||
- `app/external/pal24_webhook.py` — Flask webhook server for PayPalych callbacks.
|
||||
Классы: `Pal24WebhookServer` (3 методов) — Threaded Flask server for Pal24 callbacks.
|
||||
Функции: `_normalize_payload`, `create_pal24_flask_app`
|
||||
- `app/external/remnawave_api.py` — Python-модуль
|
||||
Классы: `UserStatus`, `TrafficLimitStrategy`, `RemnaWaveUser`, `RemnaWaveInternalSquad`, `RemnaWaveNode`, `SubscriptionInfo`, `RemnaWaveAPIError` (1 методов), `RemnaWaveAPI` (8 методов)
|
||||
Функции: `format_bytes`, `parse_bytes`
|
||||
|
||||
@@ -254,31 +254,31 @@ setInterval(() => {
|
||||
### Python Webhook receiver
|
||||
|
||||
```python
|
||||
from flask import Flask, request
|
||||
from fastapi import FastAPI, Request, HTTPException
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
app = Flask(__name__)
|
||||
app = FastAPI()
|
||||
WEBHOOK_SECRET = "your-secret"
|
||||
|
||||
@app.route('/webhook', methods=['POST'])
|
||||
def webhook():
|
||||
@app.post('/webhook')
|
||||
async def webhook(request: Request):
|
||||
signature = request.headers.get('X-Webhook-Signature', '')
|
||||
event_type = request.headers.get('X-Webhook-Event')
|
||||
payload = request.json
|
||||
|
||||
payload = await request.json()
|
||||
|
||||
# Проверка подписи
|
||||
if not verify_signature(payload, signature, WEBHOOK_SECRET):
|
||||
return {'error': 'Invalid signature'}, 401
|
||||
|
||||
raise HTTPException(status_code=401, detail='Invalid signature')
|
||||
|
||||
# Обработка события
|
||||
if event_type == 'user.created':
|
||||
handle_new_user(payload)
|
||||
elif event_type == 'payment.completed':
|
||||
handle_payment(payload)
|
||||
|
||||
return {'status': 'ok'}, 200
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
def verify_signature(payload, signature, secret):
|
||||
payload_json = json.dumps(payload, sort_keys=True)
|
||||
|
||||
@@ -517,8 +517,8 @@ async def main():
|
||||
logger.error('❌ Ошибка подготовки внешней админки: %s', error)
|
||||
|
||||
bot_run_mode = settings.get_bot_run_mode()
|
||||
polling_enabled = bot_run_mode in {'polling', 'both'}
|
||||
telegram_webhook_enabled = bot_run_mode in {'webhook', 'both'}
|
||||
polling_enabled = bot_run_mode == 'polling'
|
||||
telegram_webhook_enabled = bot_run_mode == 'webhook'
|
||||
|
||||
payment_webhooks_enabled = any(
|
||||
[
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
@@ -21,7 +21,6 @@ dependencies = [
|
||||
'packaging>=23.2',
|
||||
'bcrypt>=4.2.0',
|
||||
'pyjwt>=2.8.0',
|
||||
'flask>=3.1.0',
|
||||
'pyzipper>=0.3.6',
|
||||
]
|
||||
|
||||
|
||||
@@ -46,8 +46,5 @@ packaging==23.2
|
||||
|
||||
aiofiles==23.2.1
|
||||
|
||||
# Вебхуки PayPalych (Flask)
|
||||
Flask==3.1.0
|
||||
|
||||
# Архивирование с паролем
|
||||
pyzipper==0.3.6
|
||||
|
||||
@@ -5,71 +5,6 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def test_notification_message_bright_prompt():
|
||||
"""
|
||||
Тест: проверяем что формируется ЯРКОЕ сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=true.
|
||||
"""
|
||||
|
||||
# Эмулируем код из kassa_ai.py
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = True
|
||||
display_name = 'Kassa AI'
|
||||
amount_formatted = '10₽'
|
||||
|
||||
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {amount_formatted}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'💎 Средства зачислены на ваш баланс!\n\n'
|
||||
'‼️ <b>ВНИМАНИЕ! ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ!</b> ‼️\n\n'
|
||||
'⚠️ Пополнение баланса <b>НЕ АКТИВИРУЕТ</b> подписку автоматически!\n\n'
|
||||
'👇 <b>НАЖМИТЕ КНОПКУ НИЖЕ ДЛЯ АКТИВАЦИИ</b> 👇'
|
||||
)
|
||||
else:
|
||||
message = ''
|
||||
|
||||
# Проверки
|
||||
assert '‼️' in message
|
||||
assert 'ВНИМАНИЕ' in message
|
||||
assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' in message
|
||||
assert '👇' in message
|
||||
assert display_name in message
|
||||
assert amount_formatted in message
|
||||
print(f'\n✅ ЯРКОЕ сообщение сформировано правильно:\n{message}')
|
||||
|
||||
|
||||
def test_notification_message_standard():
|
||||
"""
|
||||
Тест: проверяем что формируется обычное сообщение с SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false.
|
||||
"""
|
||||
# Эмулируем код из kassa_ai.py
|
||||
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP = False
|
||||
display_name = 'Kassa AI'
|
||||
amount_formatted = '10₽'
|
||||
|
||||
if SHOW_ACTIVATION_PROMPT_AFTER_TOPUP:
|
||||
message = ''
|
||||
else:
|
||||
message = (
|
||||
'✅ <b>Платеж успешно завершен!</b>\n\n'
|
||||
f'💰 Сумма: {amount_formatted}\n'
|
||||
f'💳 Способ: {display_name}\n\n'
|
||||
'Средства зачислены на ваш баланс!\n\n'
|
||||
'⚠️ <b>Важно:</b> Пополнение баланса не активирует подписку автоматически. '
|
||||
'Обязательно активируйте подписку отдельно!\n\n'
|
||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||
f'подписка будет приобретена автоматически после пополнения баланса.'
|
||||
)
|
||||
|
||||
# Проверки
|
||||
assert '‼️' not in message
|
||||
assert 'ОБЯЗАТЕЛЬНО АКТИВИРУЙТЕ ПОДПИСКУ' not in message
|
||||
assert 'Платеж успешно завершен' in message
|
||||
assert display_name in message
|
||||
assert amount_formatted in message
|
||||
print(f'\n✅ Обычное сообщение сформировано правильно:\n{message}')
|
||||
|
||||
|
||||
def test_telegram_id_saved_before_commit():
|
||||
"""
|
||||
Тест: проверяем что telegram_id сохраняется в локальную переменную ДО commit.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def test_available_languages_default_contains_fa(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', '', raising=False)
|
||||
languages = settings.get_available_languages()
|
||||
assert 'fa' in languages
|
||||
|
||||
|
||||
def test_available_languages_normalizes_and_deduplicates(monkeypatch):
|
||||
monkeypatch.setattr(settings, 'AVAILABLE_LANGUAGES', 'ru,en,fa,FA,fa-IR', raising=False)
|
||||
languages = settings.get_available_languages()
|
||||
assert languages[0] == 'ru'
|
||||
assert 'en' in languages
|
||||
assert 'fa' in languages
|
||||
assert 'FA' not in languages
|
||||
@@ -31,6 +31,12 @@ def test_format_days_declension_handles_russian_rules() -> None:
|
||||
assert formatters.format_days_declension(10) == '10 дней'
|
||||
|
||||
|
||||
def test_format_days_declension_uses_russian_fallback_for_fa() -> None:
|
||||
"""Для fa используем fallback на русские формы до полной локализации."""
|
||||
assert formatters.format_days_declension(1, language='fa') == '1 день'
|
||||
assert formatters.format_days_declension(3, language='fa') == '3 дня'
|
||||
|
||||
|
||||
def test_format_duration_switches_units() -> None:
|
||||
"""В зависимости от длины интервала выбирается подходящая единица измерения."""
|
||||
assert formatters.format_duration(45) == '45 сек.'
|
||||
@@ -102,3 +108,9 @@ def test_format_boolean_localises_output() -> None:
|
||||
"""Булевые значения отображаются локализованными словами."""
|
||||
assert formatters.format_boolean(True, language='ru') == '✅ Да'
|
||||
assert formatters.format_boolean(False, language='en') == '❌ No'
|
||||
|
||||
|
||||
def test_format_boolean_uses_russian_fallback_for_fa() -> None:
|
||||
"""Для fa булевы значения пока используют базовый ru fallback."""
|
||||
assert formatters.format_boolean(True, language='fa') == '✅ Да'
|
||||
assert formatters.format_boolean(False, language='fa') == '❌ Нет'
|
||||
|
||||
@@ -214,15 +214,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.1.4"
|
||||
@@ -509,23 +500,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "frozenlist"
|
||||
version = "1.8.0"
|
||||
@@ -653,15 +627,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
@@ -1149,7 +1114,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remnawave-bedolaga-telegram-bot"
|
||||
version = "3.6.0"
|
||||
version = "3.8.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiogram" },
|
||||
@@ -1159,7 +1124,6 @@ dependencies = [
|
||||
{ name = "bcrypt" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "flask" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
@@ -1188,7 +1152,6 @@ requires-dist = [
|
||||
{ name = "bcrypt", specifier = ">=4.2.0" },
|
||||
{ name = "cryptography", specifier = ">=41.0.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
|
||||
{ name = "flask", specifier = ">=3.1.0" },
|
||||
{ name = "packaging", specifier = ">=23.2" },
|
||||
{ name = "pyjwt", specifier = ">=2.8.0" },
|
||||
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
|
||||
@@ -1501,18 +1464,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wrapt"
|
||||
version = "2.0.1"
|
||||
|
||||
Reference in New Issue
Block a user