Compare commits

...

19 Commits

Author SHA1 Message Date
Egor 6488dcfcb2 Merge pull request #2584 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.9.0
2026-02-09 23:08:05 +03:00
github-actions[bot] 9ec5f7f59e chore(main): release 3.9.0 2026-02-09 20:07:13 +00:00
Egor 0621a3febc Merge pull request #2582 from BEDOLAGA-DEV/dev
Release: remove auto-activation, Flask cleanup, production bug fixes
2026-02-09 22:42:36 +03:00
Fringg ebd6bee05e feat: allow tariff deletion with active subscriptions
Remove blocking check that prevented tariff deletion when subscriptions
exist. DB schema already supports SET NULL on tariff FK, so subscriptions
gracefully become "legacy" and users pick a new tariff on renewal.
Return affected_subscriptions count in API response.
2026-02-09 22:30:26 +03:00
Fringg 119f463c36 refactor: remove Flask, use FastAPI exclusively for all webhooks
Delete dead Flask-based PAL24 webhook server (app/external/pal24_webhook.py).
PAL24 webhooks already handled by unified FastAPI server on port 8080.

- Remove flask dependency from pyproject.toml and requirements.txt
- Remove PAL24_WEBHOOK_PORT config (unused, FastAPI uses shared port)
- Remove pal24_webhook module reference from log filter
- Update docs: webhook example rewritten from Flask to FastAPI
- Uninstall flask, werkzeug, blinker, itsdangerous
2026-02-09 21:54:15 +03:00
Fringg a3903a252e refactor: remove smart auto-activation & activation prompt, fix production bugs
Remove AUTO_ACTIVATE_AFTER_TOPUP and SHOW_ACTIVATION_PROMPT_AFTER_TOPUP
features from all payment providers, config, system settings, and tests.
Cart auto-purchase (AUTO_PURCHASE_AFTER_TOPUP) is preserved.

Bug fixes:
- fix KeyError 'months' in devices.py for custom locale overrides
- fix IntegrityError on trial subscription retry (update existing PENDING instead of INSERT)
- fix PendingRollbackError cascade by adding db.rollback() before recovery
- fix TelegramForbiddenError not caught in photo_message.py
- fix "query is too old" spam in required_sub_channel_check
- add missing trial locale keys (TRIAL_PAYMENT_DESCRIPTION, TRIAL_REFUND_DESCRIPTION, TRIAL_ACTIVATION_ERROR)
2026-02-09 21:39:53 +03:00
Egor 65ba50c2cf Merge pull request #2547 from DenyaBanan/patch-1
Fix 401 error
2026-02-09 21:10:04 +03:00
Egor cc54a7ad2f Merge pull request #2580 from xenral/main
feat(localization): add Persian (fa) locale support and wire it across app flows
2026-02-09 21:09:43 +03:00
PEDZEO 7b0403a307 feat: add lite mode functionality with endpoints for retrieval and update
Introduced a new feature for lite mode, including a GET endpoint to retrieve the current lite mode setting and a PATCH endpoint to update it. Added corresponding response and update models for lite mode management.
2026-02-09 18:18:56 +03:00
Fringg 142ff14a50 perf: cache logo file_id to avoid re-uploading on every message
After first logo upload, Telegram returns a file_id that can be reused
for all subsequent sends. This eliminates 3-4 second delay per message
caused by re-uploading the same file from disk every time.
2026-02-09 18:14:54 +03:00
Ali Morshedzadeh 29a3b395b6 feat: add Persian (fa) locale with complete translations
Translate all bot strings to Persian, including admin panel, user interface, payment flows, contests, monitoring, and promotional features. Add RTL text support and Persian-specific formatting for dates, numbers, and currency displays.
2026-02-09 18:24:28 +03:30
Fringg 49871f82f3 fix: prevent sync from overwriting end_date for non-ACTIVE panel users
sync_users_to_panel uses _safe_expire_at_for_panel which replaces past
end_dates with now+1min for expired subscriptions. When sync_users_from_panel
reads these artificial dates back, it treated them as legitimate "newer"
dates and overwrote all expired subscriptions' end_date to approximately
current time. This caused all subscription end dates to show as "just now"
after sync.

Fix: only update end_date from panel when the panel user status is ACTIVE.
For EXPIRED/DISABLED users, the panel date may be a _safe_expire_at artifact
and should not override the real expiry date in the local database.
2026-02-09 17:39:25 +03:00
Fringg efa3a5d457 refactor: remove "both" mode from BOT_RUN_MODE, keep only polling and webhook 2026-02-09 17:32:17 +03:00
Fringg 0b86f379b4 fix: nullify payment FK references before deleting transactions in user restoration
The user restoration flow deleted transactions without first clearing
foreign key references from payment tables (yookassa_payments,
cryptobot_payments, etc.) and referral_earnings. This caused
IntegrityError when a deleted user had payment records linked to
transactions.
2026-02-09 17:19:45 +03:00
Fringg 1cae7130bc fix: promo code max_uses=0 conversion and trial UX after promo activation
- Convert max_uses=0 to 999999 (unlimited) in cabinet and webapi routes,
  matching bot handler behavior. Fixes miniapp-created promo codes being
  immediately invalid due to is_valid check (current_uses < max_uses).
- Skip trial offer in post-registration keyboard when promo code already
  activated a subscription, showing "back to menu" button instead.
2026-02-09 17:13:11 +03:00
Fringg 45410168af fix: use selection.period.days instead of selection.period_days
PurchaseSelection dataclass has period: PurchasePeriodConfig (with .days),
not period_days. This caused admin notification to fail silently on every
subscription purchase from cabinet.
2026-02-09 16:45:36 +03:00
Ali Morshedzadeh 5482e609f8 Add initial Persian locale support and language handling updates 2026-02-09 16:53:50 +03:30
Fringg e79f598d17 fix: skip users with active subscriptions in admin inactive cleanup
Admin "Clear all" button was deleting inactive users regardless of
subscription status, destroying paid subscriptions. Now matches the
monitoring service behavior by checking is_active before deletion.
2026-02-09 05:53:30 +03:00
DenyaBanan 916ad9d567 Fix 401 error
If there is a token, the bot checks it anyway, and cannot connect to the remnawave panel.
2026-02-07 03:33:16 +04:00
64 changed files with 2375 additions and 1214 deletions
+4 -11
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.8.0"
".": "3.9.0"
}
+31
View File
@@ -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
View File
@@ -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
+2 -5
View File
@@ -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ч
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
+1 -1
View File
@@ -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 ============
+5 -2
View File
@@ -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)
+2 -9
View File
@@ -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)
+49
View File
@@ -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)
+39 -9
View File
@@ -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)
+1 -1
View File
@@ -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,
+32 -3
View File
@@ -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'])
+4 -2
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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,
+17 -4
View File
@@ -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,
-166
View File
@@ -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
+17 -4
View File
@@ -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
View File
@@ -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):
+7 -1
View File
@@ -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)
+1
View File
@@ -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(
+27 -13
View File
@@ -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
View File
@@ -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}')])
+1 -1
View File
@@ -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'
+3
View File
@@ -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
+3
View File
@@ -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",
+3
View File
@@ -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",
+6
View File
@@ -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",
+12
View File
@@ -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 -3
View File
@@ -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. Отправляем текстовое сообщение.',
+1 -13
View File
@@ -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(
+12 -73
View File
@@ -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,
+1 -21
View File
@@ -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)
+1 -18
View File
@@ -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)
-17
View File
@@ -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',
+9 -46
View File
@@ -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)
+1 -23
View File
@@ -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
+1 -18
View File
@@ -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)
+1 -18
View File
@@ -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)
+1 -21
View File
@@ -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',
+1 -18
View File
@@ -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)
+49 -69
View File
@@ -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}',
+34 -25
View File
@@ -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']
-14
View File
@@ -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.',
+2 -18
View File
@@ -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
+21 -5
View File
@@ -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
View File
@@ -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'
-1
View File
@@ -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',
)
+27 -8
View File
@@ -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
View File
@@ -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:
# Последний фоллбек — обычный текст
+2 -1
View File
@@ -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:
+7 -7
View File
@@ -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}, '
+5 -2
View File
@@ -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)
-3
View File
@@ -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`
+10 -10
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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',
]
-3
View File
@@ -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.
+16
View File
@@ -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
+12
View File
@@ -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') == '❌ Нет'
Generated
+1 -50
View File
@@ -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"