Compare commits

..

187 Commits

Author SHA1 Message Date
Egor b276d764a3 Update Python version argument to v2.9.4 2025-12-30 19:28:19 +04:00
Egor ee78ab0932 Update docker-registry.yml 2025-12-30 19:28:03 +04:00
Egor e9daa76d7e Update docker-hub.yml 2025-12-30 19:27:52 +04:00
Egor 780c3956fa Merge pull request #2226 from BEDOLAGA-DEV/dev5
Новый режим покупки фикс+докупка со сбросом при продлении
2025-12-30 18:53:30 +04:00
Egor e582802e39 Update payment link generation to use async method 2025-12-30 18:51:55 +04:00
Egor d13b20d380 Update cloudpayments_service.py 2025-12-30 18:51:21 +04:00
Egor 5918f296ff Update inline.py 2025-12-30 18:33:18 +04:00
Egor 2f1ef8a60d Update pricing.py 2025-12-30 18:16:35 +04:00
Egor cefb6602f7 Refactor selectable logic in ensurePurchaseTrafficSelection 2025-12-30 18:15:50 +04:00
Egor b6503f9af9 Update miniapp.py 2025-12-30 18:15:02 +04:00
Egor 76f465e0f6 Update subscription.py 2025-12-30 18:13:18 +04:00
Egor 63ec894615 Update inline.py 2025-12-30 18:11:56 +04:00
Egor c9c25613af Add new traffic selection mode option 2025-12-30 18:10:08 +04:00
Egor 6ecaa406aa Update subscription_purchase_service.py 2025-12-30 18:09:11 +04:00
Egor 22c8f73eac Update traffic limit handling in subscription service
Refactor traffic limit assignment logic for subscriptions.
2025-12-30 18:08:32 +04:00
Egor d4bc7d0b51 Update subscription_renewal_service.py 2025-12-30 18:08:03 +04:00
Egor aa3c9231b0 Update subscription_service.py 2025-12-30 18:07:39 +04:00
Egor bce05d4bc4 Implement traffic limit reset on subscription renewal
Added logic to handle traffic limit reset during subscription renewal based on fixed traffic settings.
2025-12-30 18:05:26 +04:00
Egor f107109091 Update traffic.py 2025-12-30 18:04:54 +04:00
Egor 826a554d55 Update config.py 2025-12-30 17:56:40 +04:00
Egor fd5801839c Merge pull request #2225 from BEDOLAGA-DEV/main
w
2025-12-30 17:55:46 +04:00
Egor abce475ccc Merge pull request #2223 from Gy9vin/main
Отправка чеков со временем
2025-12-30 09:47:45 +04:00
gy9vin 22129ecbec минификс в отладке конкурсов 2025-12-30 02:15:52 +03:00
gy9vin 2a2a3daaae fix(contests): исправление статистики реферальных конкурсов
Основные исправления:
  - Фильтрация событий по дате регистрации реферала (occurred_at)
    в период конкурса (start_at - end_at)
  - Лидерборд теперь показывает правильные числа (было 21, стало 11)
  - Разделение DEPOSIT и SUBSCRIPTION_PAYMENT в статистике:
    - Основная метрика: покупки подписок (SUBSCRIPTION_PAYMENT)
    - Информационно: пополнения баланса (DEPOSIT)

  Новый функционал:
  - Кнопка "🔍 Отладка" для просмотра транзакций конкурса
  - Разбивка сумм по типам в детальной статистике
  - Кнопки "Назад" в синхронизации и отладке
  - Логирование дат фильтрации в синхронизации

  Также исправлено:
  - NaloGO: защита от дублирования чеков в очереди
    (проверка nalogo:created и nalogo:queued в Redis)
2025-12-30 02:08:23 +03:00
gy9vin ac94d5d708 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2025-12-30 00:39:50 +03:00
gy9vin d10ccc4844 refactor(nalogo): улучшение системы чеков NaloGO
Сохранение времени оплаты:
  - Добавлен параметр operation_time в create_receipt()
  - Чеки из очереди создаются с оригинальным временем платежа
  - Парсинг created_at из Redis очереди

  Защита от дублей (3 уровня):
  - Проверка transaction.receipt_uuid перед созданием
  - Redis ключ nalogo:created:{payment_id} с TTL 30 дней
  - Сохранение receipt_uuid в транзакцию после создания

  Бесконечные повторы:
  - Убрано удаление чеков после 10 попыток
  - Чеки остаются в очереди до успешной отправки

  Обработка ошибок:
  - Добавлена обработка 500 и "внутренняя ошибка" как временной недоступности

  Сверка чеков:
  - Заменена API сверка на сверку по логам (logs/current/payments.log)
  - Кнопка "Без чеков" → "Сверка чеков" с прямым показом сверки
  - Исправлена навигация кнопок "Назад"
2025-12-30 00:39:00 +03:00
Mikhail de733a5f08 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-29 22:15:48 +03:00
gy9vin 4cab3f5ed4 Отправка чеков со временем 2025-12-29 22:15:07 +03:00
Egor 26b85b16b5 Merge pull request #2220 from BEDOLAGA-DEV/main
w
2025-12-29 10:35:52 +04:00
Egor ea770c32e8 Merge pull request #2218 from Gy9vin/main
Апдейты
2025-12-28 17:23:04 +03:00
gy9vin 23f8bdfbec feat(restrictions): добавить систему ограничений пользователей
Добавлена возможность ограничивать пользователям:
  - Пополнение баланса (restriction_topup)
  - Покупку/продление подписки (restriction_subscription)

  Изменения:
  - models.py: добавлены поля restriction_topup, restriction_subscription,
    restriction_reason и property has_restrictions
  - universal_migration.py: миграция для новых полей
  - admin/users.py: меню управления ограничениями в карточке пользователя
  - keyboards/admin.py: клавиатура ограничений с toggle-кнопками
  - states.py: состояние editing_user_restriction_reason

  Проверки ограничений добавлены на двух уровнях:
  - start_*_payment: при выборе метода оплаты
  - process_*_payment_amount: при создании платежа

  Затронутые провайдеры: stars, yookassa, mulenpay, wata, pal24,
  cryptobot, heleket, platega, tribute, cloudpayments

  При ограничении пользователь видит причину и кнопку "Обжаловать",
  ведущую на контакт поддержки из настроек.
2025-12-28 13:32:04 +03:00
gy9vin a362ef9f25 refactor(nalogo): восстановить описание чеков из настроек и использовать локальную библиотеку
- Добавлено восстановление описания чека из настроек при обработке очереди
- Передача telegram_user_id и amount_kopeks через всю цепочку создания чеков
- Переход на локальную исправленную версию библ
2025-12-28 04:58:05 +03:00
gy9vin 1b736b381d refactor(nalogo): упростить настройку чеков и использовать локальное время
- Удалена избыточная настройка NALOGO_RECEIPTS_ENABLED
- Удален эндпоинт /settings/support/nalogo_receipts_enabled
- Удалены методы is_nalogo_receipts_enabled и set_nalogo_receipts_enabled из SupportSettingsService
- Упрощена логика создания чеков
2025-12-27 19:29:04 +03:00
gy9vin d343a317ee feat(logging): добавить систему ротации логов
- Ежедневная ротация в 00:00 с архивацией в tar.gz
  - Разделение по уровням: info.log, warning.log, error.log
  - Отдельный payments.log для платежных операций
  - Отправка архивов в Telegram-канал бекапов
  - Автоочистка архивов старше 7 дней (настраивается)
  - Переключатель LOG_ROTATION_ENABLED (по умолчанию выключен)
2025-12-27 19:02:28 +03:00
Egor 3fc9e63653 Merge pull request #2208 from Gy9vin/fix
Исправление уязвимостей и багов в конкурсах
2025-12-27 16:20:18 +03:00
Mikhail 9f12462871 Merge branch 'main' into fix 2025-12-27 15:54:07 +03:00
Egor e293470c14 Merge pull request #2217 from D4nilKO/promo-campaing-trial-fix
Fix campaign subscription logic: use trial instead of paid
2025-12-27 11:17:21 +03:00
Egor 6322e970ef Merge pull request #2216 from BEDOLAGA-DEV/main
w
2025-12-27 10:55:00 +03:00
PEDZEO 8a40d54c03 Merge pull request #2215 from BEDOLAGA-DEV/BACKUP
Add backup management endpoints
2025-12-27 05:18:15 +03:00
PEDZEO 0264c24743 Enhance backup upload validation
- Added checks for safe filename to prevent directory traversal attacks.
- Updated file type validation to use the sanitized filename.
- Implemented path resolution to ensure uploaded files are within the backup directory.
2025-12-27 05:16:08 +03:00
PEDZEO 1aade85fc9 Add backup management endpoints
- Implemented download, restore, upload and delete functionalities for backups.
- Added corresponding request and response schemas for backup operations.
- Enhanced security checks to prevent unauthorized access to backup files.
2025-12-27 04:33:13 +03:00
PEDZEO a5874a9873 Merge pull request #2214 from BEDOLAGA-DEV/BACKUP
Add support for password-protected backup archives
2025-12-27 04:13:22 +03:00
PEDZEO 6f9dc45e16 Update BackupService to check for temporary zip path instead of password for captioning 2025-12-27 04:11:08 +03:00
PEDZEO 34db5a0d28 Add support for password-protected backup archives
- Updated .env.example to include BACKUP_ARCHIVE_PASSWORD variable.
- Added pyzipper to requirements.txt for ZIP file encryption.
- Modified Settings class in config.py to handle BACKUP_ARCHIVE_PASSWORD.
- Enhanced BackupService to create and send password-protected ZIP archives if a password is provided.
2025-12-27 03:58:48 +03:00
Dxnil 34517427b0 test 2025-12-27 03:05:48 +03:00
gy9vin cad9abd8bd feat(payments): умная автоактивация подписки после пополнения + округление цен
Добавлена функция умной автоактивации подписки после пополнения баланса:

  - Новая настройка AUTO_ACTIVATE_AFTER_TOPUP_ENABLED в .env
  - Функция auto_activate_subscription_after_topup() в subscription_auto_purchase_service.py:
    - Автоматически продлевает истёкшую подписку с теми же параметрами
    - Создаёт новую подписку с дефолтными параметрами если подписки нет
    - Проверяет достаточность баланса перед активацией
    - Интеграция с RemnaWave API
    - Уведомления пользователю и админам

  - Интеграция во все 9 платёжных провайдеров:
    - Stars, CryptoBot, YooKassa, CloudPayments
    - WATA, Platega, Pal24, MulenPay, Tribute

  - Исправлен handle_activate_button в menu.py:
    - Полная переработка с интеграцией RemnaWave
    - Корректная работа с балансом и транзакциями
    - Использование SubscriptionRenewalService

  Добавлено округление цен при отображении:

  - Новая настройка PRICE_ROUNDING_ENABLED в .env
  - Логика: ≤50 коп → вниз, >50 коп → вверх
  - Применяется везде: пополнения, партнёрки, скидки, промогруппы
2025-12-26 23:38:46 +03:00
gy9vin bf9728352e feat(subscription): улучшение UX выбора серверов и устройств
- Кнопки устройств теперь в один столбец (вместо 2 колонок)
  - Автоматический предвыбор бесплатных серверов (price_kopeks == 0)
  - Вывод описания сквадов в тексте сообщения над кнопками

  Изменённые файлы:
  - keyboards/inline.py: get_devices_keyboard в 1 столбец
  - handlers/subscription/countries.py: хелперы _get_preselected_free_countries и _build_countries_selection_text
  - handlers/subscription/purchase.py, traffic.py, autopay.py: применение новой логики
2025-12-26 22:15:34 +03:00
gy9vin 58c924b70a fix(campaign,channel): исправлена логика рекламных кампаний и проверки подписки на канал
- Рекламные кампании теперь выдают триальную подписку (is_trial=True),
    а не платную — пользователь становится платным только после оплаты

  - Добавлена настройка CHANNEL_REQUIRED_FOR_ALL для проверки подписки
    на канал для ВСЕХ пользователей (платных и триальных)

  - Добавлен параметр is_trial в create_paid_subscription для гибкости
2025-12-26 21:40:28 +03:00
gy9vin 64ffffdf90 fix(traffic): исправлен баг с бесплатным переключением трафика
При наличии докупленного трафика (например 250 + 10 ГБ = 260 ГБ)
  система округляла текущий пакет до ближайшего (500 ГБ) и позволяла
  бесплатно переключиться на него.

  Исправления:
  - confirm_switch_traffic: используется базовый трафик для расчёта цены
  - get_traffic_switch_keyboard: добавлен параметр base_traffic_gb
  - handle_switch_traffic: показывает информацию о докупленном трафике
  - execute_switch_traffic: сбрасывает purchased_traffic_gb при переключении
2025-12-26 11:18:16 +03:00
gy9vin e71a3c1af7 feat(referral): топ рефереров по периодам в админ-панели
Добавлена возможность просмотра топа рефереров за неделю/месяц
   с сортировкой по количеству приглашённых или по заработку:

   - get_top_referrers_by_period() в crud/referral.py
   - Интерактивные кнопки выбора периода и критерия сортировки
   - Топ-20 рефереров с медалями для первых трёх мест
2025-12-26 09:11:08 +03:00
gy9vin 265d2b907b fix(devices): сброс устройств при уменьшении лимита
При уменьшении лимита устройств подключённые устройства не удалялись,
   позволяя пользователю продолжать использовать их бесплатно.

   Исправления:
   - execute_change_devices: сброс всех устройств через API если
     подключённых больше чем новый лимит
   - confirm_change_devices: предупреждение пользователя о сбросе
     устройств перед подтверждением
   - Уведомление о количестве сброшенных устройств в результате
2025-12-26 09:00:56 +03:00
gy9vin 54ffe3e126 feat(transactions): добавлен параметр payment_method для ручных пополнений баланса
Добавлена поддержка указания способа оплаты при пополнении баланса:

- add_user_balance(): новый параметр payment_method для передачи в транзакцию
- add_user_balance_by_id(): поддержка payment_method
- UserService: ручные пополнения админом пом
2025-12-26 08:53:08 +03:00
gy9vin 7e3d7d4771 refactor(referral): извлечена lambda в именованную функцию для пагинации списка рефералов
Заменена inline lambda на handle_referral_list_page() для улучшения читаемости:

- Извлечение номера страницы из callback.data
- Вызов show_detailed_referral_list() с параметром page
- Улучшена читаемость кода обработчика пагинации
2025-12-26 08:39:59 +03:00
gy9vin 05bfd89a02 ```
feat(tickets): добавлены уведомления админам об ответах пользователей на тикеты

Реализована функция notify_admins_about_ticket_reply() для оповещения администраторов:

- Уведомление отправляется после успешного добавления ответа пользователя
- Формат уведомления включает ID тикета, заголовок
2025-12-26 08:31:36 +03:00
gy9vin 3bf540427e feat(nalogo): расширена проверка временных ошибок для очереди чеков
Добавлены проверки сетевых ошибок и таймаутов в _is_service_unavailable():

- Проверка типа исключения (timeout, readtimeout, connecttimeout)
- Проверка сетевых ошибок (connectionerror, connecterror)
- Проверка текста ошибки на наличие "timeout"
- Обновлён docstring мет
2025-12-26 00:27:34 +03:00
gy9vin bf6dc3991a feat(trial): платный триал с выбором метода оплаты
Реализована система платного триала с гибким выбором способа оплаты:

- Автоопределение платности: если TRIAL_ACTIVATION_PRICE > 0, триал автоматически платный
- TRIAL_PAYMENT_ENABLED теперь опционален (для обратной совместимости)
- Добавлена функция create
2025-12-26 00:03:08 +03:00
Mikhail fb9405cccd Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-25 23:56:04 +03:00
gy9vin 0b34e90372 feat(migrations): добавлены миграции для модема, трафика и призов конкурсов
Добавлены миграции для новых функций:

- migrate_contest_templates_prize_columns(): миграция prize_days → prize_type + prize_value
- add_subscription_modem_enabled_column(): колонка modem_enabled в subscriptions
- add_subscription_purchased_traffic_column(): колонка purchased_traffic_gb в subscriptions
- Обновлён check
2025-12-25 23:29:02 +03:00
Egor 04e83814b4 Merge pull request #2213 from BEDOLAGA-DEV/dev5
CloudPayments
2025-12-25 23:09:44 +03:00
gy9vin 0df3018703 feat(nalogo): система очереди чеков с отложенной отправкой
Реализована отказоустойчивая система отправки чеков в налоговую:

  - Добавлен NalogoQueueService для фоновой обработки очереди чеков
  - При недоступности nalog.ru (503) чеки сохраняются в Redis
  - Автоматическая повторная отправка с настраиваемым интервалом
  - Защита от DDoS: задержка между чеками (NALOGO_QUEUE_RECEIPT_DELAY)
  - Уведомления админам в топик при проблемах и успешной разгрузке

  Изменения в файлах:
  - app/services/nalogo_queue_service.py: новый фоновый сервис
  - app/services/nalogo_service.py: методы очереди, определение 503
  - app/utils/cache.py: lpush/rpop/llen/lrange для Redis List
  - app/handlers/admin/monitoring.py: статистика чеков в админке
  - app/config.py: NALOGO_QUEUE_* и ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
  - main.py: интеграция запуска/остановки сервиса

  Новые ENV переменные:
  - ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
  - NALOGO_QUEUE_CHECK_INTERVAL (300с)
  - NALOGO_QUEUE_RECEIPT_DELAY (3с)
  - NALOGO_QUEUE_MAX_ATTEMPTS (10)
2025-12-25 23:01:49 +03:00
Egor 8eb2be15ff Update index.html 2025-12-25 21:11:13 +03:00
Egor 648fba6e2a Update miniapp.py 2025-12-25 21:10:15 +03:00
Egor 3df8d0cb24 Update bot_configuration.py 2025-12-25 21:09:18 +03:00
Egor a59a15af7f Update inline.py 2025-12-25 21:07:42 +03:00
Egor c919953219 Add files via upload 2025-12-25 21:07:00 +03:00
Egor 5e6baac07e Add files via upload 2025-12-25 21:05:49 +03:00
Egor 42b06ea8bc Add files via upload 2025-12-25 21:05:10 +03:00
Egor 07ded3710c Add files via upload 2025-12-25 21:04:10 +03:00
Egor 7d0977066b Add files via upload 2025-12-25 21:03:14 +03:00
Egor d8e57ab766 Add files via upload 2025-12-25 21:01:58 +03:00
Egor 83cb1ec823 Add files via upload 2025-12-25 21:01:11 +03:00
gy9vin da46e39c61 refactor(modem): рефакторинг модуля управления модемом
Рефакторинг архитектуры управления модемом:

- Создан сервис app/services/modem_service.py:
  - ModemService с бизнес-логикой подключения/отключения
  - ModemError enum для типизации ошибок
  - ModemPriceInfo, ModemOperationResult dataclass'ы
  - Константы MODEM_WARNING_DAYS_* для уровней предупреждений
2025-12-25 18:44:27 +03:00
gy9vin 86dd18fbe7 refactor(contests): доработка ежедневных конкурсов
Рефакторинг архитектуры ежедневных конкурсов:

- Создан модуль app/services/contests/ с новой архитектурой:
  - enums.py: GameType, RoundStatus, PrizeType enum классы
  - games.py: паттерн Стратегия для 7 типов игр
  - attempt_service.py: ContestAttemptService для атомарных операций

- Упрощён handlers/contests.py:
  - Удалены отдельные _render_* функции (заменены на стратегии)
  - Логика обработки попыток вынесена в ContestAttemptService
  - Уменьшено с 523 до 342 строк (-35%)

- Обновлён contest_rotation_service.py:
  - Заменена if-elif цепочка на get_game_strategy().build_payload()
  - Используются enum классы вместо магических строк

- Исправлен handlers/admin/daily_contests.py:
  - prize_days → prize_type/prize_value (соответствие модели БД)
  - Обновлены EDITABLE_FIELDS и отображение приза

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:09:11 +03:00
gy9vin 21f34a9b08 fix(contests): блокировка повторных ответов в текстовых играх
- Создание попытки сразу при показе вопроса (cipher/emoji/anagram)
- Проверка attempt.answer is not None для блокировки повторного ответа
- Обновление существующей попытки вместо создания новой
- Добавлена функция update_attempt() в CRUD
2025-12-25 15:00:30 +03:00
gy9vin 0538d0e337 feat(traffic): улучшение системы докупки и сброса трафика
- Добавлен ENV переключатель TRAFFIC_TOPUP_ENABLED для вкл/выкл докупки
- Добавлена отдельная конфигурация пакетов TRAFFIC_TOPUP_PACKAGES_CONFIG
- Добавлено поле purchased_traffic_gb для отслеживания докупленного трафика
- Добавлены режимы расчета цены сброса (period/traffic/traffic_with_purchased)
- Исправлен абьюз: цена сброса теперь учитывает докупленный трафик
- Сброс purchased_traffic_gb при продлении/покупке подписки
- UX: меню сброса теперь показывает цену и баланс вместо alert
- UX: кнопка пополнения если не хватает средств на сброс
- Добавлена миграция для нового поля purchased_traffic_gb
- Добавлена локализация TRAFFIC_TOPUP_DISABLED (ru/en/ua/zh)
2025-12-25 14:48:24 +03:00
Mikhail 1e64b65586 Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-25 14:26:35 +03:00
gy9vin 90c3df6331 Добавлен функционал подключения модема к подписке
Изменения:
- Добавлены настройки модема в .env.example и config.py (MODEM_ENABLED, MODEM_PRICE_PER_MONTH, MODEM_PERIOD_DISCOUNTS)
- Добавлено поле modem_enabled в модель Subscription
- Реализован модуль handlers/subscription/modem.py с обработчиками подключения/отключения модема
- Добавлено управ
2025-12-25 14:26:15 +03:00
Egor b9ea5a80ac Merge pull request #2212 from BEDOLAGA-DEV/dev5
Update subscription_service.py
2025-12-25 11:00:00 +03:00
Egor 40d6514dee Update subscription_service.py 2025-12-25 10:59:35 +03:00
Egor 4a2b254d99 Merge pull request #2211 from BEDOLAGA-DEV/dev5
Dev5
2025-12-25 09:45:07 +03:00
Egor 6c740ad984 Update purchase.py 2025-12-25 09:35:49 +03:00
Egor 929a8d5fa9 Update purchase.py 2025-12-25 09:09:46 +03:00
Egor b0bf5131d4 Update subscription_service.py 2025-12-25 09:09:12 +03:00
Egor ef8d9fe1ff Update subscription_purchase_service.py 2025-12-24 23:15:37 +03:00
Egor d57e0743a1 Update tickets.py 2025-12-24 23:06:22 +03:00
Egor 664cbff1ce Update tickets.py 2025-12-24 23:05:52 +03:00
Egor 6ea52bf406 Update messages.py 2025-12-24 22:46:04 +03:00
Mikhail 0ddf24125b Merge branch 'BEDOLAGA-DEV:main' into main 2025-12-24 16:17:29 +03:00
Egor f5a533e407 Merge pull request #2210 from BEDOLAGA-DEV/dev5
Dev5
2025-12-24 15:30:46 +03:00
Egor dd87b4d408 Update database.py 2025-12-24 14:44:02 +03:00
Egor 2c6c7056e8 Update subscriptions.py 2025-12-24 11:16:00 +03:00
Egor 8a10b96fef Update users.py 2025-12-24 11:15:40 +03:00
Egor c0a85bad84 Merge pull request #2209 from BEDOLAGA-DEV/dev5
Dev5
2025-12-23 22:09:46 +03:00
Egor fbfc00586c Update purchase.py 2025-12-23 21:03:13 +03:00
Egor 5723c3b379 Update purchase.py 2025-12-23 20:37:01 +03:00
Egor f1be66d1a5 Update inline.py 2025-12-23 20:35:42 +03:00
gy9vin b3cdd3c03a Расширение функционала конкурсов: разнообразие наград, напоминания, многоязычность
Изменения:
- ContestTemplate: prize_days заменен на prize_type и prize_value для поддержки разных типов наград (days, balance, custom)
- _award_prize: обновлена логика выдачи призов для всех типов наград
- DEFAULT_TEMPLATES: обновлены для использования prize_type/prize_value
- upsert_template: обновлена сигнатура для новых полей
- _announce_round_start: добавлена локализация и напоминания о конкурсах
- handle_text_answer: исправлена гонка условий с атомарным инкрементом победителей
- Локализация: добавлены ключи CONTEST_START_ANNOUNCEMENT, CONTEST_PRIZE, DAYS, CONTEST_WINNERS, CONTEST_ATTEMPTS, CONTEST_ELIGIBILITY, REMINDER, CONTEST_REMINDER_TEXT в ru.json и en.json
- API схемы: обновлены ContestTemplateResponse и ContestTemplateUpdateRequest

Требуется миграция БД для новых колонок prize_type и prize_value.
2025-12-23 19:15:40 +03:00
gy9vin 01afce002a Исправление уязвимостей и багов в конкурсах
- Добавлена защита от спама: rate limiting для попыток (1 попытка/3-5 сек)
- Усилена валидация входных данных: функция _validate_callback_data для безопасного парсинга callback.data
- Перепроверка авторизации: статус подписки проверяется на каждом шаге
- Атомарные операции победителей: использование select with_for_update для предотвращения гонок условий
- Улучшено логирование: добавлены логи попыток и побед для аудита
- Добавлена кнопка 'Назад' в игру 'Блиц' для предотвращения застревания пользователей
- Исправлены отступы и ошибки линтера в _render_blitz

Все изменения направлены на повышение безопасности, стабильности и UX конкурсов.
2025-12-23 18:59:44 +03:00
Egor 63a9c1afd1 Update inline.py 2025-12-23 16:09:58 +03:00
Egor 0c9f667cd8 Merge pull request #2207 from BEDOLAGA-DEV/dev5
Dev5
2025-12-23 11:57:02 +03:00
Egor dc428baa6a Update subscription.py 2025-12-23 11:53:52 +03:00
Egor f65aa6a82d Update remnawave_service.py 2025-12-23 11:53:11 +03:00
Egor c986b127a7 Merge pull request #2206 from BEDOLAGA-DEV/dev5
w
2025-12-23 11:29:16 +03:00
Egor 94a7a5fce8 Update remnawave_service.py 2025-12-23 11:28:37 +03:00
Egor f07bbe0fd0 Update remnawave_api.py 2025-12-23 11:27:57 +03:00
Egor 46f61dbc57 Update config.py 2025-12-23 11:27:33 +03:00
Egor 41502cadcd Merge pull request #2205 from BEDOLAGA-DEV/dev5
Update remnawave_api.py
2025-12-23 11:18:46 +03:00
Egor aa5e6841f8 Update remnawave_api.py 2025-12-23 11:18:22 +03:00
Egor 8a98a7ee84 Merge pull request #2204 from BEDOLAGA-DEV/dev5
Update config.py
2025-12-23 11:09:26 +03:00
Egor e1d6c73557 Update config.py 2025-12-23 11:08:50 +03:00
Egor b4d66bd34d Merge pull request #2203 from BEDOLAGA-DEV/dev5
Update photo_message.py
2025-12-23 02:19:18 +03:00
Egor 1496ec901d Update photo_message.py 2025-12-23 02:17:31 +03:00
Egor 78ce6bd428 Merge pull request #2202 from BEDOLAGA-DEV/dev5
Update remnawave_service.py
2025-12-23 00:54:44 +03:00
Egor 9ec52dcf95 Update remnawave_service.py 2025-12-23 00:54:01 +03:00
Egor 80a13e6b71 Merge pull request #2201 from BEDOLAGA-DEV/dev5
fix
2025-12-22 22:46:27 +03:00
Egor 9231f770a3 Update payment_utils.py 2025-12-22 22:45:21 +03:00
Egor f8ba587e5c Add files via upload 2025-12-22 22:43:51 +03:00
Egor 9280eecf4c Update Dockerfile 2025-12-22 19:40:19 +03:00
Egor a36b15e8ad Update docker-registry.yml 2025-12-22 19:40:09 +03:00
Egor 3e833f8a8c Update docker-hub.yml 2025-12-22 19:39:57 +03:00
Egor 48044b56c1 Merge pull request #2200 from BEDOLAGA-DEV/dev5
platega change name
2025-12-22 19:36:09 +03:00
Egor 3dab9df26c Update platega.py 2025-12-22 19:33:09 +03:00
Egor 65da3a57a7 Update inline.py 2025-12-22 19:32:36 +03:00
Egor 791482a8a1 Update system_settings_service.py 2025-12-22 19:32:06 +03:00
Egor 7b03f3e553 Update payment_verification_service.py 2025-12-22 19:31:32 +03:00
Egor 47698882fa Update payments.py 2025-12-22 19:30:52 +03:00
Egor 2c4a55f7c7 Update config.py 2025-12-22 19:30:13 +03:00
Egor f8053e14c3 Merge pull request #2199 from BEDOLAGA-DEV/main
w
2025-12-22 17:43:38 +03:00
Egor 2073d42fa8 Merge pull request #2198 from BEDOLAGA-DEV/dev5
fix: add trafic button
2025-12-22 17:08:20 +03:00
Egor 076eb89760 Update menu.py 2025-12-22 15:53:56 +03:00
Egor 34878a2ef3 Merge pull request #2197 from BEDOLAGA-DEV/dev5
Caddy Token auth
2025-12-22 15:33:36 +03:00
Egor 6ae573ed48 Update .env.example 2025-12-22 15:32:38 +03:00
Egor 482c42e9bf Update README.md 2025-12-22 15:30:57 +03:00
Egor 66092af999 Update maintenance_service.py 2025-12-22 15:29:46 +03:00
Egor c7eabdf90e Update remnawave_service.py 2025-12-22 15:29:21 +03:00
Egor ee6f4b35b0 Update subscription_service.py 2025-12-22 15:29:02 +03:00
Egor 7aea87a227 Update remnawave_api.py 2025-12-22 15:28:23 +03:00
Egor a2b039d092 Update config.py 2025-12-22 15:28:02 +03:00
Egor f2d2c0a8a4 Merge pull request #2196 from BEDOLAGA-DEV/dev5
Remnawave Api Update / Pinned Massages
2025-12-22 15:18:18 +03:00
Egor ea3033a088 Update validators.py 2025-12-22 15:11:23 +03:00
Egor 07f445b485 Update pinned_messages.py 2025-12-22 15:01:26 +03:00
Egor a94cb355aa Add files via upload 2025-12-22 14:56:16 +03:00
Egor 9f14ae67da Update __init__.py 2025-12-22 14:48:14 +03:00
Egor 44247dee03 Update app.py 2025-12-22 14:47:40 +03:00
Egor c66af415d5 Add files via upload 2025-12-22 14:46:51 +03:00
Egor 6b2d7618a7 Add files via upload 2025-12-22 14:45:27 +03:00
Egor 866fa56f5b Add files via upload 2025-12-22 14:43:58 +03:00
Egor ea48036010 Update messages.py 2025-12-22 14:43:22 +03:00
Egor 4077b2a032 Update admin.py 2025-12-22 14:42:52 +03:00
Egor a69500ce91 Update states.py 2025-12-22 14:42:23 +03:00
Egor 9a1e57a764 Update remnawave_service.py 2025-12-22 14:08:44 +03:00
Egor 88657dd3e2 Update remnawave_service.py 2025-12-22 14:03:33 +03:00
Egor 85d4c9c208 Add files via upload 2025-12-22 13:57:37 +03:00
Egor 2bda60cd18 Update messages.py 2025-12-22 13:56:52 +03:00
Egor 73e5e1b5a3 Update pinned_message_service.py 2025-12-22 13:56:15 +03:00
Egor 3f1b65b602 Update 7a3c0b8f5b84_add_send_before_menu_to_pinned_messages.py 2025-12-22 13:55:43 +03:00
Egor fa7dd7434b Update 1b2e3d4f5a6b_add_pinned_start_mode_and_user_last_pin.py 2025-12-22 13:55:18 +03:00
Egor 0243d3a2ba Merge pull request #2195 from BEDOLAGA-DEV/i1s5hb-bedolaga/add-pinned-message-feature-in-admin-menu
Add one-time pinned message delivery mode
2025-12-22 13:32:48 +03:00
Egor 0951c9f6dd Add one-time pinned message delivery mode 2025-12-22 13:32:34 +03:00
Egor 6891cc1f36 Merge pull request #2194 from BEDOLAGA-DEV/revert-2192-kbzjuf-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add start delivery frequency toggle for pinned messages"
2025-12-22 13:32:16 +03:00
Egor dbe805662a Revert "Add start delivery frequency toggle for pinned messages" 2025-12-22 13:32:07 +03:00
Egor cd5c293b71 Merge pull request #2192 from BEDOLAGA-DEV/kbzjuf-bedolaga/add-pinned-message-feature-in-admin-menu
Add start delivery frequency toggle for pinned messages
2025-12-22 13:28:58 +03:00
Egor 58e789b094 Merge pull request #2193 from BEDOLAGA-DEV/revert-2191-wc3pdx-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add admin option to remove pinned message"
2025-12-22 13:28:50 +03:00
Egor 3fd48807d1 Revert "Add admin option to remove pinned message" 2025-12-22 13:28:40 +03:00
Egor 76b32ea4fe Add one-time pinned message option for /start 2025-12-22 13:28:08 +03:00
Egor 60670e44a2 Merge pull request #2191 from BEDOLAGA-DEV/wc3pdx-bedolaga/add-pinned-message-feature-in-admin-menu
Add admin option to remove pinned message
2025-12-22 13:14:24 +03:00
Egor 69f1b91ce7 Add admin control to remove pinned message 2025-12-22 13:14:01 +03:00
Egor c78313b5db Merge pull request #2190 from BEDOLAGA-DEV/revert-2189-h29si7-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Prevent duplicate unknown command message after /start"
2025-12-22 13:13:27 +03:00
Egor 55a7ec6b11 Revert "Prevent duplicate unknown command message after /start" 2025-12-22 13:13:18 +03:00
Egor 41d94cd8e7 Merge pull request #2189 from BEDOLAGA-DEV/h29si7-bedolaga/add-pinned-message-feature-in-admin-menu
Prevent duplicate unknown command message after /start
2025-12-22 13:00:53 +03:00
Egor 2422f56137 Avoid unknown handler after /start commands 2025-12-22 13:00:17 +03:00
Egor bb7b95c971 Merge pull request #2188 from BEDOLAGA-DEV/revert-2187-6r14me-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add pinned message placement control and reduce broadcast throttling"
2025-12-22 13:00:02 +03:00
Egor a17344858b Revert "Add pinned message placement control and reduce broadcast throttling" 2025-12-22 12:59:52 +03:00
Egor a45dd70531 Merge pull request #2187 from BEDOLAGA-DEV/6r14me-bedolaga/add-pinned-message-feature-in-admin-menu
Add pinned message placement control and reduce broadcast throttling
2025-12-22 12:49:05 +03:00
Egor 2bcda34f1c Add pinned message placement control and fix throttling broadcast 2025-12-22 12:48:21 +03:00
Egor ac698b3a74 Merge pull request #2186 from BEDOLAGA-DEV/revert-2185-1li4we-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Add position control for pinned messages"
2025-12-22 12:47:19 +03:00
Egor e52a47bfb3 Revert "Add position control for pinned messages" 2025-12-22 12:47:10 +03:00
Egor 47d7bdd632 Merge pull request #2185 from BEDOLAGA-DEV/1li4we-bedolaga/add-pinned-message-feature-in-admin-menu
Add position control for pinned messages
2025-12-22 12:42:12 +03:00
Egor f7be2911cd Add position control for pinned messages 2025-12-22 12:41:43 +03:00
Egor 104cf899ee Merge pull request #2184 from BEDOLAGA-DEV/revert-2183-b72kym-bedolaga/add-pinned-message-feature-in-admin-menu
Revert "Support media attachments in pinned messages"
2025-12-22 12:30:15 +03:00
Egor 6f0e3c0bfd Revert "Support media attachments in pinned messages" 2025-12-22 12:30:06 +03:00
Egor 09d8261cbf Merge pull request #2183 from BEDOLAGA-DEV/b72kym-bedolaga/add-pinned-message-feature-in-admin-menu
Support media attachments in pinned messages
2025-12-22 12:18:48 +03:00
Egor 3c9580dc30 Add pinned messages to universal migration 2025-12-22 12:18:32 +03:00
Egor 1d72419243 Merge pull request #2181 from BEDOLAGA-DEV/dev5
fix: promo info button in menu
2025-12-22 11:50:49 +03:00
Egor ae420a592a Update menu.py 2025-12-22 11:47:42 +03:00
Egor a2586d88e1 Merge pull request #2179 from D4nilKO/main
chore: sync subscription with Remnawave on creation/replacement
2025-12-21 20:25:13 +03:00
Dxnil 0b61878de8 chore: sync subscription with Remnawave on creation/replacement 2025-12-21 17:53:16 +03:00
151 changed files with 16305 additions and 1133 deletions
+77 -2
View File
@@ -14,6 +14,7 @@ ADMIN_NOTIFICATIONS_ENABLED=true
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID=133 # Опционально: ID топика для уведомлений о чеках NaloGO
# Автоматические отчеты
ADMIN_REPORTS_ENABLED=false
ADMIN_REPORTS_CHAT_ID= # Опционально: чат для отчетов (по умолчанию ADMIN_NOTIFICATIONS_CHAT_ID)
@@ -38,6 +39,7 @@ CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
# ===== DATABASE CONFIGURATION =====
# Режим базы данных: "auto", "postgresql", "sqlite"
@@ -64,8 +66,9 @@ REDIS_URL=redis://redis:6379/0
REMNAWAVE_API_URL=https://panel.example.com
REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth"
# Тип авторизации: "api_key", "basic_auth", "caddy"
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
@@ -100,6 +103,9 @@ REMNAWAVE_USER_DELETE_MODE=delete
TRIAL_DURATION_DAYS=3
TRIAL_TRAFFIC_LIMIT_GB=10
TRIAL_DEVICE_LIMIT=1
# Платный триал: если TRIAL_ACTIVATION_PRICE > 0, триал становится платным
# Цена в копейках (1000 = 10 рублей). Пользователь может оплатить триал любым методом оплаты.
# TRIAL_PAYMENT_ENABLED опционален (для обратной совместимости)
TRIAL_PAYMENT_ENABLED=false
TRIAL_ACTIVATION_PRICE=0
@@ -131,6 +137,26 @@ TRAFFIC_SELECTION_MODE=selectable
# 0 = безлимит
FIXED_TRAFFIC_LIMIT_GB=100
# ===== ДОКУПКА ТРАФИКА =====
# Включить/выключить функцию докупки трафика к существующей подписке
TRAFFIC_TOPUP_ENABLED=true
# Пакеты для докупки трафика (формат: "гб:цена_в_копейках:enabled")
# Пустая строка = использовать TRAFFIC_PACKAGES_CONFIG
# Пример: "10:5000:true,25:10000:true,50:15000:true,100:25000:true"
TRAFFIC_TOPUP_PACKAGES_CONFIG=
# ===== СБРОС ТРАФИКА =====
# Режим расчета цены сброса трафика:
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение, может быть абьюзом!)
# "traffic" - цена = стоимость текущего пакета трафика подписки
# "traffic_with_purchased" - цена = стоимость базового + докупленного трафика (рекомендуется)
TRAFFIC_RESET_PRICE_MODE=traffic_with_purchased
# Базовая цена сброса в копейках (0 = использовать PERIOD_PRICES[30])
# Используется как минимальная цена или фиксированная в режиме "period"
TRAFFIC_RESET_BASE_PRICE=0
# ===== ПЕРИОДЫ ПОДПИСКИ =====
# Доступные периоды подписки (через запятую)
# Возможные значения: 14,30,60,90,180,360
@@ -173,6 +199,16 @@ PRICE_PER_DEVICE=10000
DEVICES_SELECTION_ENABLED=true
# Единое количество устройств для режима без выбора (0 — не назначать устройства)
DEVICES_SELECTION_DISABLED_AMOUNT=0
# ===== МОДЕМ =====
# Включить функционал подключения модема
MODEM_ENABLED=false
# Цена модема в копейках за месяц (добавляется к ежемесячному платежу)
MODEM_PRICE_PER_MONTH=10000
# Скидки на модем за длительный срок: "месяцев:процент,месяцев:процент"
# Пример: 3 мес = 15%, 6 мес = 20%, 12 мес = 25%
MODEM_PERIOD_DISCOUNTS=3:15,6:20,12:25
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW=false
@@ -281,11 +317,13 @@ PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES=10
# ===== НАЛОГОВАЯ СЛУЖБА (NaloGO) =====
# Автоматическая отправка чеков в налоговую при пополнении баланса
NALOGO_ENABLED=false
NALOGO_RECEIPTS_ENABLED=false
NALOGO_INN= # ИНН самозанятого
NALOGO_PASSWORD= # Пароль от личного кабинета налоговой
NALOGO_DEVICE_ID= # Опционально: ID устройства для авторизации
NALOGO_STORAGE_PATH=./nalogo_tokens.json # Путь к файлу с токенами
NALOGO_QUEUE_CHECK_INTERVAL=300 # Интервал проверки очереди чеков (секунды)
NALOGO_QUEUE_RECEIPT_DELAY=3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS=10 # Максимум попыток отправки одного чека
# ===== НАСТРОЙКИ ОПИСАНИЙ ПЛАТЕЖЕЙ =====
# Эти настройки позволяют изменить описания платежей,
@@ -459,6 +497,12 @@ DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
# true: 14.78₽ → 15₽, 14.12₽ → 14₽
# false: показывать точные суммы с копейками
PRICE_ROUNDING_ENABLED=true
# Часовой пояс
TZ=Europe/Moscow # или UTC, America/New_York и т.д.
@@ -484,6 +528,8 @@ BACKUP_SEND_ENABLED=true
BACKUP_SEND_CHAT_ID=-100123456789 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА!
# ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
BACKUP_SEND_TOPIC_ID=123 # Опционально: ID топика
# Пароль для архива бекапа (опционально). Если задан - бекап отправляется в зашифрованном ZIP с AES
BACKUP_ARCHIVE_PASSWORD=
# ===== ПРОВЕРКА ОБНОВЛЕНИЙ БОТА =====
VERSION_CHECK_ENABLED=true
@@ -494,6 +540,28 @@ VERSION_CHECK_INTERVAL_HOURS=1
LOG_LEVEL=INFO
LOG_FILE=logs/bot.log
# === Ротация логов ===
# Включить новую систему ротации (по умолчанию старое поведение)
LOG_ROTATION_ENABLED=false
# Время ротации (HH:MM)
LOG_ROTATION_TIME=00:00
# Хранить архивы N дней
LOG_ROTATION_KEEP_DAYS=7
# Сжимать архивы gzip
LOG_ROTATION_COMPRESS=true
# Отправлять архивы в Telegram-канал
LOG_ROTATION_SEND_TO_TELEGRAM=false
# Канал для логов (если не задан, используется BACKUP_SEND_CHAT_ID)
LOG_ROTATION_CHAT_ID=
# Топик в канале (если не задан, используется BACKUP_SEND_TOPIC_ID)
LOG_ROTATION_TOPIC_ID=
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR=logs
LOG_INFO_FILE=info.log
LOG_WARNING_FILE=warning.log
LOG_ERROR_FILE=error.log
LOG_PAYMENTS_FILE=payments.log
# ===== РАЗРАБОТКА =====
DEBUG=false
WEBHOOK_URL=
@@ -510,6 +578,13 @@ BOT_RUN_MODE=polling # polling, webhook или both
CONTESTS_ENABLED=false
CONTESTS_BUTTON_VISIBLE=false
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# Умная автоактивация: система сама решает — продлить или создать подписку
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
# ACTIVATE_BUTTON_TEXT=активировать
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.9.2-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.9.2-dev-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v2.9.2-pr-$(git rev-parse --short HEAD)"
VERSION="v2.9.4-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v2.9.2"
ARG VERSION="v2.9.4"
ARG BUILD_DATE
ARG VCS_REF
+6 -6
View File
@@ -61,12 +61,12 @@
### 📚 Поддерживаемые методы авторизации
| Метод | Заголовок | Описание |
|-------|-----------|----------|
| API Key | X-Api-Key: your_api_key | Стандартный API ключ |
| Bearer Token | Authorization: Bearer token | Классический Bearer token |
| Basic Auth | X-Api-Key: Basic base64(user:pass) | Basic Authentication |
| eGames Cookies | Cookies в формате key:value | Для панелей eGames |
| Конфигурация | Authorization | X-Api-Key |
|------------------|---------------------|---------------------|
| Только API Key | Bearer <api_key> | <api_key> |
| Basic Auth | Bearer <api_key> | Basic <user:pass> |
| Caddy + API Key | Basic <caddy_token> | <api_key> |
| Cookies (eGames) | Bearer <api_key> | <api_key> + cookies |
---
+284 -11
View File
@@ -45,6 +45,12 @@ class Settings(BaseSettings):
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID: Optional[int] = None
# Настройки очереди чеков NaloGO
NALOGO_QUEUE_CHECK_INTERVAL: int = 300 # Интервал проверки очереди (секунды)
NALOGO_QUEUE_RECEIPT_DELAY: int = 3 # Задержка между отправкой чеков (секунды)
NALOGO_QUEUE_MAX_ATTEMPTS: int = 10 # Максимум попыток отправки чека
ADMIN_REPORTS_ENABLED: bool = False
ADMIN_REPORTS_CHAT_ID: Optional[str] = None
@@ -55,7 +61,8 @@ class Settings(BaseSettings):
CHANNEL_LINK: Optional[str] = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
DATABASE_URL: Optional[str] = None
POSTGRES_HOST: str = "postgres"
@@ -79,7 +86,8 @@ class Settings(BaseSettings):
REMNAWAVE_USERNAME: Optional[str] = None
REMNAWAVE_PASSWORD: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key"
REMNAWAVE_CADDY_TOKEN: Optional[str] = None
REMNAWAVE_AUTH_TYPE: str = "api_key" # api_key, basic, bearer, cookies, caddy
REMNAWAVE_USER_DESCRIPTION_TEMPLATE: str = "Bot user: {full_name} {username}"
REMNAWAVE_USER_USERNAME_TEMPLATE: str = "user_{telegram_id}"
REMNAWAVE_USER_DELETE_MODE: str = "delete" # "delete" или "disable"
@@ -142,11 +150,35 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: Optional[int] = None
# Настройки модема
MODEM_ENABLED: bool = False
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
MODEM_PERIOD_DISCOUNTS: str = "" # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ""
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
# Режим выбора трафика:
# - selectable: пользователь выбирает трафик при покупке и может докупать
# - fixed: фиксированный лимит, без выбора и без докупки
# - fixed_with_topup: фиксированный лимит при покупке, но докупка разрешена (при продлении сброс до лимита)
TRAFFIC_SELECTION_MODE: str = "selectable"
FIXED_TRAFFIC_LIMIT_GB: int = 100
BUY_TRAFFIC_BUTTON_VISIBLE: bool = True
# Настройки докупки трафика
TRAFFIC_TOPUP_ENABLED: bool = True # Включить/выключить функцию докупки трафика
# Пакеты для докупки трафика (формат: "гб:цена:enabled", пустая строка = использовать TRAFFIC_PACKAGES_CONFIG)
TRAFFIC_TOPUP_PACKAGES_CONFIG: str = ""
# Настройки сброса трафика
# Режимы расчета цены сброса:
# "period" - фиксированная цена = стоимость периода 30 дней (старое поведение)
# "traffic" - цена зависит от текущего лимита трафика (цена пакета трафика)
# "traffic_with_purchased" - цена = базовый трафик + докупленный трафик (рекомендуется)
TRAFFIC_RESET_PRICE_MODE: str = "traffic_with_purchased"
# Базовая цена сброса в копейках (используется если режим "period" или как минимальная цена)
TRAFFIC_RESET_BASE_PRICE: int = 0 # 0 = использовать PERIOD_PRICES[30]
REFERRAL_MINIMUM_TOPUP_KOPEKS: int = 10000
REFERRAL_FIRST_TOPUP_BONUS_KOPEKS: int = 10000
@@ -235,13 +267,13 @@ class Settings(BaseSettings):
PAYMENT_VERIFICATION_AUTO_CHECK_INTERVAL_MINUTES: int = 10
NALOGO_ENABLED: bool = False
NALOGO_RECEIPTS_ENABLED: bool = False
NALOGO_INN: Optional[str] = None
NALOGO_PASSWORD: Optional[str] = None
NALOGO_DEVICE_ID: Optional[str] = None
NALOGO_STORAGE_PATH: str = "./nalogo_tokens.json"
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
@@ -318,6 +350,7 @@ class Settings(BaseSettings):
PLATEGA_ENABLED: bool = False
PLATEGA_MERCHANT_ID: Optional[str] = None
PLATEGA_SECRET: Optional[str] = None
PLATEGA_DISPLAY_NAME: str = "Platega"
PLATEGA_BASE_URL: str = "https://app.platega.io"
PLATEGA_RETURN_URL: Optional[str] = None
PLATEGA_FAILED_URL: Optional[str] = None
@@ -347,6 +380,24 @@ class Settings(BaseSettings):
WATA_PUBLIC_KEY_URL: Optional[str] = None
WATA_PUBLIC_KEY_CACHE_SECONDS: int = 3600
# CloudPayments
CLOUDPAYMENTS_ENABLED: bool = False
CLOUDPAYMENTS_PUBLIC_ID: Optional[str] = None
CLOUDPAYMENTS_API_SECRET: Optional[str] = None
CLOUDPAYMENTS_API_URL: str = "https://api.cloudpayments.ru"
CLOUDPAYMENTS_WIDGET_URL: str = "https://widget.cloudpayments.ru/show"
CLOUDPAYMENTS_DESCRIPTION: str = "Пополнение баланса"
CLOUDPAYMENTS_CURRENCY: str = "RUB"
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS: int = 5000
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS: int = 10000000
CLOUDPAYMENTS_WEBHOOK_PATH: str = "/cloudpayments-webhook"
CLOUDPAYMENTS_WEBHOOK_HOST: str = "0.0.0.0"
CLOUDPAYMENTS_WEBHOOK_PORT: int = 8087
CLOUDPAYMENTS_RETURN_URL: Optional[str] = None
CLOUDPAYMENTS_SKIN: str = "mini" # mini, classic, modern
CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False
CLOUDPAYMENTS_TEST_MODE: bool = False
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -372,10 +423,29 @@ class Settings(BaseSettings):
DEFAULT_LANGUAGE: str = "ru"
AVAILABLE_LANGUAGES: str = "ru,en"
LANGUAGE_SELECTION_ENABLED: bool = True
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
PRICE_ROUNDING_ENABLED: bool = True
LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log"
# === Log Rotation Settings ===
LOG_ROTATION_ENABLED: bool = False # По умолчанию старое поведение
LOG_ROTATION_TIME: str = "00:00" # Время ротации (HH:MM)
LOG_ROTATION_KEEP_DAYS: int = 7 # Хранить архивы N дней
LOG_ROTATION_COMPRESS: bool = True # Сжимать архивы gzip
LOG_ROTATION_SEND_TO_TELEGRAM: bool = False # Отправлять в канал
LOG_ROTATION_CHAT_ID: Optional[str] = None # Канал для логов (или BACKUP_SEND_CHAT_ID)
LOG_ROTATION_TOPIC_ID: Optional[int] = None # Топик в канале
# Пути к лог-файлам (при LOG_ROTATION_ENABLED=true)
LOG_DIR: str = "logs"
LOG_INFO_FILE: str = "info.log"
LOG_WARNING_FILE: str = "warning.log"
LOG_ERROR_FILE: str = "error.log"
LOG_PAYMENTS_FILE: str = "payments.log"
DEBUG: bool = False
WEBHOOK_URL: Optional[str] = None
WEBHOOK_PATH: str = "/webhook"
@@ -418,6 +488,7 @@ class Settings(BaseSettings):
BACKUP_SEND_ENABLED: bool = False
BACKUP_SEND_CHAT_ID: Optional[str] = None
BACKUP_SEND_TOPIC_ID: Optional[int] = None
BACKUP_ARCHIVE_PASSWORD: Optional[str] = None
EXTERNAL_ADMIN_TOKEN: Optional[str] = None
EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None
@@ -575,6 +646,7 @@ class Settings(BaseSettings):
"secret_key": self.REMNAWAVE_SECRET_KEY,
"username": self.REMNAWAVE_USERNAME,
"password": self.REMNAWAVE_PASSWORD,
"caddy_token": self.REMNAWAVE_CADDY_TOKEN,
"auth_type": self.REMNAWAVE_AUTH_TYPE
}
@@ -739,7 +811,17 @@ class Settings(BaseSettings):
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def is_auto_activate_after_topup_enabled(self) -> bool:
"""Умная автоактивация после пополнения баланса (без корзины)."""
value = getattr(self, "AUTO_ACTIVATE_AFTER_TOPUP_ENABLED", False)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {"1", "true", "yes", "on"}
return bool(value)
def get_available_languages(self) -> List[str]:
defaults = ["ru", "en", "ua", "zh"]
@@ -779,10 +861,32 @@ class Settings(BaseSettings):
def is_language_selection_enabled(self) -> bool:
return bool(getattr(self, "LANGUAGE_SELECTION_ENABLED", True))
def format_price(self, price_kopeks: int) -> str:
sign = "-" if price_kopeks < 0 else ""
rubles, kopeks = divmod(abs(price_kopeks), 100)
def format_price(self, price_kopeks: int, round_kopeks: Optional[bool] = None) -> str:
"""
Форматирует цену в копейках для отображения пользователю.
Args:
price_kopeks: Сумма в копейках
round_kopeks: Если True, округляет копейки (≤50 вниз, >50 вверх).
Если None, использует настройку PRICE_ROUNDING_ENABLED.
Returns:
Отформатированная строка цены (например, "150 ₽")
"""
# Используем настройку если не передано явно
should_round = round_kopeks if round_kopeks is not None else self.PRICE_ROUNDING_ENABLED
sign = "-" if price_kopeks < 0 else ""
abs_kopeks = abs(price_kopeks)
rubles, kopeks = divmod(abs_kopeks, 100)
if should_round:
# Округление: ≤50 коп вниз, >50 коп вверх
if kopeks > 50:
rubles += 1
return f"{sign}{rubles}"
# Без округления - показываем точное значение
if kopeks:
value = f"{sign}{rubles}.{kopeks:02d}".rstrip("0").rstrip(".")
return f"{value}"
@@ -958,11 +1062,67 @@ class Settings(BaseSettings):
return self.TRAFFIC_SELECTION_MODE.lower() == "selectable"
def is_traffic_fixed(self) -> bool:
"""Возвращает True если выбор трафика отключён (fixed или fixed_with_topup)"""
return self.TRAFFIC_SELECTION_MODE.lower() in ("fixed", "fixed_with_topup")
def is_traffic_topup_blocked(self) -> bool:
"""Возвращает True если докупка трафика полностью заблокирована (только fixed)"""
return self.TRAFFIC_SELECTION_MODE.lower() == "fixed"
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_traffic_topup_enabled(self) -> bool:
return self.TRAFFIC_TOPUP_ENABLED
def get_traffic_topup_packages(self) -> List[Dict]:
"""Возвращает пакеты для докупки трафика. Если не настроены - использует TRAFFIC_PACKAGES_CONFIG."""
config_str = self.TRAFFIC_TOPUP_PACKAGES_CONFIG.strip()
if not config_str:
# Если не настроены отдельные пакеты для докупки - используем основные
return self.get_traffic_packages()
packages = []
for package_config in config_str.split(','):
package_config = package_config.strip()
if not package_config:
continue
parts = package_config.split(':')
if len(parts) >= 2:
try:
gb = int(parts[0])
price = int(parts[1])
enabled = parts[2].lower() == 'true' if len(parts) > 2 else True
packages.append({"gb": gb, "price": price, "enabled": enabled})
except (ValueError, IndexError):
continue
return packages if packages else self.get_traffic_packages()
def get_traffic_topup_price(self, gb: Optional[int]) -> int:
"""Возвращает цену докупки для указанного количества ГБ."""
packages = self.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
if not enabled_packages:
return 0
# Ищем точное совпадение
for pkg in enabled_packages:
if pkg["gb"] == gb:
return pkg["price"]
# Если не нашли - возвращаем 0
return 0
def get_traffic_reset_price_mode(self) -> str:
return self.TRAFFIC_RESET_PRICE_MODE.lower()
def get_traffic_reset_base_price(self) -> int:
return self.TRAFFIC_RESET_BASE_PRICE
def is_devices_selection_enabled(self) -> bool:
return self.DEVICES_SELECTION_ENABLED
@@ -989,7 +1149,70 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> Optional[int]:
return self.get_devices_selection_disabled_amount()
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def get_modem_price_per_month(self) -> int:
try:
value = int(self.MODEM_PRICE_PER_MONTH)
except (TypeError, ValueError):
logger.warning(
"Некорректное значение MODEM_PRICE_PER_MONTH: %s",
self.MODEM_PRICE_PER_MONTH,
)
return 10000
return max(0, value)
def get_modem_period_discounts(self) -> Dict[int, int]:
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
try:
config_str = (self.MODEM_PERIOD_DISCOUNTS or "").strip()
if not config_str:
return {}
discounts: Dict[int, int] = {}
for part in config_str.split(','):
part = part.strip()
if not part:
continue
months_and_discount = part.split(':')
if len(months_and_discount) != 2:
continue
months_str, discount_str = months_and_discount
try:
months = int(months_str.strip())
discount_percent = int(discount_str.strip())
except ValueError:
continue
discounts[months] = max(0, min(100, discount_percent))
return discounts
except Exception:
return {}
def get_modem_period_discount(self, months: int) -> int:
"""Возвращает процент скидки для указанного количества месяцев"""
if months <= 0:
return 0
discounts = self.get_modem_period_discounts()
# Ищем точное совпадение или ближайшее меньшее
applicable_discount = 0
for discount_months, discount_percent in sorted(discounts.items()):
if months >= discount_months:
applicable_discount = discount_percent
return applicable_discount
def is_trial_paid_activation_enabled(self) -> bool:
# Если цена > 0, триал автоматически платный
# (TRIAL_PAYMENT_ENABLED теперь опционален - для обратной совместимости)
if self.TRIAL_ACTIVATION_PRICE > 0:
return True
return bool(self.TRIAL_PAYMENT_ENABLED)
def get_trial_activation_price(self) -> int:
@@ -1083,6 +1306,15 @@ class Settings(BaseSettings):
and self.PLATEGA_SECRET is not None
)
def get_platega_display_name(self) -> str:
name = (self.PLATEGA_DISPLAY_NAME or "").strip()
if not name:
return "Platega"
return name
def get_platega_display_name_html(self) -> str:
return html.escape(self.get_platega_display_name())
def get_platega_return_url(self) -> Optional[str]:
if self.PLATEGA_RETURN_URL:
return self.PLATEGA_RETURN_URL
@@ -1151,6 +1383,13 @@ class Settings(BaseSettings):
and self.WATA_TERMINAL_PUBLIC_ID is not None
)
def is_cloudpayments_enabled(self) -> bool:
return (
self.CLOUDPAYMENTS_ENABLED
and self.CLOUDPAYMENTS_PUBLIC_ID is not None
and self.CLOUDPAYMENTS_API_SECRET is not None
)
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
@@ -1411,6 +1650,40 @@ class Settings(BaseSettings):
return (self.BACKUP_SEND_ENABLED and
self.get_backup_send_chat_id() is not None)
def get_backup_archive_password(self) -> Optional[str]:
password = (self.BACKUP_ARCHIVE_PASSWORD or "").strip()
return password if password else None
# === Log Rotation Methods ===
def is_log_rotation_enabled(self) -> bool:
"""Проверить, включена ли новая система ротации логов."""
return self.LOG_ROTATION_ENABLED
def get_log_rotation_chat_id(self) -> Optional[int]:
"""Получить ID канала для отправки логов.
Если LOG_ROTATION_CHAT_ID не задан, использует BACKUP_SEND_CHAT_ID.
"""
chat_id = self.LOG_ROTATION_CHAT_ID or self.BACKUP_SEND_CHAT_ID
if not chat_id:
return None
try:
return int(chat_id)
except (ValueError, TypeError):
return None
def get_log_rotation_topic_id(self) -> Optional[int]:
"""Получить ID топика для отправки логов.
Если LOG_ROTATION_TOPIC_ID не задан, использует BACKUP_SEND_TOPIC_ID.
"""
topic_id = self.LOG_ROTATION_TOPIC_ID
if topic_id is not None:
return topic_id
return self.BACKUP_SEND_TOPIC_ID
def get_referral_settings(self) -> Dict:
return {
"program_enabled": self.is_referral_program_enabled(),
+245
View File
@@ -0,0 +1,245 @@
"""CRUD operations for CloudPayments payments."""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import CloudPaymentsPayment
logger = logging.getLogger(__name__)
async def create_cloudpayments_payment(
db: AsyncSession,
*,
user_id: int,
invoice_id: str,
amount_kopeks: int,
description: Optional[str] = None,
currency: str = "RUB",
payment_url: Optional[str] = None,
email: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
test_mode: bool = False,
) -> CloudPaymentsPayment:
"""
Create a new CloudPayments payment record.
Args:
db: Database session
user_id: Internal user ID
invoice_id: Unique invoice ID
amount_kopeks: Amount in kopeks
description: Payment description
currency: Currency code (default RUB)
payment_url: Payment widget URL
email: User's email
metadata: Additional metadata
test_mode: Whether this is a test payment
Returns:
Created CloudPaymentsPayment object
"""
payment = CloudPaymentsPayment(
user_id=user_id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status="pending",
is_paid=False,
payment_url=payment_url,
email=email,
metadata_json=metadata,
test_mode=test_mode,
)
db.add(payment)
await db.flush()
await db.refresh(payment)
logger.debug(
"Created CloudPayments payment: id=%s, invoice=%s, amount=%s",
payment.id,
invoice_id,
amount_kopeks,
)
return payment
async def get_cloudpayments_payment_by_invoice_id(
db: AsyncSession,
invoice_id: str,
) -> Optional[CloudPaymentsPayment]:
"""Get CloudPayments payment by invoice ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(
CloudPaymentsPayment.invoice_id == invoice_id
)
)
return result.scalars().first()
async def get_cloudpayments_payment_by_id(
db: AsyncSession,
payment_id: int,
) -> Optional[CloudPaymentsPayment]:
"""Get CloudPayments payment by internal ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.id == payment_id)
)
return result.scalars().first()
async def get_cloudpayments_payment_by_transaction_id(
db: AsyncSession,
transaction_id_cp: int,
) -> Optional[CloudPaymentsPayment]:
"""Get CloudPayments payment by CloudPayments transaction ID."""
result = await db.execute(
select(CloudPaymentsPayment).where(
CloudPaymentsPayment.transaction_id_cp == transaction_id_cp
)
)
return result.scalars().first()
async def update_cloudpayments_payment(
db: AsyncSession,
payment_id: int,
**kwargs: Any,
) -> Optional[CloudPaymentsPayment]:
"""
Update CloudPayments payment record.
Args:
db: Database session
payment_id: Internal payment ID
**kwargs: Fields to update
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
for key, value in kwargs.items():
if hasattr(payment, key):
setattr(payment, key, value)
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
return payment
async def mark_cloudpayments_payment_as_paid(
db: AsyncSession,
payment_id: int,
*,
transaction_id_cp: Optional[int] = None,
token: Optional[str] = None,
card_first_six: Optional[str] = None,
card_last_four: Optional[str] = None,
card_type: Optional[str] = None,
card_exp_date: Optional[str] = None,
email: Optional[str] = None,
callback_payload: Optional[Dict[str, Any]] = None,
) -> Optional[CloudPaymentsPayment]:
"""
Mark CloudPayments payment as paid.
Args:
db: Database session
payment_id: Internal payment ID
transaction_id_cp: CloudPayments transaction ID
token: Card token for recurrent payments
card_first_six: First 6 digits of card
card_last_four: Last 4 digits of card
card_type: Card type (Visa, MasterCard, etc.)
card_exp_date: Card expiration date
email: Payer's email
callback_payload: Full webhook payload
Returns:
Updated payment or None if not found
"""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.status = "completed"
payment.is_paid = True
payment.paid_at = datetime.utcnow()
if transaction_id_cp is not None:
payment.transaction_id_cp = transaction_id_cp
if token:
payment.token = token
if card_first_six:
payment.card_first_six = card_first_six
if card_last_four:
payment.card_last_four = card_last_four
if card_type:
payment.card_type = card_type
if card_exp_date:
payment.card_exp_date = card_exp_date
if email:
payment.email = email
if callback_payload:
payment.callback_payload = callback_payload
payment.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(payment)
logger.info(
"Marked CloudPayments payment as paid: id=%s, invoice=%s",
payment.id,
payment.invoice_id,
)
return payment
async def link_cloudpayments_payment_to_transaction(
db: AsyncSession,
payment_id: int,
transaction_id: int,
) -> Optional[CloudPaymentsPayment]:
"""Link CloudPayments payment to internal transaction."""
payment = await get_cloudpayments_payment_by_id(db, payment_id)
if not payment:
return None
payment.transaction_id = transaction_id
await db.flush()
await db.refresh(payment)
return payment
async def get_user_cloudpayments_payments(
db: AsyncSession,
user_id: int,
*,
limit: int = 10,
offset: int = 0,
) -> list[CloudPaymentsPayment]:
"""Get CloudPayments payments for a user."""
result = await db.execute(
select(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.order_by(CloudPaymentsPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
+20 -2
View File
@@ -40,7 +40,8 @@ async def upsert_template(
slug: str,
name: str,
description: str = "",
prize_days: int = 1,
prize_type: str = "days",
prize_value: str = "1",
max_winners: int = 1,
attempts_per_user: int = 1,
times_per_day: int = 1,
@@ -56,7 +57,8 @@ async def upsert_template(
template.name = name
template.description = description
template.prize_days = prize_days
template.prize_type = prize_type
template.prize_value = prize_value
template.max_winners = max_winners
template.attempts_per_user = attempts_per_user
template.times_per_day = times_per_day
@@ -189,6 +191,22 @@ async def create_attempt(
return attempt
async def update_attempt(
db: AsyncSession,
attempt: ContestAttempt,
*,
answer: Optional[str] = None,
is_winner: bool = False,
) -> ContestAttempt:
"""Update existing attempt with answer and winner status."""
if answer is not None:
attempt.answer = answer
attempt.is_winner = is_winner
await db.commit()
await db.refresh(attempt)
return attempt
async def clear_attempts(db: AsyncSession, round_id: int) -> int:
result = await db.execute(delete(ContestAttempt).where(ContestAttempt.round_id == round_id))
deleted_count = result.rowcount
+170
View File
@@ -269,6 +269,176 @@ async def get_referral_statistics(db: AsyncSession) -> dict:
}
async def get_top_referrers_by_period(
db: AsyncSession,
period: str = "week", # "week" или "month"
sort_by: str = "earnings", # "earnings" или "invited"
limit: int = 20
) -> list:
"""
Получает топ рефереров за период.
Args:
period: "week" (7 дней) или "month" (30 дней)
sort_by: "earnings" (по заработку) или "invited" (по приглашённым)
limit: количество записей
Returns:
Список словарей с данными рефереров
"""
from app.database.models import Transaction, TransactionType
now = datetime.utcnow()
if period == "week":
start_date = now - timedelta(days=7)
else: # month
start_date = now - timedelta(days=30)
if sort_by == "invited":
# Топ по количеству приглашённых за период
referrals_result = await db.execute(
select(
User.referred_by_id.label('referrer_id'),
func.count(User.id).label('invited_count')
)
.where(
and_(
User.referred_by_id.isnot(None),
User.created_at >= start_date
)
)
.group_by(User.referred_by_id)
.order_by(func.count(User.id).desc())
.limit(limit)
)
top_data = []
for row in referrals_result:
# Получаем заработок за период для этого реферера
earnings_result = await db.execute(
select(func.coalesce(func.sum(ReferralEarning.amount_kopeks), 0))
.where(
and_(
ReferralEarning.user_id == row.referrer_id,
ReferralEarning.created_at >= start_date
)
)
)
earnings = earnings_result.scalar() or 0
# Добавляем транзакции REFERRAL_REWARD
trans_earnings_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id == row.referrer_id,
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= start_date
)
)
)
earnings += trans_earnings_result.scalar() or 0
top_data.append({
'referrer_id': row.referrer_id,
'invited_count': row.invited_count,
'earnings_kopeks': earnings
})
else:
# Топ по заработку за период
# Собираем заработки из ReferralEarning
referral_earnings_result = await db.execute(
select(
ReferralEarning.user_id.label('referrer_id'),
func.sum(ReferralEarning.amount_kopeks).label('ref_earnings')
)
.where(ReferralEarning.created_at >= start_date)
.group_by(ReferralEarning.user_id)
)
referral_earnings = {row.referrer_id: row.ref_earnings for row in referral_earnings_result}
# Добавляем транзакции REFERRAL_REWARD
transaction_earnings_result = await db.execute(
select(
Transaction.user_id.label('referrer_id'),
func.sum(Transaction.amount_kopeks).label('trans_earnings')
)
.where(
and_(
Transaction.type == TransactionType.REFERRAL_REWARD.value,
Transaction.created_at >= start_date
)
)
.group_by(Transaction.user_id)
)
# Объединяем заработки
combined_earnings = dict(referral_earnings)
for row in transaction_earnings_result:
if row.referrer_id in combined_earnings:
combined_earnings[row.referrer_id] += row.trans_earnings or 0
else:
combined_earnings[row.referrer_id] = row.trans_earnings or 0
# Сортируем и берём топ
sorted_referrers = sorted(
combined_earnings.items(),
key=lambda x: x[1],
reverse=True
)[:limit]
top_data = []
for referrer_id, earnings in sorted_referrers:
# Получаем количество приглашённых за период
invited_result = await db.execute(
select(func.count(User.id))
.where(
and_(
User.referred_by_id == referrer_id,
User.created_at >= start_date
)
)
)
invited_count = invited_result.scalar() or 0
top_data.append({
'referrer_id': referrer_id,
'invited_count': invited_count,
'earnings_kopeks': earnings
})
# Добавляем информацию о пользователях
result = []
for data in top_data:
user_result = await db.execute(
select(User.id, User.username, User.first_name, User.last_name, User.telegram_id)
.where(User.id == data['referrer_id'])
)
user = user_result.first()
if user:
display_name = ""
if user.first_name:
display_name = user.first_name
if user.last_name:
display_name += f" {user.last_name}"
elif user.username:
display_name = f"@{user.username}"
else:
display_name = f"ID{user.telegram_id}"
result.append({
'user_id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'display_name': display_name,
'invited_count': data['invited_count'],
'earnings_kopeks': data['earnings_kopeks']
})
return result
async def get_user_referral_stats(db: AsyncSession, user_id: int) -> dict:
invited_count_result = await db.execute(
+546 -2
View File
@@ -179,10 +179,20 @@ async def get_contest_leaderboard(
*,
limit: Optional[int] = None,
) -> Sequence[Tuple[User, int, int]]:
"""Получить лидерборд конкурса.
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
"""
contest = await get_referral_contest(db, contest_id)
if not contest:
return []
# Нормализуем границы дат
contest_start = contest.start_at
contest_end = contest.end_at
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
query = (
select(
User,
@@ -190,7 +200,13 @@ async def get_contest_leaderboard(
func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"),
)
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
.group_by(User.id)
.order_by(desc("referral_count"), desc("total_amount"), User.id)
)
@@ -206,10 +222,29 @@ async def get_contest_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[Tuple[User, int]]:
"""Получить участников конкурса.
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
"""
contest = await get_referral_contest(db, contest_id)
if not contest:
return []
contest_start = contest.start_at
contest_end = contest.end_at
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
result = await db.execute(
select(User, func.count(ReferralContestEvent.id).label("referral_count"))
.join(User, User.id == ReferralContestEvent.referrer_id)
.where(ReferralContestEvent.contest_id == contest_id)
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
.group_by(User.id)
)
return result.all()
@@ -297,3 +332,512 @@ async def delete_referral_contest(
) -> None:
await db.delete(contest)
await db.commit()
async def get_contest_payment_stats(
db: AsyncSession,
contest_id: int,
) -> dict:
"""Получить статистику оплат по конкурсу.
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
Returns:
dict: {
"paid_count": int, # Рефералов с платежами > 0
"unpaid_count": int, # Рефералов без платежей
"total_amount": int, # Общая сумма платежей
}
"""
# Получаем даты конкурса для фильтрации
contest = await get_referral_contest(db, contest_id)
if not contest:
return {"paid_count": 0, "unpaid_count": 0, "total_amount": 0}
contest_start = contest.start_at
contest_end = contest.end_at
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
# Считаем рефералов с платежами (только зарегистрированных в период конкурса)
paid_result = await db.execute(
select(func.count(ReferralContestEvent.id))
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.amount_kopeks > 0,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
paid_count = int(paid_result.scalar_one() or 0)
# Считаем рефералов без платежей (только зарегистрированных в период конкурса)
unpaid_result = await db.execute(
select(func.count(ReferralContestEvent.id))
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.amount_kopeks == 0,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
unpaid_count = int(unpaid_result.scalar_one() or 0)
# Общая сумма (только за рефералов зарегистрированных в период конкурса)
total_result = await db.execute(
select(func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0))
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
total_amount = int(total_result.scalar_one() or 0)
return {
"paid_count": paid_count,
"unpaid_count": unpaid_count,
"total_amount": total_amount,
}
async def get_contest_transaction_breakdown(
db: AsyncSession,
contest_id: int,
) -> dict:
"""Получить разбивку транзакций по типам для конкурса.
Учитывает только рефералов, зарегистрированных В ПЕРИОД конкурса.
Returns:
dict: {
"subscription_total": int, # Сумма покупок подписок (копейки)
"deposit_total": int, # Сумма пополнений баланса (копейки)
}
"""
contest = await get_referral_contest(db, contest_id)
if not contest:
return {"subscription_total": 0, "deposit_total": 0}
contest_start = contest.start_at
contest_end = contest.end_at
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
# Получаем referral_id только из событий в период конкурса
events_result = await db.execute(
select(ReferralContestEvent.referral_id)
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
referral_ids = [r[0] for r in events_result.fetchall()]
if not referral_ids:
return {"subscription_total": 0, "deposit_total": 0}
# Сумма покупок подписок
subscription_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
subscription_total = int(subscription_result.scalar_one() or 0)
# Сумма пополнений баланса
deposit_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
deposit_total = int(deposit_result.scalar_one() or 0)
return {
"subscription_total": subscription_total,
"deposit_total": deposit_total,
}
async def upsert_contest_event(
db: AsyncSession,
*,
contest_id: int,
referrer_id: int,
referral_id: int,
amount_kopeks: int = 0,
event_type: str = "subscription_purchase",
) -> Tuple[ReferralContestEvent, bool]:
"""Создать или обновить событие конкурса.
Returns:
Tuple[ReferralContestEvent, bool]: (событие, создано_новое)
"""
result = await db.execute(
select(ReferralContestEvent).where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.referral_id == referral_id,
)
)
)
existing = result.scalar_one_or_none()
if existing:
# Обновляем сумму если она изменилась
if existing.amount_kopeks != amount_kopeks:
existing.amount_kopeks = amount_kopeks
await db.commit()
await db.refresh(existing)
return existing, False
event = ReferralContestEvent(
contest_id=contest_id,
referrer_id=referrer_id,
referral_id=referral_id,
amount_kopeks=amount_kopeks,
event_type=event_type,
occurred_at=datetime.utcnow(),
)
db.add(event)
await db.commit()
await db.refresh(event)
return event, True
async def debug_contest_transactions(
db: AsyncSession,
contest_id: int,
limit: int = 20,
) -> dict:
"""Показать транзакции которые учитываются в конкурсе для отладки.
Возвращает информацию о транзакциях рефералов конкурса,
чтобы понять какие именно платежи считаются.
"""
contest = await get_referral_contest(db, contest_id)
if not contest:
return {"error": "Contest not found"}
# Нормализуем границы дат
contest_start = contest.start_at
contest_end = contest.end_at
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
# Получаем referral_id ТОЛЬКО из событий которые произошли в период конкурса
events_result = await db.execute(
select(ReferralContestEvent.referral_id)
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
referral_ids = [r[0] for r in events_result.fetchall()]
# Также считаем сколько всего событий для сравнения
all_events_result = await db.execute(
select(func.count(ReferralContestEvent.id))
.where(ReferralContestEvent.contest_id == contest_id)
)
total_all_events = int(all_events_result.scalar_one() or 0)
if not referral_ids:
return {
"contest_start": contest_start.isoformat(),
"contest_end": contest_end.isoformat(),
"referral_count": 0,
"total_all_events": total_all_events,
"transactions": [],
}
# Получаем транзакции этих рефералов ЗА период конкурса
transactions_in_period = await db.execute(
select(Transaction)
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]),
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
.order_by(desc(Transaction.created_at))
.limit(limit)
)
txs_in = transactions_in_period.scalars().all()
# Также получаем транзакции ВНЕ периода для сравнения
transactions_outside = await db.execute(
select(Transaction)
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type.in_([
TransactionType.DEPOSIT.value,
TransactionType.SUBSCRIPTION_PAYMENT.value,
]),
func.not_(
and_(
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
),
)
)
.order_by(desc(Transaction.created_at))
.limit(limit)
)
txs_out = transactions_outside.scalars().all()
# Подсчёт общих сумм ПО ТИПАМ
deposit_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.DEPOSIT.value)
subscription_in_period = sum(tx.amount_kopeks for tx in txs_in if tx.type == TransactionType.SUBSCRIPTION_PAYMENT.value)
total_in_period = deposit_in_period + subscription_in_period
total_outside = sum(tx.amount_kopeks for tx in txs_out)
# Подсчёт ПОЛНЫХ сумм (не только sample)
full_deposit_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
full_deposit_total = int(full_deposit_result.scalar_one() or 0)
full_subscription_result = await db.execute(
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(referral_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
full_subscription_total = int(full_subscription_result.scalar_one() or 0)
return {
"contest_start": contest_start.isoformat(),
"contest_end": contest_end.isoformat(),
"referral_count": len(referral_ids),
"total_all_events": total_all_events,
"filtered_out": total_all_events - len(referral_ids),
"transactions_in_period": [
{
"id": tx.id,
"user_id": tx.user_id,
"type": tx.type,
"amount_kopeks": tx.amount_kopeks,
"created_at": tx.created_at.isoformat() if tx.created_at else None,
"payment_method": tx.payment_method,
}
for tx in txs_in
],
"transactions_outside_period": [
{
"id": tx.id,
"user_id": tx.user_id,
"type": tx.type,
"amount_kopeks": tx.amount_kopeks,
"created_at": tx.created_at.isoformat() if tx.created_at else None,
"payment_method": tx.payment_method,
}
for tx in txs_out
],
"total_in_period_kopeks": total_in_period,
"total_outside_period_kopeks": total_outside,
"deposit_total_kopeks": full_deposit_total,
"subscription_total_kopeks": full_subscription_total,
"sample_size": limit,
}
async def sync_contest_events(
db: AsyncSession,
contest_id: int,
) -> dict:
"""Синхронизировать события конкурса с реальными данными.
Обновляет ВСЕ существующие события конкурса, пересчитывая платежи
каждого реферала СТРОГО за период конкурса (start_at - end_at).
Returns:
dict: {
"updated": int, # Событий обновлено
"skipped": int, # Пропущено (нет изменений)
"total_events": int, # Всего событий проверено
"total_amount": int, # Общая сумма платежей
"paid_count": int, # Рефералов с платежами
"unpaid_count": int, # Рефералов без платежей
}
"""
contest = await get_referral_contest(db, contest_id)
if not contest:
return {"error": "Contest not found"}
# Нормализуем границы дат для СТРОГОЙ фильтрации
# start_at должен быть началом дня (00:00:00)
# end_at должен быть концом дня (23:59:59.999999)
contest_start = contest.start_at
contest_end = contest.end_at
# Если start_at содержит только дату (время 00:00), оставляем как есть
# Если end_at содержит только дату, добавляем время до конца дня
if contest_end.hour == 0 and contest_end.minute == 0 and contest_end.second == 0:
# Конец дня: 23:59:59.999999
contest_end = contest_end.replace(hour=23, minute=59, second=59, microsecond=999999)
logger.info(
"Синхронизация конкурса %s: период с %s по %s",
contest_id, contest_start, contest_end
)
stats = {
"updated": 0,
"skipped": 0,
"total_events": 0,
"total_amount": 0,
"paid_count": 0,
"unpaid_count": 0,
"contest_start": contest_start.isoformat(),
"contest_end": contest_end.isoformat(),
}
# Получаем события конкурса ТОЛЬКО те, что произошли в период конкурса
# (реферал зарегистрировался в период проведения конкурса)
events_result = await db.execute(
select(ReferralContestEvent)
.where(
and_(
ReferralContestEvent.contest_id == contest_id,
ReferralContestEvent.occurred_at >= contest_start,
ReferralContestEvent.occurred_at <= contest_end,
)
)
)
events = events_result.scalars().all()
# Также считаем сколько всего событий (для отладки)
all_events_result = await db.execute(
select(func.count(ReferralContestEvent.id))
.where(ReferralContestEvent.contest_id == contest_id)
)
total_all_events = int(all_events_result.scalar_one() or 0)
stats["total_events"] = len(events)
stats["total_all_events"] = total_all_events
stats["filtered_out_events"] = total_all_events - len(events)
stats["deposit_total"] = 0
stats["subscription_total"] = 0
for event in events:
# Считаем ТОЛЬКО покупки подписок (реальные траты на подписки)
subscription_query = (
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id == event.referral_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
sub_result = await db.execute(subscription_query)
subscription_paid = int(sub_result.scalar_one() or 0)
# Также считаем пополнения баланса (для информации)
deposit_query = (
select(func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id == event.referral_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.created_at >= contest_start,
Transaction.created_at <= contest_end,
)
)
)
dep_result = await db.execute(deposit_query)
deposit_paid = int(dep_result.scalar_one() or 0)
stats["subscription_total"] += subscription_paid
stats["deposit_total"] += deposit_paid
# Основная метрика — покупки подписок
total_paid = subscription_paid
# Считаем статистику
if total_paid > 0:
stats["total_amount"] += total_paid
stats["paid_count"] += 1
else:
stats["unpaid_count"] += 1
# Обновляем сумму если изменилась
if event.amount_kopeks != total_paid:
old_amount = event.amount_kopeks
event.amount_kopeks = total_paid
stats["updated"] += 1
# Логируем значительные изменения
if abs(old_amount - total_paid) > 10000: # больше 100 руб разницы
logger.debug(
"Событие %s (реферал %s): %s -> %s коп.",
event.id, event.referral_id, old_amount, total_paid
)
else:
stats["skipped"] += 1
# Сохраняем изменения
await db.commit()
logger.info(
"Синхронизация конкурса %s завершена: обновлено %s, пропущено %s, сумма %s коп.",
contest_id, stats["updated"], stats["skipped"], stats["total_amount"]
)
return stats
+121 -11
View File
@@ -128,17 +128,18 @@ async def create_paid_subscription(
device_limit: Optional[int] = None,
connected_squads: List[str] = None,
update_server_counters: bool = False,
is_trial: bool = False,
) -> Subscription:
end_date = datetime.utcnow() + timedelta(days=duration_days)
if device_limit is None:
device_limit = settings.DEFAULT_DEVICE_LIMIT
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
is_trial=False,
is_trial=is_trial,
start_date=datetime.utcnow(),
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
@@ -222,6 +223,7 @@ async def replace_subscription(
subscription.end_date = current_time + timedelta(days=duration_days)
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик при замене подписки
subscription.device_limit = device_limit
subscription.connected_squads = list(new_squads)
subscription.subscription_url = None
@@ -339,7 +341,17 @@ async def extend_subscription(
if settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
logger.info("🔄 Сбрасываем использованный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
subscription.purchased_traffic_gb = 0 # Сбрасываем докупленный трафик вместе с использованным
logger.info("🔄 Сбрасываем использованный и докупленный трафик согласно настройке RESET_TRAFFIC_ON_PAYMENT")
# В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита
if settings.is_traffic_fixed() and days > 0:
fixed_limit = settings.get_fixed_traffic_limit()
old_limit = subscription.traffic_limit_gb
if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0:
subscription.traffic_limit_gb = fixed_limit
subscription.purchased_traffic_gb = 0
logger.info(f"🔄 Сброс трафика при продлении (fixed_with_topup): {old_limit} ГБ → {fixed_limit} ГБ")
subscription.updated_at = current_time
@@ -797,17 +809,17 @@ async def update_subscription_usage(
return subscription
async def get_all_subscriptions(
db: AsyncSession,
page: int = 1,
db: AsyncSession,
page: int = 1,
limit: int = 10
) -> Tuple[List[Subscription], int]:
count_result = await db.execute(
select(func.count(Subscription.id))
)
total_count = count_result.scalar()
offset = (page - 1) * limit
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
@@ -815,11 +827,27 @@ async def get_all_subscriptions(
.offset(offset)
.limit(limit)
)
subscriptions = result.scalars().all()
return subscriptions, total_count
async def get_subscriptions_batch(
db: AsyncSession,
offset: int = 0,
limit: int = 500,
) -> List[Subscription]:
"""Получает подписки пачками для синхронизации. Загружает связанных пользователей."""
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.order_by(Subscription.id)
.offset(offset)
.limit(limit)
)
return list(result.scalars().all())
async def add_subscription_servers(
db: AsyncSession,
subscription: Subscription,
@@ -1139,7 +1167,12 @@ async def get_subscription_renewal_cost(
total_servers_cost = discounted_servers_per_month * months_in_period
total_servers_discount = servers_discount_per_month * months_in_period
traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
# В режиме fixed_with_topup при продлении используем фиксированный лимит
if settings.is_traffic_fixed():
renewal_traffic_gb = settings.get_fixed_traffic_limit()
else:
renewal_traffic_gb = subscription.traffic_limit_gb
traffic_price_per_month = settings.get_traffic_price(renewal_traffic_gb)
traffic_discount_percent = _get_discount_percent(
user,
promo_group,
@@ -1529,7 +1562,84 @@ async def create_pending_subscription(
subscription.id,
payment_method,
)
return subscription
async def create_pending_trial_subscription(
db: AsyncSession,
user_id: int,
duration_days: int,
traffic_limit_gb: int = 0,
device_limit: int = 1,
connected_squads: List[str] = None,
payment_method: str = "pending",
total_price_kopeks: int = 0
) -> Subscription:
"""Creates a pending trial subscription that will be activated after payment."""
current_time = datetime.utcnow()
end_date = current_time + timedelta(days=duration_days)
existing_subscription = await get_subscription_by_user_id(db, user_id)
if existing_subscription:
if (
existing_subscription.status == SubscriptionStatus.ACTIVE.value
and existing_subscription.end_date > current_time
):
logger.warning(
"⚠️ Попытка создать pending триал для активного пользователя %s. Возвращаем существующую запись.",
user_id,
)
return existing_subscription
# Обновляем существующую подписку
existing_subscription.status = SubscriptionStatus.PENDING.value
existing_subscription.is_trial = True # Помечаем как триальную
existing_subscription.start_date = current_time
existing_subscription.end_date = end_date
existing_subscription.traffic_limit_gb = traffic_limit_gb
existing_subscription.device_limit = device_limit
existing_subscription.connected_squads = connected_squads or []
existing_subscription.traffic_used_gb = 0.0
existing_subscription.updated_at = current_time
await db.commit()
await db.refresh(existing_subscription)
logger.info(
"♻️ Обновлена ожидающая триальная подписка пользователя %s, ID: %s, метод оплаты: %s",
user_id,
existing_subscription.id,
payment_method,
)
return existing_subscription
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.PENDING.value,
is_trial=True, # Помечаем как триальную
start_date=current_time,
end_date=end_date,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads or [],
autopay_enabled=settings.is_autopay_enabled_by_default(),
autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE,
)
db.add(subscription)
await db.commit()
await db.refresh(subscription)
logger.info(
"💳 Создана ожидающая триальная подписка для пользователя %s, ID: %s, метод оплаты: %s",
user_id,
subscription.id,
payment_method,
)
return subscription
+8 -4
View File
@@ -331,13 +331,14 @@ async def add_user_balance(
description: str = "Пополнение баланса",
create_transaction: bool = True,
transaction_type: TransactionType = TransactionType.DEPOSIT,
bot = None
bot = None,
payment_method: Optional[PaymentMethod] = None
) -> bool:
try:
old_balance = user.balance_kopeks
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.utcnow()
if create_transaction:
from app.database.crud.transaction import create_transaction as create_trans
@@ -346,7 +347,8 @@ async def add_user_balance(
user_id=user.id,
type=transaction_type,
amount_kopeks=amount_kopeks,
description=description
description=description,
payment_method=payment_method
)
await db.commit()
@@ -368,19 +370,21 @@ async def add_user_balance_by_id(
amount_kopeks: int,
description: str = "Пополнение баланса",
transaction_type: TransactionType = TransactionType.DEPOSIT,
payment_method: Optional[PaymentMethod] = None,
) -> bool:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error(f"Пользователь с telegram_id {telegram_id} не найден")
return False
return await add_user_balance(
db,
user,
amount_kopeks,
description,
transaction_type=transaction_type,
payment_method=payment_method,
)
except Exception as e:
+230 -66
View File
@@ -1,26 +1,39 @@
import asyncio
import logging
from typing import AsyncGenerator, Optional
from functools import wraps
from typing import AsyncGenerator, Callable, Optional, TypeVar
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
AsyncSession,
create_async_engine,
async_sessionmaker,
AsyncEngine
)
from sqlalchemy.pool import NullPool, AsyncAdaptedQueuePool
from sqlalchemy import event, text, bindparam, inspect
from sqlalchemy.engine import Engine
from sqlalchemy.exc import OperationalError, InterfaceError
import time
from app.config import settings
from app.database.models import Base
logger = logging.getLogger(__name__)
T = TypeVar("T")
# ============================================================================
# PRODUCTION-GRADE CONNECTION POOLING
# ============================================================================
if settings.get_database_url().startswith("sqlite"):
def _is_sqlite_url(url: str) -> bool:
"""Проверка на SQLite URL (поддерживает sqlite:// и sqlite+aiosqlite://)"""
return url.startswith("sqlite") or ":memory:" in url
DATABASE_URL = settings.get_database_url()
IS_SQLITE = _is_sqlite_url(DATABASE_URL)
if IS_SQLITE:
poolclass = NullPool
pool_kwargs = {}
else:
@@ -31,7 +44,7 @@ else:
"pool_timeout": 30,
"pool_recycle": 3600,
"pool_pre_ping": True,
# 🔥 Агрессивная очистка мертвых соединений
# Агрессивная очистка мертвых соединений
"pool_reset_on_return": "rollback",
}
@@ -39,28 +52,30 @@ else:
# ENGINE WITH ADVANCED OPTIMIZATIONS
# ============================================================================
# PostgreSQL-специфичные connect_args
_pg_connect_args = {
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
}
engine = create_async_engine(
settings.get_database_url(),
DATABASE_URL,
poolclass=poolclass,
echo=settings.DEBUG,
future=True,
**pool_kwargs,
connect_args={
"server_settings": {
"application_name": "remnawave_bot",
"jit": "on",
"statement_timeout": "60000", # 60 секунд
"idle_in_transaction_session_timeout": "300000", # 5 минут
},
"command_timeout": 60,
"timeout": 10,
} if not settings.get_database_url().startswith("sqlite") else {},
# Кеш скомпилированных запросов (правильное размещение)
query_cache_size=500,
connect_args=_pg_connect_args if not IS_SQLITE else {},
execution_options={
"isolation_level": "READ COMMITTED", # Оптимальный для большинства случаев
"compiled_cache_size": 500, # Кеш скомпилированных запросов
}
"isolation_level": "READ COMMITTED",
},
**pool_kwargs,
)
# ============================================================================
@@ -71,10 +86,86 @@ AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False, # 🔥 Критично для производительности
autoflush=False, # Критично для производительности
autocommit=False,
)
# ============================================================================
# RETRY LOGIC FOR DATABASE OPERATIONS
# ============================================================================
RETRYABLE_EXCEPTIONS = (OperationalError, InterfaceError, ConnectionRefusedError, OSError)
DEFAULT_RETRY_ATTEMPTS = 3
DEFAULT_RETRY_DELAY = 0.5 # секунды
def with_db_retry(
attempts: int = DEFAULT_RETRY_ATTEMPTS,
delay: float = DEFAULT_RETRY_DELAY,
backoff: float = 2.0,
) -> Callable:
"""
Декоратор для автоматического retry при сбоях подключения к БД.
Args:
attempts: Количество попыток
delay: Начальная задержка между попытками (секунды)
backoff: Множитель задержки для каждой следующей попытки
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
last_exception = None
current_delay = delay
for attempt in range(1, attempts + 1):
try:
return await func(*args, **kwargs)
except RETRYABLE_EXCEPTIONS as e:
last_exception = e
if attempt < attempts:
logger.warning(
"Ошибка БД (попытка %d/%d): %s. Повтор через %.1f сек...",
attempt, attempts, str(e)[:100], current_delay
)
await asyncio.sleep(current_delay)
current_delay *= backoff
else:
logger.error(
"Ошибка БД: все %d попыток исчерпаны. Последняя ошибка: %s",
attempts, str(e)
)
raise last_exception
return wrapper
return decorator
async def execute_with_retry(
session: AsyncSession,
statement,
attempts: int = DEFAULT_RETRY_ATTEMPTS,
):
"""Выполнение SQL с retry логикой."""
last_exception = None
delay = DEFAULT_RETRY_DELAY
for attempt in range(1, attempts + 1):
try:
return await session.execute(statement)
except RETRYABLE_EXCEPTIONS as e:
last_exception = e
if attempt < attempts:
logger.warning(
"SQL retry (попытка %d/%d): %s",
attempt, attempts, str(e)[:100]
)
await asyncio.sleep(delay)
delay *= 2
raise last_exception
# ============================================================================
# QUERY PERFORMANCE MONITORING
# ============================================================================
@@ -97,35 +188,68 @@ if settings.DEBUG:
# ADVANCED SESSION MANAGER WITH READ REPLICAS
# ============================================================================
HEALTH_CHECK_TIMEOUT = 5.0 # секунды
def _validate_database_url(url: Optional[str]) -> Optional[str]:
"""Валидация URL базы данных."""
if not url:
return None
url = url.strip()
if not url or url.isspace():
return None
# Простая проверка на валидный формат
if not ("://" in url or url.startswith("sqlite")):
logger.warning("Невалидный DATABASE_URL: %s", url[:20])
return None
return url
class DatabaseManager:
"""Продвинутый менеджер БД с поддержкой реплик и кеширования"""
def __init__(self):
self.engine = engine
self.read_replica_engine: Optional[AsyncEngine] = None
if hasattr(settings, 'DATABASE_READ_REPLICA_URL') and settings.DATABASE_READ_REPLICA_URL:
self.read_replica_engine = create_async_engine(
settings.DATABASE_READ_REPLICA_URL,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
echo=False,
)
self._read_replica_session_factory: Optional[async_sessionmaker] = None
# Валидация и создание read replica engine
replica_url = _validate_database_url(
getattr(settings, 'DATABASE_READ_REPLICA_URL', None)
)
if replica_url:
try:
self.read_replica_engine = create_async_engine(
replica_url,
poolclass=poolclass,
pool_size=30, # Больше для read операций
max_overflow=50,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
# Создаём sessionmaker один раз (не при каждом вызове)
self._read_replica_session_factory = async_sessionmaker(
bind=self.read_replica_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
logger.info("Read replica настроена: %s", replica_url[:30] + "...")
except Exception as e:
logger.error("Не удалось настроить read replica: %s", e)
self.read_replica_engine = None
@asynccontextmanager
async def session(self, read_only: bool = False):
target_engine = self.read_replica_engine if (read_only and self.read_replica_engine) else self.engine
async_session = async_sessionmaker(
bind=target_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async with async_session() as session:
"""Контекстный менеджер для работы с сессией БД."""
# Используем предсозданный sessionmaker вместо создания нового
if read_only and self._read_replica_session_factory:
session_factory = self._read_replica_session_factory
else:
session_factory = AsyncSessionLocal
async with session_factory() as session:
try:
yield session
if not read_only:
@@ -133,20 +257,31 @@ class DatabaseManager:
except Exception:
await session.rollback()
raise
async def health_check(self) -> dict:
async def health_check(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> dict:
"""
Проверка здоровья БД с таймаутом.
Args:
timeout: Максимальное время ожидания (секунды)
"""
pool = self.engine.pool
status = "unhealthy"
latency = None
try:
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
async with asyncio.timeout(timeout):
async with AsyncSessionLocal() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except asyncio.TimeoutError:
logger.error("Health check таймаут (%s сек)", timeout)
status = "timeout"
except Exception as e:
logger.error(f"Database health check failed: {e}")
logger.error("Database health check failed: %s", e)
status = "unhealthy"
latency = None
return {
"status": status,
@@ -154,6 +289,34 @@ class DatabaseManager:
"pool": _collect_health_pool_metrics(pool),
}
async def health_check_replica(self, timeout: float = HEALTH_CHECK_TIMEOUT) -> Optional[dict]:
"""Проверка здоровья read replica."""
if not self.read_replica_engine:
return None
pool = self.read_replica_engine.pool
status = "unhealthy"
latency = None
try:
async with asyncio.timeout(timeout):
async with self._read_replica_session_factory() as session:
start = time.time()
await session.execute(text("SELECT 1"))
latency = (time.time() - start) * 1000
status = "healthy"
except asyncio.TimeoutError:
status = "timeout"
except Exception as e:
logger.error("Read replica health check failed: %s", e)
return {
"status": status,
"latency_ms": round(latency, 2) if latency else None,
"pool": _collect_health_pool_metrics(pool),
}
db_manager = DatabaseManager()
# ============================================================================
@@ -254,13 +417,13 @@ batch_ops = BatchOperations()
async def init_db():
"""Инициализация БД с оптимизациями"""
logger.info("🚀 Создание таблиц базы данных...")
logger.info("Создание таблиц базы данных...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if not settings.get_database_url().startswith("sqlite"):
logger.info("📊 Создание индексов для оптимизации...")
if not IS_SQLITE:
logger.info("Создание индексов для оптимизации...")
async with engine.begin() as conn:
indexes = [
@@ -294,22 +457,23 @@ async def init_db():
await conn.execute(text(index_sql))
except Exception as e:
logger.debug("Index creation skipped for %s: %s", table_name, e)
logger.info("База данных успешно инициализирована")
logger.info("База данных успешно инициализирована")
health = await db_manager.health_check()
logger.info(f"📊 Database health: {health}")
logger.info("Database health: %s", health)
async def close_db():
"""Корректное закрытие всех соединений"""
logger.info("🔄 Закрытие соединений с БД...")
logger.info("Закрытие соединений с БД...")
await engine.dispose()
if db_manager.read_replica_engine:
await db_manager.read_replica_engine.dispose()
logger.info("Все подключения к базе данных закрыты")
logger.info("Все подключения к базе данных закрыты")
# ============================================================================
# CONNECTION POOL METRICS (для мониторинга)
+116 -4
View File
@@ -86,6 +86,7 @@ class PaymentMethod(Enum):
PAL24 = "pal24"
WATA = "wata"
PLATEGA = "platega"
CLOUDPAYMENTS = "cloudpayments"
MANUAL = "manual"
@@ -468,6 +469,82 @@ class PlategaPayment(Base):
)
class CloudPaymentsPayment(Base):
__tablename__ = "cloudpayments_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# CloudPayments идентификаторы
transaction_id_cp = Column(Integer, unique=True, nullable=True, index=True) # TransactionId от CloudPayments
invoice_id = Column(String(255), unique=True, nullable=False, index=True) # Наш InvoiceId
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
status = Column(String(50), nullable=False, default="pending") # pending, completed, failed, authorized
is_paid = Column(Boolean, default=False)
paid_at = Column(DateTime, nullable=True)
# Данные карты (маскированные)
card_first_six = Column(String(6), nullable=True)
card_last_four = Column(String(4), nullable=True)
card_type = Column(String(50), nullable=True) # Visa, MasterCard, etc.
card_exp_date = Column(String(10), nullable=True) # MM/YY
# Токен для рекуррентных платежей
token = Column(String(255), nullable=True)
# URL для оплаты (виджет)
payment_url = Column(Text, nullable=True)
# Email плательщика
email = Column(String(255), nullable=True)
# Тестовый режим
test_mode = Column(Boolean, default=False)
# Дополнительные данные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Связь с транзакцией в нашей системе
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
user = relationship("User", backref="cloudpayments_payments")
transaction = relationship("Transaction", backref="cloudpayments_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_completed(self) -> bool:
return self.status == "completed" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status == "failed"
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<CloudPaymentsPayment(id={0}, invoice={1}, amount={2}₽, status={3})>".format(
self.id,
self.invoice_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
@@ -611,6 +688,17 @@ class User(Base):
promo_group = relationship("PromoGroup", back_populates="users")
user_promo_groups = relationship("UserPromoGroup", back_populates="user", cascade="all, delete-orphan")
poll_responses = relationship("PollResponse", back_populates="user")
last_pinned_message_id = Column(Integer, nullable=True)
# Ограничения пользователя
restriction_topup = Column(Boolean, default=False, nullable=False) # Запрет пополнения
restriction_subscription = Column(Boolean, default=False, nullable=False) # Запрет продления/покупки
restriction_reason = Column(String(500), nullable=True) # Причина ограничения
@property
def has_restrictions(self) -> bool:
"""Проверить, есть ли у пользователя активные ограничения."""
return self.restriction_topup or self.restriction_subscription
@property
def balance_rubles(self) -> float:
@@ -677,11 +765,13 @@ class Subscription(Base):
traffic_limit_gb = Column(Integer, default=0)
traffic_used_gb = Column(Float, default=0.0)
purchased_traffic_gb = Column(Integer, default=0) # Докупленный трафик (для расчета цены сброса)
subscription_url = Column(String, nullable=True)
subscription_crypto_link = Column(String, nullable=True)
device_limit = Column(Integer, default=1)
modem_enabled = Column(Boolean, default=False)
connected_squads = Column(JSON, default=list)
@@ -840,10 +930,14 @@ class Transaction(Base):
description = Column(Text, nullable=True)
payment_method = Column(String(50), nullable=True)
external_id = Column(String(255), nullable=True)
external_id = Column(String(255), nullable=True)
is_completed = Column(Boolean, default=True)
# NaloGO чек
receipt_uuid = Column(String(255), nullable=True, index=True)
receipt_created_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
completed_at = Column(DateTime, nullable=True)
@@ -1029,7 +1123,8 @@ class ContestTemplate(Base):
name = Column(String(100), nullable=False)
slug = Column(String(50), nullable=False, unique=True, index=True)
description = Column(Text, nullable=True)
prize_days = Column(Integer, nullable=False, default=1)
prize_type = Column(String(20), nullable=False, default="days")
prize_value = Column(String(50), nullable=False, default="1")
max_winners = Column(Integer, nullable=False, default=1)
attempts_per_user = Column(Integer, nullable=False, default=1)
times_per_day = Column(Integer, nullable=False, default=1)
@@ -1553,6 +1648,23 @@ class WelcomeText(Base):
creator = relationship("User", backref="created_welcome_texts")
class PinnedMessage(Base):
__tablename__ = "pinned_messages"
id = Column(Integer, primary_key=True, index=True)
content = Column(Text, nullable=False, default="")
media_type = Column(String(32), nullable=True)
media_file_id = Column(String(255), nullable=True)
send_before_menu = Column(Boolean, nullable=False, server_default="1", default=True)
send_on_every_start = Column(Boolean, nullable=False, server_default="1", default=True)
is_active = Column(Boolean, default=True)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
creator = relationship("User", backref="pinned_messages")
class AdvertisingCampaign(Base):
__tablename__ = "advertising_campaigns"
+534 -2
View File
@@ -3014,6 +3014,157 @@ async def create_welcome_texts_table():
logger.error(f"Ошибка создания таблицы welcome_texts: {e}")
return False
async def create_pinned_messages_table():
table_exists = await check_table_exists("pinned_messages")
if table_exists:
logger.info("Таблица pinned_messages уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
create_sql = """
CREATE TABLE pinned_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL DEFAULT '',
media_type VARCHAR(32) NULL,
media_file_id VARCHAR(255) NULL,
send_before_menu BOOLEAN NOT NULL DEFAULT 1,
send_on_every_start BOOLEAN NOT NULL DEFAULT 1,
is_active BOOLEAN DEFAULT 1,
created_by INTEGER NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active);
"""
elif db_type == "postgresql":
create_sql = """
CREATE TABLE pinned_messages (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL DEFAULT '',
media_type VARCHAR(32) NULL,
media_file_id VARCHAR(255) NULL,
send_before_menu BOOLEAN NOT NULL DEFAULT TRUE,
send_on_every_start BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN DEFAULT TRUE,
created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS ix_pinned_messages_active ON pinned_messages(is_active);
"""
elif db_type == "mysql":
create_sql = """
CREATE TABLE pinned_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
content TEXT NOT NULL DEFAULT '',
media_type VARCHAR(32) NULL,
media_file_id VARCHAR(255) NULL,
send_before_menu BOOLEAN NOT NULL DEFAULT TRUE,
send_on_every_start BOOLEAN NOT NULL DEFAULT TRUE,
is_active BOOLEAN DEFAULT TRUE,
created_by INT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX ix_pinned_messages_active ON pinned_messages(is_active);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы pinned_messages: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("✅ Таблица pinned_messages успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы pinned_messages: {e}")
return False
async def ensure_pinned_message_media_columns():
table_exists = await check_table_exists("pinned_messages")
if not table_exists:
logger.warning("⚠️ Таблица pinned_messages отсутствует — пропускаем обновление медиа полей")
return False
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if not await check_column_exists("pinned_messages", "media_type"):
await conn.execute(
text("ALTER TABLE pinned_messages ADD COLUMN media_type VARCHAR(32)")
)
if not await check_column_exists("pinned_messages", "media_file_id"):
await conn.execute(
text("ALTER TABLE pinned_messages ADD COLUMN media_file_id VARCHAR(255)")
)
if not await check_column_exists("pinned_messages", "send_before_menu"):
default_value = "TRUE" if db_type != "sqlite" else "1"
await conn.execute(
text(
f"ALTER TABLE pinned_messages ADD COLUMN send_before_menu BOOLEAN NOT NULL DEFAULT {default_value}"
)
)
if not await check_column_exists("pinned_messages", "send_on_every_start"):
default_value = "TRUE" if db_type != "sqlite" else "1"
await conn.execute(
text(
f"ALTER TABLE pinned_messages ADD COLUMN send_on_every_start BOOLEAN NOT NULL DEFAULT {default_value}"
)
)
await conn.execute(text("UPDATE pinned_messages SET content = '' WHERE content IS NULL"))
if db_type == "postgresql":
await conn.execute(
text("ALTER TABLE pinned_messages ALTER COLUMN content SET DEFAULT ''")
)
elif db_type == "mysql":
await conn.execute(
text("ALTER TABLE pinned_messages MODIFY content TEXT NOT NULL DEFAULT ''")
)
else:
logger.info("ℹ️ Пропускаем установку DEFAULT для content в SQLite")
logger.info("✅ Медиа поля pinned_messages приведены в актуальное состояние")
return True
except Exception as e:
logger.error(f"Ошибка обновления медиа полей pinned_messages: {e}")
return False
async def ensure_user_last_pinned_column():
try:
async with engine.begin() as conn:
if not await check_column_exists("users", "last_pinned_message_id"):
await conn.execute(
text("ALTER TABLE users ADD COLUMN last_pinned_message_id INTEGER")
)
logger.info("✅ Поле last_pinned_message_id у пользователей готово")
return True
except Exception as e:
logger.error(f"Ошибка добавления поля last_pinned_message_id: {e}")
return False
async def add_media_fields_to_broadcast_history():
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ МЕДИА В BROADCAST_HISTORY ===")
@@ -3125,6 +3276,81 @@ async def add_ticket_sla_columns():
return False
async def add_user_restriction_columns() -> bool:
"""Добавить колонки ограничений пользователей в таблицу users."""
try:
col_topup = await check_column_exists('users', 'restriction_topup')
col_sub = await check_column_exists('users', 'restriction_subscription')
col_reason = await check_column_exists('users', 'restriction_reason')
if col_topup and col_sub and col_reason:
logger.info("ℹ️ Колонки ограничений пользователей уже существуют")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not col_topup:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_topup BOOLEAN DEFAULT FALSE NOT NULL"
))
else:
logger.error(f"Неподдерживаемый тип БД: {db_type}")
return False
logger.info("✅ Добавлена колонка users.restriction_topup")
if not col_sub:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT 0 NOT NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_subscription BOOLEAN DEFAULT FALSE NOT NULL"
))
else:
logger.error(f"Неподдерживаемый тип БД: {db_type}")
return False
logger.info("✅ Добавлена колонка users.restriction_subscription")
if not col_reason:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE users ADD COLUMN restriction_reason VARCHAR(500) NULL"
))
else:
logger.error(f"Неподдерживаемый тип БД: {db_type}")
return False
logger.info("✅ Добавлена колонка users.restriction_reason")
return True
except Exception as e:
logger.error(f"Ошибка добавления колонок ограничений пользователей: {e}")
return False
async def add_subscription_crypto_link_column() -> bool:
column_exists = await check_column_exists('subscriptions', 'subscription_crypto_link')
if column_exists:
@@ -4404,6 +4630,202 @@ async def add_promocode_promo_group_column() -> bool:
return False
async def migrate_contest_templates_prize_columns() -> bool:
"""Миграция contest_templates: prize_days -> prize_type + prize_value."""
try:
prize_type_exists = await check_column_exists("contest_templates", "prize_type")
prize_value_exists = await check_column_exists("contest_templates", "prize_value")
if prize_type_exists and prize_value_exists:
logger.info("Колонки prize_type и prize_value уже существуют в contest_templates")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
# Добавляем prize_type
if not prize_type_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
else:
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_type VARCHAR(20) NOT NULL DEFAULT 'days'"
))
logger.info("✅ Добавлена колонка prize_type в contest_templates")
# Добавляем prize_value
if not prize_value_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
else:
await conn.execute(text(
"ALTER TABLE contest_templates ADD COLUMN prize_value VARCHAR(50) NOT NULL DEFAULT '1'"
))
logger.info("✅ Добавлена колонка prize_value в contest_templates")
# Мигрируем данные из prize_days в prize_value (если prize_days существует)
prize_days_exists = await check_column_exists("contest_templates", "prize_days")
if prize_days_exists:
await conn.execute(text(
"UPDATE contest_templates SET prize_value = CAST(prize_days AS VARCHAR) WHERE prize_type = 'days'"
))
logger.info("✅ Данные из prize_days перенесены в prize_value")
return True
except Exception as error:
logger.error(f"❌ Ошибка миграции prize_type/prize_value в contest_templates: {error}")
return False
async def add_subscription_modem_enabled_column() -> bool:
"""Добавить колонку modem_enabled в subscriptions."""
try:
column_exists = await check_column_exists("subscriptions", "modem_enabled")
if column_exists:
logger.info("Колонка modem_enabled уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT 0"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled BOOLEAN DEFAULT FALSE"
))
else:
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN modem_enabled TINYINT(1) DEFAULT 0"
))
logger.info("✅ Добавлена колонка modem_enabled в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления modem_enabled в subscriptions: {error}")
return False
async def add_subscription_purchased_traffic_column() -> bool:
"""Добавить колонку purchased_traffic_gb в subscriptions."""
try:
column_exists = await check_column_exists("subscriptions", "purchased_traffic_gb")
if column_exists:
logger.info("Колонка purchased_traffic_gb уже существует в subscriptions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INTEGER DEFAULT 0"
))
else:
await conn.execute(text(
"ALTER TABLE subscriptions ADD COLUMN purchased_traffic_gb INT DEFAULT 0"
))
logger.info("✅ Добавлена колонка purchased_traffic_gb в subscriptions")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления purchased_traffic_gb в subscriptions: {error}")
return False
async def add_transaction_receipt_columns() -> bool:
"""Добавить колонки receipt_uuid и receipt_created_at в transactions."""
try:
receipt_uuid_exists = await check_column_exists("transactions", "receipt_uuid")
receipt_created_at_exists = await check_column_exists("transactions", "receipt_created_at")
if receipt_uuid_exists and receipt_created_at_exists:
logger.info("Колонки receipt_uuid и receipt_created_at уже существуют в transactions")
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not receipt_uuid_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
else:
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_uuid VARCHAR(255)"
))
logger.info("✅ Добавлена колонка receipt_uuid в transactions")
if not receipt_created_at_exists:
if db_type == "sqlite":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
))
elif db_type == "postgresql":
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at TIMESTAMP"
))
else:
await conn.execute(text(
"ALTER TABLE transactions ADD COLUMN receipt_created_at DATETIME"
))
logger.info("✅ Добавлена колонка receipt_created_at в transactions")
# Создаём индекс на receipt_uuid
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == "postgresql":
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
elif db_type == "sqlite":
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
else:
await conn.execute(text(
"CREATE INDEX ix_transactions_receipt_uuid "
"ON transactions (receipt_uuid)"
))
except Exception as idx_error:
logger.warning(f"Индекс на receipt_uuid возможно уже существует: {idx_error}")
return True
except Exception as error:
logger.error(f"❌ Ошибка добавления колонок чеков в transactions: {error}")
return False
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
@@ -4591,6 +5013,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей contest_templates")
logger.info("=== МИГРАЦИЯ КОЛОНОК ПРИЗА В CONTEST_TEMPLATES ===")
prize_columns_ready = await migrate_contest_templates_prize_columns()
if prize_columns_ready:
logger.info("✅ Колонки prize_type и prize_value готовы")
else:
logger.warning("⚠️ Проблемы с миграцией prize_type/prize_value")
contest_rounds_ready = await create_contest_rounds_table()
if contest_rounds_ready:
logger.info("✅ Таблица contest_rounds готова")
@@ -4690,12 +5119,33 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей user_messages")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PINNED_MESSAGES ===")
pinned_messages_created = await create_pinned_messages_table()
if pinned_messages_created:
logger.info("✅ Таблица pinned_messages готова")
else:
logger.warning("⚠️ Проблемы с таблицей pinned_messages")
logger.info("=== СОЗДАНИЕ/ОБНОВЛЕНИЕ ТАБЛИЦЫ WELCOME_TEXTS ===")
welcome_texts_created = await create_welcome_texts_table()
if welcome_texts_created:
logger.info("✅ Таблица welcome_texts готова с полем is_enabled")
else:
logger.warning("⚠️ Проблемы с таблицей welcome_texts")
logger.info("=== ОБНОВЛЕНИЕ СХЕМЫ PINNED_MESSAGES ===")
pinned_media_ready = await ensure_pinned_message_media_columns()
if pinned_media_ready:
logger.info("✅ Медиа поля для pinned_messages готовы")
else:
logger.warning("⚠️ Проблемы с медиа полями pinned_messages")
logger.info("=== ДОБАВЛЕНИЕ СЛЕДА ОТПРАВКИ ЗАКРЕПА ДЛЯ ПОЛЬЗОВАТЕЛЕЙ ===")
last_pinned_ready = await ensure_user_last_pinned_column()
if last_pinned_ready:
logger.info("✅ Колонка last_pinned_message_id добавлена")
else:
logger.warning("⚠️ Не удалось обновить колонку last_pinned_message_id")
logger.info("=== ДОБАВЛЕНИЕ МЕДИА ПОЛЕЙ В BROADCAST_HISTORY ===")
media_fields_added = await add_media_fields_to_broadcast_history()
@@ -4725,6 +5175,27 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением колонки subscription_crypto_link")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ MODEM_ENABLED ДЛЯ ПОДПИСОК ===")
modem_enabled_added = await add_subscription_modem_enabled_column()
if modem_enabled_added:
logger.info("✅ Колонка modem_enabled готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки modem_enabled")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНКИ PURCHASED_TRAFFIC_GB ДЛЯ ПОДПИСОК ===")
purchased_traffic_added = await add_subscription_purchased_traffic_column()
if purchased_traffic_added:
logger.info("✅ Колонка purchased_traffic_gb готова")
else:
logger.warning("⚠️ Проблемы с добавлением колонки purchased_traffic_gb")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ОГРАНИЧЕНИЙ ПОЛЬЗОВАТЕЛЕЙ ===")
restrictions_added = await add_user_restriction_columns()
if restrictions_added:
logger.info("✅ Колонки ограничений пользователей готовы")
else:
logger.warning("⚠️ Проблемы с добавлением колонок ограничений пользователей")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
@@ -4825,6 +5296,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей subscription_events")
logger.info("=== ДОБАВЛЕНИЕ КОЛОНОК ЧЕКОВ В TRANSACTIONS ===")
receipt_columns_ready = await add_transaction_receipt_columns()
if receipt_columns_ready:
logger.info("✅ Колонки receipt_uuid и receipt_created_at готовы")
else:
logger.warning("⚠️ Проблемы с колонками чеков в transactions")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
@@ -4880,8 +5358,13 @@ async def check_migration_status():
"cryptobot_table": False,
"heleket_table": False,
"user_messages_table": False,
"pinned_messages_table": False,
"welcome_texts_table": False,
"welcome_texts_is_enabled_column": False,
"pinned_messages_media_columns": False,
"pinned_messages_position_column": False,
"pinned_messages_start_mode_column": False,
"users_last_pinned_column": False,
"broadcast_history_media_fields": False,
"subscription_duplicates": False,
"subscription_conversions_table": False,
@@ -4902,6 +5385,13 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": False,
"users_referral_commission_percent_column": False,
"subscription_crypto_link_column": False,
"subscription_modem_enabled_column": False,
"subscription_purchased_traffic_column": False,
"users_restriction_topup_column": False,
"users_restriction_subscription_column": False,
"users_restriction_reason_column": False,
"contest_templates_prize_type_column": False,
"contest_templates_prize_value_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
"discount_offers_extra_column": False,
@@ -4924,6 +5414,7 @@ async def check_migration_status():
status["cryptobot_table"] = await check_table_exists('cryptobot_payments')
status["heleket_table"] = await check_table_exists('heleket_payments')
status["user_messages_table"] = await check_table_exists('user_messages')
status["pinned_messages_table"] = await check_table_exists('pinned_messages')
status["welcome_texts_table"] = await check_table_exists('welcome_texts')
status["privacy_policies_table"] = await check_table_exists('privacy_policies')
status["public_offers_table"] = await check_table_exists('public_offers')
@@ -4961,7 +5452,14 @@ async def check_migration_status():
status["users_promo_offer_discount_expires_column"] = await check_column_exists('users', 'promo_offer_discount_expires_at')
status["users_referral_commission_percent_column"] = await check_column_exists('users', 'referral_commission_percent')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
status["subscription_modem_enabled_column"] = await check_column_exists('subscriptions', 'modem_enabled')
status["subscription_purchased_traffic_column"] = await check_column_exists('subscriptions', 'purchased_traffic_gb')
status["users_restriction_topup_column"] = await check_column_exists('users', 'restriction_topup')
status["users_restriction_subscription_column"] = await check_column_exists('users', 'restriction_subscription')
status["users_restriction_reason_column"] = await check_column_exists('users', 'restriction_reason')
status["contest_templates_prize_type_column"] = await check_column_exists('contest_templates', 'prize_type')
status["contest_templates_prize_value_column"] = await check_column_exists('contest_templates', 'prize_value')
media_fields_exist = (
await check_column_exists('broadcast_history', 'has_media') and
await check_column_exists('broadcast_history', 'media_type') and
@@ -4969,7 +5467,30 @@ async def check_migration_status():
await check_column_exists('broadcast_history', 'media_caption')
)
status["broadcast_history_media_fields"] = media_fields_exist
pinned_media_columns_exist = (
status["pinned_messages_table"]
and await check_column_exists('pinned_messages', 'media_type')
and await check_column_exists('pinned_messages', 'media_file_id')
)
status["pinned_messages_media_columns"] = pinned_media_columns_exist
status["pinned_messages_position_column"] = (
status["pinned_messages_table"]
and await check_column_exists('pinned_messages', 'send_before_menu')
)
status["pinned_messages_start_mode_column"] = (
status["pinned_messages_table"]
and await check_column_exists('pinned_messages', 'send_on_every_start')
)
status["users_last_pinned_column"] = await check_column_exists('users', 'last_pinned_message_id')
# Колонки чеков в transactions
status["transactions_receipt_uuid_column"] = await check_column_exists('transactions', 'receipt_uuid')
status["transactions_receipt_created_at_column"] = await check_column_exists('transactions', 'receipt_created_at')
async with engine.begin() as conn:
duplicates_check = await conn.execute(text("""
SELECT COUNT(*) FROM (
@@ -4987,10 +5508,15 @@ async def check_migration_status():
"cryptobot_table": "Таблица CryptoBot payments",
"heleket_table": "Таблица Heleket payments",
"user_messages_table": "Таблица пользовательских сообщений",
"pinned_messages_table": "Таблица закреплённых сообщений",
"welcome_texts_table": "Таблица приветственных текстов",
"privacy_policies_table": "Таблица политик конфиденциальности",
"public_offers_table": "Таблица публичных оферт",
"welcome_texts_is_enabled_column": "Поле is_enabled в welcome_texts",
"pinned_messages_media_columns": "Медиа поля в pinned_messages",
"pinned_messages_position_column": "Позиция закрепа (до/после меню)",
"pinned_messages_start_mode_column": "Режим отправки закрепа при /start",
"users_last_pinned_column": "Колонка last_pinned_message_id у пользователей",
"broadcast_history_media_fields": "Медиа поля в broadcast_history",
"subscription_conversions_table": "Таблица конверсий подписок",
"subscription_events_table": "Таблица событий подписок",
@@ -5009,6 +5535,10 @@ async def check_migration_status():
"users_promo_offer_discount_expires_column": "Колонка срока действия промо-скидки у пользователей",
"users_referral_commission_percent_column": "Колонка процента реферальной комиссии у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"subscription_modem_enabled_column": "Колонка modem_enabled в subscriptions",
"subscription_purchased_traffic_column": "Колонка purchased_traffic_gb в subscriptions",
"contest_templates_prize_type_column": "Колонка prize_type в contest_templates",
"contest_templates_prize_value_column": "Колонка prize_value в contest_templates",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
@@ -5024,6 +5554,8 @@ async def check_migration_status():
"promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates",
"promo_offer_logs_table": "Таблица promo_offer_logs",
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
"transactions_receipt_uuid_column": "Колонка receipt_uuid в transactions",
"transactions_receipt_created_at_column": "Колонка receipt_created_at в transactions",
}
for check_key, check_status in status.items():
+34 -10
View File
@@ -225,14 +225,24 @@ class RemnaWaveAPIError(Exception):
class RemnaWaveAPI:
def __init__(self, base_url: str, api_key: str, secret_key: Optional[str] = None,
username: Optional[str] = None, password: Optional[str] = None):
def __init__(
self,
base_url: str,
api_key: str,
secret_key: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
caddy_token: Optional[str] = None,
auth_type: str = "api_key",
):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.secret_key = secret_key
self.username = username
self.password = password
self.caddy_token = caddy_token
self.auth_type = auth_type.lower() if auth_type else "api_key"
self.session: Optional[aiohttp.ClientSession] = None
self.authenticated = False
@@ -264,19 +274,32 @@ class RemnaWaveAPI:
'X-Forwarded-For': '127.0.0.1',
'X-Real-IP': '127.0.0.1'
}
if self.username and self.password:
import base64
# Caddy авторизация — добавляется поверх основной
if self.caddy_token:
# Caddy Security: готовый base64 токен используется как есть
headers['Authorization'] = f'Basic {self.caddy_token}'
logger.debug("Используем Caddy Basic Auth")
# Основная авторизация RemnaWave API
if self.auth_type == "basic" and self.username and self.password:
credentials = f"{self.username}:{self.password}"
encoded_credentials = base64.b64encode(credentials.encode()).decode()
headers['X-Api-Key'] = f"Basic {encoded_credentials}"
logger.debug("Используем Basic Auth в X-Api-Key заголовке")
elif self.auth_type == "caddy":
# Для caddy auth_type основная авторизация уже в Authorization header
# Но API ключ всё равно нужен для RemnaWave
if self.api_key:
headers['X-Api-Key'] = self.api_key
logger.debug("Используем API ключ для RemnaWave + Caddy авторизацию")
else:
# api_key или bearer — стандартный режим
headers['X-Api-Key'] = self.api_key
if not self.caddy_token:
headers['Authorization'] = f'Bearer {self.api_key}'
logger.debug("Используем API ключ в X-Api-Key заголовке")
headers['Authorization'] = f'Bearer {self.api_key}'
return headers
async def __aenter__(self):
@@ -416,7 +439,8 @@ class RemnaWaveAPI:
data['tag'] = tag
if active_internal_squads:
data['activeInternalSquads'] = active_internal_squads
logger.debug("Создание пользователя в панели: %s", data)
response = await self._make_request('POST', '/api/users', data)
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
+3 -1
View File
@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
@@ -69,6 +69,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"YOOKASSA",
"CRYPTOBOT",
"HELEKET",
"CLOUDPAYMENTS",
"MULENPAY",
"PAL24",
"WATA",
@@ -255,6 +256,7 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
"YooKassa": settings.is_yookassa_enabled(),
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"CloudPayments": settings.is_cloudpayments_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
+202 -3
View File
@@ -688,10 +688,15 @@ async def show_detailed_stats(
"📈 <b>Статистика конкурса</b>",
f"🏆 {contest.title}",
"",
f"👥 Участников: <b>{stats['total_participants']}</b>",
f"👥 Участников (рефереров): <b>{stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{stats['total_invited']}</b>",
f"💰 Оплатили подписок: <b>{stats['total_paid_amount'] // 100} руб.</b>",
f"❌ Не оплатили: <b>{stats['total_unpaid']}</b>",
"",
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
"",
"<b>💰 СУММЫ:</b>",
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
]
await callback.message.edit_text(
@@ -760,6 +765,198 @@ async def show_detailed_stats_page(
await callback.answer()
@admin_required
@error_handler
async def sync_contest(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
"""Синхронизировать события конкурса с реальными платежами."""
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден.", show_alert=True)
return
await callback.answer("🔄 Синхронизация запущена...", show_alert=False)
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.sync_contest(db, contest_id)
if "error" in stats:
await callback.message.answer(
f"❌ Ошибка синхронизации:\n{stats['error']}",
)
return
# Формируем сообщение о результатах
# Показываем точные даты которые использовались для фильтрации
start_str = stats.get('contest_start', contest.start_at.isoformat())
end_str = stats.get('contest_end', contest.end_at.isoformat())
lines = [
"✅ <b>Синхронизация завершена!</b>",
"",
f"📊 <b>Конкурс:</b> {contest.title}",
f"📅 <b>Период:</b> {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
f"🔍 <b>Фильтр транзакций:</b>",
f" <code>{start_str}</code>",
f" <code>{end_str}</code>",
"",
f"📝 Рефералов в периоде: <b>{stats.get('total_events', 0)}</b>",
f"⚠️ Отфильтровано (вне периода): <b>{stats.get('filtered_out_events', 0)}</b>",
f"📊 Всего событий в БД: <b>{stats.get('total_all_events', 0)}</b>",
"",
f"🔄 Обновлено сумм: <b>{stats.get('updated', 0)}</b>",
f"⏭ Без изменений: <b>{stats.get('skipped', 0)}</b>",
"",
f"💳 Рефералов оплатили: <b>{stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{stats.get('unpaid_count', 0)}</b>",
"",
"<b>💰 СУММЫ:</b>",
f" 🛒 Покупки подписок: <b>{stats.get('subscription_total', 0) // 100} руб.</b>",
f" 📥 Пополнения баланса: <b>{stats.get('deposit_total', 0) // 100} руб.</b>",
]
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
])
await callback.message.answer(
"\n".join(lines),
parse_mode="HTML",
reply_markup=back_keyboard,
)
# Обновляем основное сообщение с новой статистикой
detailed_stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
general_lines = [
f"🏆 <b>{contest.title}</b>",
f"📅 Период: {contest.start_at.strftime('%d.%m.%Y')} - {contest.end_at.strftime('%d.%m.%Y')}",
"",
f"👥 Участников (рефереров): <b>{detailed_stats['total_participants']}</b>",
f"📨 Приглашено рефералов: <b>{detailed_stats['total_invited']}</b>",
"",
f"💳 Рефералов оплатили: <b>{detailed_stats.get('paid_count', 0)}</b>",
f"❌ Рефералов не оплатили: <b>{detailed_stats.get('unpaid_count', 0)}</b>",
f"🛒 Покупки подписок: <b>{detailed_stats['total_paid_amount'] // 100} руб.</b>",
]
await callback.message.edit_text(
"\n".join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
contest_id, is_active=contest.is_active, language=db_user.language
),
)
@admin_required
@error_handler
async def debug_contest_transactions(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
"""Показать транзакции рефералов конкурса для отладки."""
if not settings.is_contests_enabled():
await callback.answer(
get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."),
show_alert=True,
)
return
contest_id = int(callback.data.split("_")[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer("Конкурс не найден.", show_alert=True)
return
await callback.answer("🔍 Загружаю данные...", show_alert=False)
from app.database.crud.referral_contest import debug_contest_transactions as debug_txs
debug_data = await debug_txs(db, contest_id, limit=10)
if "error" in debug_data:
await callback.message.answer(f"❌ Ошибка: {debug_data['error']}")
return
deposit_total = debug_data.get('deposit_total_kopeks', 0) // 100
subscription_total = debug_data.get('subscription_total_kopeks', 0) // 100
lines = [
"🔍 <b>Отладка транзакций конкурса</b>",
"",
f"📊 <b>Конкурс:</b> {contest.title}",
f"📅 <b>Период фильтрации:</b>",
f" Начало: <code>{debug_data.get('contest_start')}</code>",
f" Конец: <code>{debug_data.get('contest_end')}</code>",
f"👥 <b>Рефералов в периоде:</b> {debug_data.get('referral_count', 0)}",
f"⚠️ <b>Отфильтровано (вне периода):</b> {debug_data.get('filtered_out', 0)}",
f"📊 <b>Всего событий в БД:</b> {debug_data.get('total_all_events', 0)}",
"",
"<b>💰 СУММЫ:</b>",
f" 📥 Пополнения баланса: <b>{deposit_total}</b> руб.",
f" 🛒 Покупки подписок: <b>{subscription_total}</b> руб.",
"",
]
# Показываем транзакции В периоде
txs_in = debug_data.get('transactions_in_period', [])
if txs_in:
lines.append(f"✅ <b>Транзакции в периоде</b> (первые {len(txs_in)}):")
for tx in txs_in[:5]: # Показываем максимум 5
lines.append(
f"{tx['created_at'][:10]} | "
f"{tx['type']} | "
f"{tx['amount_kopeks'] // 100}₽ | "
f"user={tx['user_id']}"
)
if len(txs_in) > 5:
lines.append(f" ... и ещё {len(txs_in) - 5}")
else:
lines.append("✅ <b>Транзакций в периоде:</b> 0")
lines.append("")
# Показываем транзакции ВНЕ периода
txs_out = debug_data.get('transactions_outside_period', [])
if txs_out:
lines.append(f"❌ <b>Транзакции вне периода</b> (первые {len(txs_out)}):")
for tx in txs_out[:5]:
lines.append(
f"{tx['created_at'][:10]} | "
f"{tx['type']} | "
f"{tx['amount_kopeks'] // 100}₽ | "
f"user={tx['user_id']}"
)
if len(txs_out) > 5:
lines.append(f" ... и ещё {len(txs_out) - 5}")
else:
lines.append("❌ <b>Транзакций вне периода:</b> 0")
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
back_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад к конкурсу", callback_data=f"admin_contest_view_{contest_id}")]
])
await callback.message.answer(
"\n".join(lines),
parse_mode="HTML",
reply_markup=back_keyboard,
)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == "admin_contests")
dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral")
@@ -772,6 +969,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_"))
dp.callback_query.register(show_detailed_stats, F.data.startswith("admin_contest_detailed_stats_"))
dp.callback_query.register(show_detailed_stats_page, F.data.startswith("admin_contest_detailed_stats_page_"))
dp.callback_query.register(sync_contest, F.data.startswith("admin_contest_sync_"))
dp.callback_query.register(debug_contest_transactions, F.data.startswith("admin_contest_debug_"))
dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create")
dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"]))
+9 -4
View File
@@ -9,10 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
clear_attempts,
create_round,
get_template_by_id,
list_templates,
update_template_fields,
create_round,
)
from app.database.models import ContestTemplate
from app.keyboards.admin import (
@@ -28,7 +29,8 @@ from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
EDITABLE_FIELDS: Dict[str, Dict] = {
"prize_days": {"type": int, "min": 1, "label": "приз (дни)"},
"prize_type": {"type": str, "label": "тип приза (days/balance/custom)"},
"prize_value": {"type": str, "label": "значение приза"},
"max_winners": {"type": int, "min": 1, "label": "макс. победителей"},
"attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"},
"times_per_day": {"type": int, "min": 1, "label": "раундов в день"},
@@ -57,7 +59,8 @@ async def show_daily_contests(
else:
for tpl in templates:
status = "🟢" if tpl.is_enabled else "⚪️"
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}")
prize_info = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines.append(f"{status} <b>{tpl.name}</b> (slug: {tpl.slug}) — приз {prize_info}, макс {tpl.max_winners}")
keyboard_rows = []
if templates:
@@ -101,10 +104,12 @@ async def show_daily_contest(
await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True)
return
prize_display = f"{tpl.prize_value} ({tpl.prize_type})" if tpl.prize_type else tpl.prize_value
lines = [
f"🏷 <b>{tpl.name}</b> (slug: {tpl.slug})",
f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}",
f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}",
f"Тип приза: {tpl.prize_type or 'days'} | Значение: {tpl.prize_value or '1'}",
f"Макс победителей: {tpl.max_winners}",
f"Попыток/польз: {tpl.attempts_per_user}",
f"Раундов в день: {tpl.times_per_day}",
f"Расписание: {tpl.schedule_times or '-'}",
+381 -20
View File
@@ -1,3 +1,4 @@
import html
import logging
import asyncio
from datetime import datetime, timedelta
@@ -25,13 +26,19 @@ from app.keyboards.admin import (
get_admin_pagination_keyboard, get_broadcast_media_keyboard,
get_media_confirm_keyboard, get_updated_message_buttons_selector_keyboard_with_media,
BROADCAST_BUTTON_ROWS, DEFAULT_BROADCAST_BUTTONS,
get_broadcast_button_config, get_broadcast_button_labels
get_broadcast_button_config, get_broadcast_button_labels, get_pinned_message_keyboard
)
from app.localization.texts import get_texts
from app.database.crud.user import get_users_list
from app.database.crud.subscription import get_expiring_subscriptions
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.services.pinned_message_service import (
broadcast_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
logger = logging.getLogger(__name__)
@@ -167,6 +174,302 @@ async def show_messages_menu(
await callback.answer()
@admin_required
@error_handler
async def show_pinned_message_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
await state.clear()
pinned_message = await get_active_pinned_message(db)
if pinned_message:
content_preview = html.escape(pinned_message.content or "")
last_updated = pinned_message.updated_at or pinned_message.created_at
timestamp_text = last_updated.strftime("%d.%m.%Y %H:%M") if last_updated else ""
media_line = ""
if pinned_message.media_type:
media_label = "Фото" if pinned_message.media_type == "photo" else "Видео"
media_line = f"📎 Медиа: {media_label}\n"
position_line = (
"⬆️ Отправлять перед меню"
if pinned_message.send_before_menu
else "⬇️ Отправлять после меню"
)
start_mode_line = (
"🔁 При каждом /start"
if pinned_message.send_on_every_start
else "🚫 Только один раз и при обновлении"
)
body = (
"📌 <b>Закрепленное сообщение</b>\n\n"
"📝 Текущий текст:\n"
f"<code>{content_preview}</code>\n\n"
f"{media_line}"
f"{position_line}\n"
f"{start_mode_line}\n"
f"🕒 Обновлено: {timestamp_text}"
)
else:
body = (
"📌 <b>Закрепленное сообщение</b>\n\n"
"Сообщение не задано. Отправьте новый текст, чтобы разослать и закрепить его у пользователей."
)
await callback.message.edit_text(
body,
reply_markup=get_pinned_message_keyboard(
db_user.language,
send_before_menu=getattr(pinned_message, "send_before_menu", True),
send_on_every_start=getattr(pinned_message, "send_on_every_start", True),
),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def prompt_pinned_message_update(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
await state.set_state(AdminStates.editing_pinned_message)
await callback.message.edit_text(
"✏️ <b>Новое закрепленное сообщение</b>\n\n"
"Пришлите текст, фото или видео, которое нужно закрепить.\n"
"Бот отправит его всем активным пользователям, открепит старое и закрепит новое без уведомлений.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_pinned_message")]
]),
parse_mode="HTML",
)
await callback.answer()
@admin_required
@error_handler
async def toggle_pinned_message_position(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
pinned_message = await get_active_pinned_message(db)
if not pinned_message:
await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True)
return
pinned_message.send_before_menu = not pinned_message.send_before_menu
pinned_message.updated_at = datetime.utcnow()
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@admin_required
@error_handler
async def toggle_pinned_message_start_mode(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
pinned_message = await get_active_pinned_message(db)
if not pinned_message:
await callback.answer("Сначала задайте закрепленное сообщение", show_alert=True)
return
pinned_message.send_on_every_start = not pinned_message.send_on_every_start
pinned_message.updated_at = datetime.utcnow()
await db.commit()
await show_pinned_message_menu(callback, db_user, db, state)
@admin_required
@error_handler
async def delete_pinned_message(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
pinned_message = await get_active_pinned_message(db)
if not pinned_message:
await callback.answer("Закрепленное сообщение уже отсутствует", show_alert=True)
return
await callback.message.edit_text(
"🗑️ <b>Удаление закрепленного сообщения</b>\n\n"
"Подождите, пока бот открепит сообщение у пользователей...",
parse_mode="HTML",
)
unpinned_count, failed_count, deleted = await unpin_active_pinned_message(
callback.bot,
db,
)
if not deleted:
await callback.message.edit_text(
"❌ Не удалось найти активное закрепленное сообщение для удаления",
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML",
)
await state.clear()
return
total = unpinned_count + failed_count
await callback.message.edit_text(
"✅ <b>Закрепленное сообщение удалено</b>\n\n"
f"👥 Чатов обработано: {total}\n"
f"✅ Откреплено: {unpinned_count}\n"
f"⚠️ Ошибок: {failed_count}\n\n"
"Новое сообщение можно задать кнопкой \"Обновить\".",
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML",
)
await state.clear()
@admin_required
@error_handler
async def process_pinned_message_update(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
texts = get_texts(db_user.language)
media_type: Optional[str] = None
media_file_id: Optional[str] = None
if message.photo:
media_type = "photo"
media_file_id = message.photo[-1].file_id
elif message.video:
media_type = "video"
media_file_id = message.video.file_id
pinned_text = message.html_text or message.caption_html or message.text or message.caption or ""
if not pinned_text and not media_file_id:
await message.answer(
texts.t("ADMIN_PINNED_NO_CONTENT", "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.")
)
return
try:
pinned_message = await set_active_pinned_message(
db,
pinned_text,
db_user.id,
media_type=media_type,
media_file_id=media_file_id,
)
except ValueError as validation_error:
await message.answer(f"{validation_error}")
return
# Сообщение сохранено, спрашиваем о рассылке
from app.keyboards.admin import get_pinned_broadcast_confirm_keyboard
from app.states import AdminStates
await message.answer(
texts.t(
"ADMIN_PINNED_SAVED_ASK_BROADCAST",
"📌 <b>Сообщение сохранено!</b>\n\n"
"Выберите, как доставить сообщение пользователям:\n\n"
"• <b>Разослать сейчас</b> — отправит и закрепит у всех активных пользователей\n"
"• <b>Только при /start</b> — пользователи увидят при следующем запуске бота",
),
reply_markup=get_pinned_broadcast_confirm_keyboard(db_user.language, pinned_message.id),
parse_mode="HTML",
)
await state.set_state(AdminStates.confirming_pinned_broadcast)
@admin_required
@error_handler
async def handle_pinned_broadcast_now(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Разослать закреплённое сообщение сейчас всем пользователям."""
texts = get_texts(db_user.language)
# Получаем ID сообщения из callback_data
pinned_message_id = int(callback.data.split(":")[1])
# Получаем сообщение из БД
from sqlalchemy import select
from app.database.models import PinnedMessage
result = await db.execute(
select(PinnedMessage).where(PinnedMessage.id == pinned_message_id)
)
pinned_message = result.scalar_one_or_none()
if not pinned_message:
await callback.answer("❌ Сообщение не найдено", show_alert=True)
await state.clear()
return
await callback.message.edit_text(
texts.t("ADMIN_PINNED_SAVING", "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей..."),
parse_mode="HTML",
)
sent_count, failed_count = await broadcast_pinned_message(
callback.bot,
db,
pinned_message,
)
total = sent_count + failed_count
await callback.message.edit_text(
texts.t(
"ADMIN_PINNED_UPDATED",
"✅ <b>Закрепленное сообщение обновлено</b>\n\n"
"👥 Получателей: {total}\n"
"✅ Отправлено: {sent}\n"
"⚠️ Ошибок: {failed}",
).format(total=total, sent=sent_count, failed=failed_count),
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML",
)
await state.clear()
@admin_required
@error_handler
async def handle_pinned_broadcast_skip(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
"""Пропустить рассылку — пользователи увидят при /start."""
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t(
"ADMIN_PINNED_SAVED_NO_BROADCAST",
"✅ <b>Закрепленное сообщение сохранено</b>\n\n"
"Рассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.",
),
reply_markup=get_admin_messages_keyboard(db_user.language),
parse_mode="HTML",
)
await state.clear()
@admin_required
@error_handler
async def show_broadcast_targets(
@@ -408,29 +711,56 @@ async def handle_media_selection(
await state.update_data(has_media=False)
await show_button_selector_callback(callback, db_user, state)
return
media_type = callback.data.replace('add_media_', '')
media_instructions = {
"photo": "📷 Отправьте фотографию для рассылки:",
"video": "🎥 Отправьте видео для рассылки:",
"document": "📄 Отправьте документ для рассылки:"
}
await state.update_data(
media_type=media_type,
waiting_for_media=True
)
await callback.message.edit_text(
instruction_text = (
f"{media_instructions.get(media_type, 'Отправьте медиафайл:')}\n\n"
f"<i>Размер файла не должен превышать 50 МБ</i>",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
]),
parse_mode="HTML"
f"<i>Размер файла не должен превышать 50 МБ</i>"
)
instruction_keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_messages")]
])
# Проверяем, является ли текущее сообщение медиа-сообщением
is_media_message = (
callback.message.photo
or callback.message.video
or callback.message.document
or callback.message.animation
or callback.message.audio
or callback.message.voice
)
if is_media_message:
# Удаляем медиа-сообщение и отправляем новое текстовое
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
instruction_text,
reply_markup=instruction_keyboard,
parse_mode="HTML"
)
else:
await callback.message.edit_text(
instruction_text,
reply_markup=instruction_keyboard,
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_broadcast_media)
await callback.answer()
@@ -557,12 +887,12 @@ async def show_button_selector_callback(
if selected_buttons is None:
selected_buttons = list(DEFAULT_SELECTED_BUTTONS)
await state.update_data(selected_buttons=selected_buttons)
media_info = ""
if has_media:
media_type = data.get('media_type', 'файл')
media_info = f"\n🖼️ <b>Медиафайл:</b> {media_type} добавлен"
text = f"""
📘 <b>Выбор дополнительных кнопок</b>
@@ -579,16 +909,39 @@ async def show_button_selector_callback(
Выберите нужные кнопки и нажмите "Продолжить":
"""
keyboard = get_updated_message_buttons_selector_keyboard_with_media(
selected_buttons, has_media, db_user.language
)
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
# Проверяем, является ли текущее сообщение медиа-сообщением
# (фото, видео, документ и т.д.) - для них нельзя использовать edit_text
is_media_message = (
callback.message.photo
or callback.message.video
or callback.message.document
or callback.message.animation
or callback.message.audio
or callback.message.voice
)
if is_media_message:
# Удаляем медиа-сообщение и отправляем новое текстовое
try:
await callback.message.delete()
except Exception:
pass # Игнорируем ошибки удаления
await callback.message.answer(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
else:
await callback.message.edit_text(
text,
reply_markup=keyboard,
parse_mode="HTML"
)
await callback.answer()
@@ -1295,6 +1648,13 @@ def get_target_display_name(target: str) -> str:
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_messages_menu, F.data == "admin_messages")
dp.callback_query.register(show_pinned_message_menu, F.data == "admin_pinned_message")
dp.callback_query.register(toggle_pinned_message_position, F.data == "admin_pinned_message_position")
dp.callback_query.register(toggle_pinned_message_start_mode, F.data == "admin_pinned_message_start_mode")
dp.callback_query.register(delete_pinned_message, F.data == "admin_pinned_message_delete")
dp.callback_query.register(prompt_pinned_message_update, F.data == "admin_pinned_message_edit")
dp.callback_query.register(handle_pinned_broadcast_now, F.data.startswith("admin_pinned_broadcast_now:"))
dp.callback_query.register(handle_pinned_broadcast_skip, F.data.startswith("admin_pinned_broadcast_skip:"))
dp.callback_query.register(show_broadcast_targets, F.data.in_(["admin_msg_all", "admin_msg_by_sub"]))
dp.callback_query.register(select_broadcast_target, F.data.startswith("broadcast_"))
dp.callback_query.register(confirm_broadcast, F.data == "admin_confirm_broadcast")
@@ -1312,3 +1672,4 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(handle_change_media, F.data == "change_media")
dp.message.register(process_broadcast_message, AdminStates.waiting_for_broadcast_message)
dp.message.register(process_broadcast_media, AdminStates.waiting_for_broadcast_media)
dp.message.register(process_pinned_message_update, AdminStates.editing_pinned_message)
+499 -4
View File
@@ -10,6 +10,7 @@ from aiogram.exceptions import TelegramBadRequest
from app.config import settings
from app.database.database import get_db
from app.services.monitoring_service import monitoring_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
@@ -911,11 +912,42 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
# Добавляем информацию о чеках NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
nalogo_section = f"""
🧾 <b>Чеки NaloGO:</b>
Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f}"
text += nalogo_section
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
buttons = []
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
@@ -925,6 +957,469 @@ async def monitoring_statistics_callback(callback: CallbackQuery):
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_nalogo_force_process")
@admin_required
async def nalogo_force_process_callback(callback: CallbackQuery):
"""Принудительная отправка чеков из очереди."""
try:
await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
result = await nalogo_queue_service.force_process()
if "error" in result:
await callback.answer(f"{result['error']}", show_alert=True)
return
message = result.get("message", "Готово")
processed = result.get("processed", 0)
remaining = result.get("remaining", 0)
if processed > 0:
text = f"✅ Обработано: {processed} чек(ов)"
if remaining > 0:
text += f"\n⏳ Осталось в очереди: {remaining}"
else:
if remaining > 0:
text = f"⚠️ Сервис nalog.ru недоступен\nВ очереди: {remaining} чек(ов)"
else:
text = "📭 Очередь пуста"
await callback.answer(text, show_alert=True)
# Обновляем страницу статистики
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Перезагружаем статистику
async for db in get_db():
from app.database.crud.subscription import get_subscriptions_statistics
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
week_errors = len(week_logs) - week_success
stats_text = f"""
📊 <b>Статистика мониторинга</b>
📱 <b>Подписки:</b>
Всего: {sub_stats['total_subscriptions']}
Активных: {sub_stats['active_subscriptions']}
Тестовых: {sub_stats['trial_subscriptions']}
Платных: {sub_stats['paid_subscriptions']}
📈 <b>За сегодня:</b>
Успешных операций: {mon_status['stats_24h']['successful']}
Ошибок: {mon_status['stats_24h']['failed']}
Успешность: {mon_status['stats_24h']['success_rate']}%
📊 <b>За неделю:</b>
Всего событий: {len(week_logs)}
Успешных: {week_success}
Ошибок: {week_errors}
Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 <b>Система:</b>
Интервал: {settings.MONITORING_INTERVAL} мин
Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
nalogo_section = f"""
🧾 <b>Чеки NaloGO:</b>
Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f}"
stats_text += nalogo_section
buttons = []
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка принудительной обработки чеков: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_missing")
@admin_required
async def receipts_missing_callback(callback: CallbackQuery):
"""Сверка чеков по логам."""
# Напрямую вызываем сверку по логам
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_receipts_link_old")
@admin_required
async def receipts_link_old_callback(callback: CallbackQuery):
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy import select, and_
from datetime import date, timedelta
from app.database.models import Transaction, PaymentMethod, TransactionType
from app.services.nalogo_service import NaloGoService
await callback.answer("🔄 Загружаю чеки из NaloGO...", show_alert=False)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
async for db in get_db():
# Получаем старые транзакции без чеков
query = select(Transaction).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.YOOKASSA.value,
Transaction.receipt_uuid.is_(None),
Transaction.is_completed == True,
Transaction.created_at < TRACKING_START_DATE,
)
).order_by(Transaction.created_at.desc())
result = await db.execute(query)
transactions = result.scalars().all()
if not transactions:
await callback.answer("✅ Нет старых транзакций для привязки", show_alert=True)
return
# Получаем чеки из NaloGO за последние 60 дней
nalogo_service = NaloGoService()
to_date = date.today()
from_date = to_date - timedelta(days=60)
incomes = await nalogo_service.get_incomes(
from_date=from_date,
to_date=to_date,
limit=500,
)
if not incomes:
await callback.answer("❌ Не удалось получить чеки из NaloGO", show_alert=True)
return
# Создаём словарь чеков по сумме для быстрого поиска
# Ключ: сумма в копейках, значение: список чеков
incomes_by_amount = {}
for income in incomes:
amount = float(income.get("totalAmount", income.get("amount", 0)))
amount_kopeks = int(amount * 100)
if amount_kopeks not in incomes_by_amount:
incomes_by_amount[amount_kopeks] = []
incomes_by_amount[amount_kopeks].append(income)
linked = 0
for t in transactions:
if t.amount_kopeks in incomes_by_amount:
matching_incomes = incomes_by_amount[t.amount_kopeks]
if matching_incomes:
# Берём первый подходящий чек
income = matching_incomes.pop(0)
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
if receipt_uuid:
t.receipt_uuid = receipt_uuid
# Парсим дату чека
operation_time = income.get("operationTime")
if operation_time:
try:
from dateutil.parser import isoparse
t.receipt_created_at = isoparse(operation_time)
except Exception:
t.receipt_created_at = datetime.utcnow()
linked += 1
if linked > 0:
await db.commit()
text = f"🔗 <b>Привязка завершена</b>\n\n"
text += f"Всего транзакций: {len(transactions)}\n"
text += f"Чеков в NaloGO: {len(incomes)}\n"
text += f"Привязано: <b>{linked}</b>\n"
text += f"Не удалось привязать: {len(transactions) - linked}"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
break
except Exception as e:
logger.error(f"Ошибка привязки старых чеков: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_reconcile")
@admin_required
async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMContext):
"""Меню выбора периода сверки."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Очищаем состояние на случай если остался ввод даты
await state.clear()
# Сразу показываем сверку по логам
await _do_reconcile_logs(callback)
async def _do_reconcile_logs(callback: CallbackQuery):
"""Внутренняя функция сверки по логам."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
from collections import defaultdict
await callback.answer("🔄 Анализирую логи платежей...", show_alert=False)
# Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
try:
await callback.message.edit_text(
"❌ <b>Файл логов не найден</b>\n\n"
f"Путь: <code>{payments_log}</code>\n\n"
"<i>Логи появятся после первого успешного платежа.</i>",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
except TelegramBadRequest:
pass # Сообщение не изменилось
return
# Паттерны для парсинга логов
# Успешный платёж: "Успешно обработан платеж YooKassa 30e3c6fc-000f-5001-9000-1a9c8b242396: пользователь 1046 пополнил баланс на 200.0₽"
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽"
)
# Чек создан: "Чек NaloGO создан для платежа 30e3c6fc-000f-5001-9000-1a9c8b242396: 243udsqtik"
receipt_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)"
)
# Читаем и парсим логи
payments = {} # payment_id -> {date, amount}
receipts = {} # payment_id -> {date, receipt_uuid}
try:
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
# Проверяем платежи
match = payment_pattern.search(line)
if match:
date_str, payment_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"amount": float(amount)
}
continue
# Проверяем чеки
match = receipt_pattern.search(line)
if match:
date_str, payment_id, receipt_uuid = match.groups()
receipts[payment_id] = {
"date": date_str,
"receipt_uuid": receipt_uuid
}
except Exception as e:
logger.error(f"Ошибка чтения логов: {e}")
await callback.message.edit_text(
f"❌ <b>Ошибка чтения логов</b>\n\n{str(e)}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
return
# Находим платежи без чеков
payments_without_receipts = []
for payment_id, payment_data in payments.items():
if payment_id not in receipts:
payments_without_receipts.append({
"payment_id": payment_id,
"date": payment_data["date"],
"amount": payment_data["amount"]
})
# Группируем по датам
by_date = defaultdict(list)
for p in payments_without_receipts:
by_date[p["date"]].append(p)
# Формируем отчёт
total_payments = len(payments)
total_receipts = len(receipts)
missing_count = len(payments_without_receipts)
missing_amount = sum(p["amount"] for p in payments_without_receipts)
text = "📋 <b>Сверка по логам</b>\n\n"
text += f"📦 <b>Всего платежей:</b> {total_payments}\n"
text += f"🧾 <b>Чеков создано:</b> {total_receipts}\n\n"
if missing_count == 0:
text += "✅ <b>Все платежи имеют чеки!</b>"
else:
text += f"⚠️ <b>Без чеков:</b> {missing_count} платежей на {missing_amount:,.2f}\n\n"
# Показываем по датам (последние)
sorted_dates = sorted(by_date.keys(), reverse=True)
for date_str in sorted_dates[:7]:
date_payments = by_date[date_str]
date_amount = sum(p["amount"] for p in date_payments)
text += f"• <b>{date_str}:</b> {len(date_payments)} шт. на {date_amount:,.2f}\n"
if len(sorted_dates) > 7:
text += f"\n<i>...и ещё {len(sorted_dates) - 7} дней</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="📄 Детали", callback_data="admin_mon_reconcile_logs_details")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass # Сообщение не изменилось
except TelegramBadRequest:
pass # Игнорируем если сообщение не изменилось
except Exception as e:
logger.error(f"Ошибка сверки по логам: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_reconcile_logs")
@admin_required
async def receipts_reconcile_logs_refresh_callback(callback: CallbackQuery):
"""Обновить сверку по логам."""
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_reconcile_logs_details")
@admin_required
async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
"""Детальный список платежей без чеков."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
await callback.answer("🔄 Загружаю детали...", show_alert=False)
# Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
await callback.answer("❌ Файл логов не найден", show_alert=True)
return
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽"
)
receipt_pattern = re.compile(
r"Чек NaloGO создан для платежа ([a-f0-9-]+)"
)
payments = {}
receipts = set()
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
match = payment_pattern.search(line)
if match:
date_str, time_str, payment_id, user_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"time": time_str,
"user_id": user_id,
"amount": float(amount)
}
continue
match = receipt_pattern.search(line)
if match:
receipts.add(match.group(1))
# Платежи без чеков
missing = []
for payment_id, data in payments.items():
if payment_id not in receipts:
missing.append({"payment_id": payment_id, **data})
# Сортируем по дате (новые сверху)
missing.sort(key=lambda x: (x["date"], x["time"]), reverse=True)
if not missing:
text = "✅ <b>Все платежи имеют чеки!</b>"
else:
text = f"📄 <b>Платежи без чеков ({len(missing)} шт.)</b>\n\n"
for p in missing[:20]:
text += (
f"• <b>{p['date']} {p['time']}</b>\n"
f" User: {p['user_id']} | {p['amount']:.0f}\n"
f" <code>{p['payment_id'][:18]}...</code>\n\n"
)
if len(missing) > 20:
text += f"<i>...и ещё {len(missing) - 20} платежей</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_reconcile_logs")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass
except TelegramBadRequest:
pass
except Exception as e:
logger.error(f"Ошибка детализации: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+1 -1
View File
@@ -38,7 +38,7 @@ def _method_display(method: PaymentMethod) -> str:
if method == PaymentMethod.YOOKASSA:
return "YooKassa"
if method == PaymentMethod.PLATEGA:
return "Platega"
return settings.get_platega_display_name()
if method == PaymentMethod.CRYPTOBOT:
return "CryptoBot"
if method == PaymentMethod.TELEGRAM_STARS:
+5 -1
View File
@@ -198,6 +198,7 @@ CORE_PRICING_ENTRIES: Tuple[SettingEntry, ...] = (
choices=(
ChoiceOption("selectable", "Выбор пакетов", "Selectable"),
ChoiceOption("fixed", "Фиксированный лимит", "Fixed limit"),
ChoiceOption("fixed_with_topup", "Фикс. лимит + докупка", "Fixed + topup"),
),
description_ru="Определяет, выбирают ли пользователи пакеты или получают фиксированный лимит.",
description_en="Defines whether users pick packages or use a fixed limit.",
@@ -351,8 +352,11 @@ def _format_core_summary(lang_code: str) -> str:
base_price = settings.format_price(settings.BASE_SUBSCRIPTION_PRICE)
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB
if settings.TRAFFIC_SELECTION_MODE == "fixed":
mode = settings.TRAFFIC_SELECTION_MODE.lower()
if mode == "fixed":
traffic_mode = "⚙️ fixed"
elif mode == "fixed_with_topup":
traffic_mode = "⚙️ fixed+topup"
else:
traffic_mode = "⚙️ selectable"
traffic_label = _format_traffic_label(traffic_limit, lang_code, short=True)
+110 -24
View File
@@ -6,7 +6,11 @@ import datetime
from app.config import settings
from app.database.models import User
from app.localization.texts import get_texts
from app.database.crud.referral import get_referral_statistics, get_user_referral_stats
from app.database.crud.referral import (
get_referral_statistics,
get_top_referrers_by_period,
get_user_referral_stats,
)
from app.database.crud.user import get_user_by_id
from app.utils.decorators import admin_required, error_handler
@@ -121,6 +125,39 @@ async def show_referral_statistics(
await callback.answer("Произошла ошибка при загрузке статистики")
def _get_top_keyboard(period: str, sort_by: str) -> types.InlineKeyboardMarkup:
"""Создаёт клавиатуру для выбора периода и сортировки."""
period_week = "✅ Неделя" if period == "week" else "Неделя"
period_month = "✅ Месяц" if period == "month" else "Месяц"
sort_earnings = "✅ По заработку" if sort_by == "earnings" else "По заработку"
sort_invited = "✅ По приглашённым" if sort_by == "invited" else "По приглашённым"
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text=period_week,
callback_data=f"admin_top_ref:week:{sort_by}"
),
types.InlineKeyboardButton(
text=period_month,
callback_data=f"admin_top_ref:month:{sort_by}"
),
],
[
types.InlineKeyboardButton(
text=sort_earnings,
callback_data=f"admin_top_ref:{period}:earnings"
),
types.InlineKeyboardButton(
text=sort_invited,
callback_data=f"admin_top_ref:{period}:invited"
),
],
[types.InlineKeyboardButton(text="🔄 Обновить", callback_data=f"admin_top_ref:{period}:{sort_by}")],
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
@admin_required
@error_handler
async def show_top_referrers(
@@ -128,27 +165,66 @@ async def show_top_referrers(
db_user: User,
db: AsyncSession
):
"""Показывает топ рефереров (по умолчанию: неделя, по заработку)."""
await _show_top_referrers_filtered(callback, db, period="week", sort_by="earnings")
@admin_required
@error_handler
async def show_top_referrers_filtered(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обрабатывает выбор периода и сортировки."""
# Парсим callback_data: admin_top_ref:period:sort_by
parts = callback.data.split(":")
if len(parts) != 3:
await callback.answer("Ошибка параметров")
return
period = parts[1] # week или month
sort_by = parts[2] # earnings или invited
if period not in ("week", "month"):
period = "week"
if sort_by not in ("earnings", "invited"):
sort_by = "earnings"
await _show_top_referrers_filtered(callback, db, period, sort_by)
async def _show_top_referrers_filtered(
callback: types.CallbackQuery,
db: AsyncSession,
period: str,
sort_by: str
):
"""Внутренняя функция отображения топа с фильтрами."""
try:
stats = await get_referral_statistics(db)
top_referrers = stats.get('top_referrers', [])
text = "🏆 <b>Топ рефереров</b>\n\n"
top_referrers = await get_top_referrers_by_period(db, period=period, sort_by=sort_by)
period_text = "за неделю" if period == "week" else "за месяц"
sort_text = "по заработку" if sort_by == "earnings" else "по приглашённым"
text = f"🏆 <b>Топ рефереров {period_text}</b>\n"
text += f"<i>Сортировка: {sort_text}</i>\n\n"
if top_referrers:
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('total_earned_kopeks', 0)
count = referrer.get('referrals_count', 0)
for i, referrer in enumerate(top_referrers[:20], 1):
earned = referrer.get('earnings_kopeks', 0)
count = referrer.get('invited_count', 0)
display_name = referrer.get('display_name', 'N/A')
username = referrer.get('username', '')
telegram_id = referrer.get('telegram_id', 'N/A')
if username:
display_text = f"@{username} (ID{telegram_id})"
elif display_name and display_name != f"ID{telegram_id}":
display_text = f"{display_name} (ID{telegram_id})"
else:
display_text = f"ID{telegram_id}"
emoji = ""
if i == 1:
emoji = "🥇 "
@@ -156,21 +232,30 @@ async def show_top_referrers(
emoji = "🥈 "
elif i == 3:
emoji = "🥉 "
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 {settings.format_price(earned)} | 👥 {count} реф.\n\n"
# Выделяем основную метрику в зависимости от сортировки
if sort_by == "invited":
text += f"{emoji}{i}. {display_text}\n"
text += f" 👥 <b>{count} приглашённых</b> | 💰 {settings.format_price(earned)}\n\n"
else:
text += f"{emoji}{i}. {display_text}\n"
text += f" 💰 <b>{settings.format_price(earned)}</b> | 👥 {count} приглашённых\n\n"
else:
text += "Нет данных о рефererах\n"
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="⬅️ К статистике", callback_data="admin_referrals")]
])
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
text += "Нет данных за выбранный период\n"
keyboard = _get_top_keyboard(period, sort_by)
try:
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
except Exception as edit_error:
if "message is not modified" in str(edit_error):
await callback.answer("Данные актуальны")
else:
raise
except Exception as e:
logger.error(f"Ошибка в show_top_referrers: {e}", exc_info=True)
logger.error(f"Ошибка в show_top_referrers_filtered: {e}", exc_info=True)
await callback.answer("Ошибка загрузки топа рефереров")
@@ -210,4 +295,5 @@ async def show_referral_settings(
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == "admin_referrals")
dp.callback_query.register(show_top_referrers, F.data == "admin_referrals_top")
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith("admin_top_ref:"))
dp.callback_query.register(show_referral_settings, F.data == "admin_referrals_settings")
+168 -52
View File
@@ -26,8 +26,67 @@ from app.utils.cache import RateLimitCache
logger = logging.getLogger(__name__)
# Максимальная длина сообщения Telegram (с запасом)
MAX_MESSAGE_LEN = 3500
def _split_long_block(block: str, max_len: int) -> list[str]:
"""Разбивает слишком длинный блок на части."""
if len(block) <= max_len:
return [block]
parts = []
remaining = block
while remaining:
if len(remaining) <= max_len:
parts.append(remaining)
break
cut_at = max_len
newline_pos = remaining.rfind('\n', 0, max_len)
space_pos = remaining.rfind(' ', 0, max_len)
if newline_pos > max_len // 2:
cut_at = newline_pos + 1
elif space_pos > max_len // 2:
cut_at = space_pos + 1
parts.append(remaining[:cut_at])
remaining = remaining[cut_at:]
return parts
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = MAX_MESSAGE_LEN) -> list[str]:
"""Разбивает текст на страницы с учётом лимита Telegram."""
pages: list[str] = []
current = header
header_len = len(header)
block_max_len = max_len - header_len - 50
for block in message_blocks:
if len(block) > block_max_len:
block_parts = _split_long_block(block, block_max_len)
for part in block_parts:
if len(current) + len(part) > max_len:
if current.strip() and current != header:
pages.append(current)
current = header + part
else:
current += part
elif len(current) + len(block) > max_len:
if current.strip() and current != header:
pages.append(current)
current = header + block
else:
current += block
if current.strip():
pages.append(current)
return pages if pages else [header]
async def show_admin_tickets(
@@ -133,15 +192,27 @@ async def view_admin_ticket(
state: Optional[FSMContext] = None,
ticket_id: Optional[int] = None
):
"""Показать детали тикета для админа"""
"""Показать детали тикета для админа с пагинацией"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
if ticket_id is None:
# Парсим ticket_id и page из callback_data
page = 1
data_str = callback.data or ""
if data_str.startswith("admin_ticket_page_"):
# format: admin_ticket_page_{ticket_id}_{page}
try:
ticket_id = int((callback.data or "").split("_")[-1])
parts = data_str.split("_")
ticket_id = int(parts[3])
page = max(1, int(parts[4]))
except (ValueError, IndexError):
pass
elif ticket_id is None:
try:
ticket_id = int(data_str.split("_")[-1])
except (ValueError, AttributeError):
texts = get_texts(db_user.language)
await callback.answer(
@@ -152,9 +223,9 @@ async def view_admin_ticket(
if state is None:
state = FSMContext(callback.bot, callback.from_user.id)
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
if not ticket:
texts = get_texts(db_user.language)
await callback.answer(
@@ -162,59 +233,60 @@ async def view_admin_ticket(
show_alert=True
)
return
texts = get_texts(db_user.language)
# Формируем текст тикета
# Формируем заголовок тикета
status_text = {
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
}.get(ticket.status, ticket.status)
user_name = ticket.user.full_name if ticket.user else "Unknown"
telegram_id_display = ticket.user.telegram_id if ticket.user else ""
username_value = ticket.user.username if ticket.user else None
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
header = f"🎫 Тикет #{ticket.id}\n\n"
header += f"👤 Пользователь: {user_name}\n"
header += f"🆔 Telegram ID: <code>{telegram_id_display}</code>\n"
if username_value:
safe_username = html.escape(username_value)
ticket_text += f"📱 Username: @{safe_username}\n"
ticket_text += (
f"🔗 ЛС: <a href=\"tg://resolve?domain={safe_username}\">"
f"tg://resolve?domain={safe_username}</a>\n"
)
header += f"📱 Username: @{safe_username}\n"
else:
ticket_text += "📱 Username: отсутствует\n"
if ticket.user and ticket.user.telegram_id:
chat_link = f"tg://user?id={int(ticket.user.telegram_id)}"
ticket_text += f"🔗 Чат по ID: <a href=\"{chat_link}\">{chat_link}</a>\n"
ticket_text += "\n"
ticket_text += f"📝 Заголовок: {ticket.title}\n"
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
header += "📱 Username: отсутствует\n"
header += f"📝 Заголовок: {ticket.title}\n"
header += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
header += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
if ticket.is_user_reply_blocked:
if ticket.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
header += "🚫 Пользователь заблокирован навсегда\n\n"
elif ticket.user_reply_block_until:
ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
header += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n\n"
# Формируем блоки сообщений
message_blocks: list[str] = []
if ticket.messages:
ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
message_blocks.append(f"💬 Сообщения ({len(ticket.messages)}):\n\n")
for msg in ticket.messages:
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
ticket_text += f"{msg.message_text}\n\n"
block = (
f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
f"{msg.message_text}\n\n"
)
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
ticket_text += "📎 Вложение: фото\n\n"
# Добавим кнопку "Вложения", если есть фото
block += "📎 Вложение: фото\n\n"
message_blocks.append(block)
# Разбиваем на страницы
pages = _split_text_into_pages(header, message_blocks, max_len=MAX_MESSAGE_LEN)
total_pages = len(pages)
if page > total_pages:
page = total_pages
# Формируем клавиатуру
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
keyboard = get_admin_ticket_view_keyboard(
ticket_id,
@@ -222,7 +294,8 @@ async def view_admin_ticket(
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
# Кнопка открытия профиля пользователя в админке
# Кнопка профиля пользователя
try:
if ticket.user:
admin_profile_btn = types.InlineKeyboardButton(
@@ -232,34 +305,76 @@ async def view_admin_ticket(
keyboard.inline_keyboard.insert(0, [admin_profile_btn])
except Exception:
pass
# Кнопки ЛС и профиль
try:
if ticket.user and ticket.user.telegram_id and ticket.user.username:
safe_username = html.escape(ticket.user.username)
buttons_row = []
pm_url = f"tg://resolve?domain={safe_username}"
buttons_row.append(types.InlineKeyboardButton(text=" Написать в ЛС", url=pm_url))
buttons_row.append(types.InlineKeyboardButton(text="✉ ЛС", url=pm_url))
profile_url = f"tg://user?id={ticket.user.telegram_id}"
buttons_row.append(types.InlineKeyboardButton(text="👤 Профиль", url=profile_url))
if buttons_row:
keyboard.inline_keyboard.insert(0, buttons_row)
except Exception:
pass
# Кнопка вложений
if has_photos:
try:
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
keyboard.inline_keyboard.insert(0, [
types.InlineKeyboardButton(
text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"),
callback_data=f"admin_ticket_attachments_{ticket_id}"
)
])
except Exception:
pass
# Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=ticket_text,
keyboard=keyboard,
parse_mode="HTML",
)
# сохраняем id для дальнейших действий (ответ/статусы)
# Пагинация
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(types.InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_ticket_page_{ticket_id}_{page - 1}"
))
nav_row.append(types.InlineKeyboardButton(
text=f"{page}/{total_pages}",
callback_data="noop"
))
if page < total_pages:
nav_row.append(types.InlineKeyboardButton(
text="➡️",
callback_data=f"admin_ticket_page_{ticket_id}_{page + 1}"
))
try:
keyboard.inline_keyboard.insert(0, nav_row)
except Exception:
pass
page_text = pages[page - 1]
# Отправка сообщения
try:
await callback.message.edit_text(
page_text,
reply_markup=keyboard,
parse_mode="HTML"
)
except TelegramBadRequest:
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
page_text,
reply_markup=keyboard,
parse_mode="HTML"
)
# Сохраняем id для дальнейших действий
if state is not None:
try:
await state.update_data(ticket_id=ticket_id)
@@ -1034,7 +1149,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(close_all_open_admin_tickets, F.data == "admin_tickets_close_all_open")
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_ticket_page_"))
# Ответы на тикеты
dp.callback_query.register(
reply_to_admin_ticket,
+354 -2
View File
@@ -24,7 +24,8 @@ from app.database.crud.campaign import (
from app.keyboards.admin import (
get_admin_users_keyboard, get_user_management_keyboard,
get_admin_pagination_keyboard, get_confirmation_keyboard,
get_admin_users_filters_keyboard, get_user_promo_group_keyboard
get_admin_users_filters_keyboard, get_user_promo_group_keyboard,
get_user_restrictions_keyboard
)
from app.localization.texts import get_texts
from app.services.user_service import UserService
@@ -1241,6 +1242,15 @@ async def _render_user_subscription_overview(
]
]
if settings.is_modem_enabled():
modem_status = "" if getattr(subscription, 'modem_enabled', False) else ""
keyboard.append([
types.InlineKeyboardButton(
text=f"📡 Модем ({modem_status})",
callback_data=f"admin_user_modem_{user_id}"
)
])
if subscription.is_active:
keyboard.append([
types.InlineKeyboardButton(
@@ -1633,6 +1643,20 @@ async def show_user_management(
else:
sections.append(texts.ADMIN_USER_MANAGEMENT_PROMO_GROUP_NONE)
# Показать ограничения пользователя если есть
restriction_topup = getattr(user, 'restriction_topup', False)
restriction_subscription = getattr(user, 'restriction_subscription', False)
if restriction_topup or restriction_subscription:
restriction_lines = ["⚠️ <b>Ограничения:</b>"]
if restriction_topup:
restriction_lines.append(" • 🚫 Пополнение запрещено")
if restriction_subscription:
restriction_lines.append(" • 🚫 Продление/покупка запрещена")
restriction_reason = getattr(user, 'restriction_reason', None)
if restriction_reason:
restriction_lines.append(f" 📝 Причина: {restriction_reason}")
sections.append("\n".join(restriction_lines))
text = "\n\n".join(sections)
# Проверяем состояние, чтобы определить, откуда пришел пользователь
@@ -2746,6 +2770,236 @@ async def block_user(
await callback.answer()
# ============ УПРАВЛЕНИЕ ОГРАНИЧЕНИЯМИ ПОЛЬЗОВАТЕЛЯ ============
@admin_required
@error_handler
async def show_user_restrictions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показать меню управления ограничениями пользователя."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
texts = get_texts(db_user.language)
# Формируем текст с информацией об ограничениях
restriction_topup = getattr(user, 'restriction_topup', False)
restriction_subscription = getattr(user, 'restriction_subscription', False)
restriction_reason = getattr(user, 'restriction_reason', None)
text_lines = [
f"⚠️ <b>Ограничения пользователя</b>",
f"👤 {user.full_name}",
"",
f"✅ — разрешено, 🚫 — запрещено",
"",
f"{'🚫' if restriction_topup else ''} Пополнение баланса",
f"{'🚫' if restriction_subscription else ''} Продление/покупка подписки",
]
if restriction_reason:
text_lines.append("")
text_lines.append(f"📝 <b>Причина:</b> {restriction_reason}")
keyboard = get_user_restrictions_keyboard(
user_id=user_id,
restriction_topup=restriction_topup,
restriction_subscription=restriction_subscription,
language=db_user.language
)
await callback.message.edit_text(
"\n".join(text_lines),
reply_markup=keyboard
)
await callback.answer()
@admin_required
@error_handler
async def toggle_user_restriction_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключить ограничение на пополнение баланса."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
# Переключаем ограничение
current_value = getattr(user, 'restriction_topup', False)
user.restriction_topup = not current_value
await db.commit()
action = "установлено" if user.restriction_topup else "снято"
await callback.answer(f"Ограничение на пополнение {action}", show_alert=False)
# Обновляем меню
await show_user_restrictions(callback, db_user, db)
@admin_required
@error_handler
async def toggle_user_restriction_subscription(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключить ограничение на продление/покупку подписки."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
# Переключаем ограничение
current_value = getattr(user, 'restriction_subscription', False)
user.restriction_subscription = not current_value
await db.commit()
action = "установлено" if user.restriction_subscription else "снято"
await callback.answer(f"Ограничение на подписку {action}", show_alert=False)
# Обновляем меню
await show_user_restrictions(callback, db_user, db)
@admin_required
@error_handler
async def ask_restriction_reason(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Запросить ввод причины ограничения."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
current_reason = getattr(user, 'restriction_reason', None) or ""
await state.set_state(AdminStates.editing_user_restriction_reason)
await state.update_data(restriction_user_id=user_id)
text = (
"📝 <b>Введите причину ограничения</b>\n\n"
"Эта причина будет показана пользователю при попытке "
"выполнить запрещённое действие.\n\n"
)
if current_reason:
text += f"Текущая причина: <i>{current_reason}</i>\n\n"
text += "Отправьте новую причину или /cancel для отмены:"
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="❌ Отмена", callback_data=f"admin_user_restrictions_{user_id}")]
])
)
await callback.answer()
@admin_required
@error_handler
async def save_restriction_reason(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext
):
"""Сохранить причину ограничения."""
data = await state.get_data()
user_id = data.get("restriction_user_id")
if not user_id:
await message.answer("Ошибка: пользователь не найден")
await state.clear()
return
user = await get_user_by_id(db, user_id)
if not user:
await message.answer("Ошибка: пользователь не найден")
await state.clear()
return
reason = message.text.strip()[:500] # Ограничение 500 символов
user.restriction_reason = reason
await db.commit()
await state.clear()
# Формируем текст с обновлённой информацией
restriction_topup = getattr(user, 'restriction_topup', False)
restriction_subscription = getattr(user, 'restriction_subscription', False)
text_lines = [
f"✅ <b>Причина ограничения сохранена</b>",
"",
f"⚠️ <b>Ограничения пользователя</b>",
f"👤 {user.full_name}",
"",
f"{'🚫' if restriction_topup else ''} Пополнение баланса",
f"{'🚫' if restriction_subscription else ''} Продление/покупка подписки",
"",
f"📝 <b>Причина:</b> {reason}",
]
keyboard = get_user_restrictions_keyboard(
user_id=user_id,
restriction_topup=restriction_topup,
restriction_subscription=restriction_subscription,
language=db_user.language
)
await message.answer(
"\n".join(text_lines),
reply_markup=keyboard
)
@admin_required
@error_handler
async def clear_user_restrictions(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Снять все ограничения с пользователя."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("Пользователь не найден", show_alert=True)
return
# Снимаем все ограничения
user.restriction_topup = False
user.restriction_subscription = False
user.restriction_reason = None
await db.commit()
await callback.answer("Все ограничения сняты", show_alert=True)
# Обновляем меню
await show_user_restrictions(callback, db_user, db)
@admin_required
@error_handler
async def show_inactive_users(
@@ -3803,6 +4057,68 @@ async def set_user_devices_button(
await callback.answer()
@admin_required
@error_handler
async def toggle_user_modem(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Переключение модема для пользователя в админке."""
user_id = int(callback.data.split('_')[-1])
user = await get_user_by_id(db, user_id)
if not user:
await callback.answer("❌ Пользователь не найден", show_alert=True)
return
subscription = user.subscription
if not subscription:
await callback.answer("❌ У пользователя нет подписки", show_alert=True)
return
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled:
# Отключаем модем
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
action_text = "отключен"
else:
# Включаем модем
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
action_text = "подключен"
subscription.updated_at = datetime.utcnow()
await db.commit()
# Обновляем в RemnaWave
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.error(f"Ошибка обновления RemnaWave при переключении модема: {e}")
await db.refresh(subscription)
modem_status = "✅ Подключен" if subscription.modem_enabled else "❌ Отключен"
await callback.message.edit_text(
f"📡 <b>Модем {action_text}</b>\n\n"
f"Статус модема: {modem_status}\n"
f"Лимит устройств: {subscription.device_limit}",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="📱 Подписка и настройки", callback_data=f"admin_user_subscription_{user_id}")]
]),
parse_mode="HTML"
)
logger.info(f"Админ {db_user.telegram_id} {action_text} модем для пользователя {user_id}")
await callback.answer()
@admin_required
@error_handler
async def process_devices_edit_text(
@@ -4978,7 +5294,38 @@ def register_handlers(dp: Dispatcher):
confirm_user_delete,
F.data.startswith("admin_user_delete_") & ~F.data.contains("confirm")
)
# Регистрация хендлеров ограничений пользователя
dp.callback_query.register(
show_user_restrictions,
F.data.startswith("admin_user_restrictions_")
)
dp.callback_query.register(
toggle_user_restriction_topup,
F.data.startswith("admin_user_restriction_toggle_topup_")
)
dp.callback_query.register(
toggle_user_restriction_subscription,
F.data.startswith("admin_user_restriction_toggle_sub_")
)
dp.callback_query.register(
ask_restriction_reason,
F.data.startswith("admin_user_restriction_reason_")
)
dp.callback_query.register(
clear_user_restrictions,
F.data.startswith("admin_user_restriction_clear_")
)
dp.message.register(
save_restriction_reason,
AdminStates.editing_user_restriction_reason
)
dp.callback_query.register(
handle_users_list_pagination_fixed,
F.data.startswith("admin_users_list_page_")
@@ -5198,6 +5545,11 @@ def register_handlers(dp: Dispatcher):
F.data.startswith("admin_user_devices_set_")
)
dp.callback_query.register(
toggle_user_modem,
F.data.startswith("admin_user_modem_")
)
dp.message.register(
process_devices_edit_text,
AdminStates.editing_user_devices
+505
View File
@@ -0,0 +1,505 @@
"""Handler for CloudPayments balance top-up."""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
async def _create_cloudpayments_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating CloudPayments payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
)
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
telegram_id=db_user.telegram_id,
language=db_user.language,
)
if not result:
error_text = texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
else:
await message_or_callback.answer(
error_text,
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
response_text = texts.t(
"CLOUDPAYMENTS_PAYMENT_CREATED",
"💳 <b>Оплата банковской картой</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(amount=f"{amount_rub:.2f}")
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"CloudPayments payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def process_cloudpayments_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
Similar to process_heleket_payment_amount and other payment handlers.
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
if not settings.is_cloudpayments_enabled():
await message.answer(
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
)
return
# Validate amount
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
await message.answer(
texts.t(
"AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {min_amount:.0f}",
).format(min_amount=min_rub),
)
return
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
await message.answer(
texts.t(
"AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {max_amount:,.0f}",
).format(max_amount=max_rub),
)
return
# Clear state
await state.clear()
await _create_cloudpayments_payment_and_respond(
message, db_user, db, amount_kopeks, edit_message=False
)
@error_handler
async def start_cloudpayments_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
):
"""
Start CloudPayments payment flow.
Shows amount input prompt or quick amount buttons.
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_cloudpayments_enabled():
await callback.answer(
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
show_alert=True,
)
return
min_amount_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
max_amount_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
message_text = texts.t(
"CLOUDPAYMENTS_ENTER_AMOUNT",
"💳 <b>Оплата банковской картой (CloudPayments)</b>\n\n"
"Введите сумму для пополнения от {min_amount:.0f} до {max_amount:,.0f} рублей:",
).format(min_amount=min_amount_rub, max_amount=max_amount_rub)
keyboard = get_back_keyboard(db_user.language)
await callback.message.edit_text(
message_text,
reply_markup=keyboard,
parse_mode="HTML",
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="cloudpayments")
await state.update_data(
cloudpayments_prompt_message_id=callback.message.message_id,
cloudpayments_prompt_chat_id=callback.message.chat.id,
)
await callback.answer()
@error_handler
async def process_cloudpayments_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process entered amount and create CloudPayments payment.
Generates payment link and sends it to user.
"""
texts = get_texts(db_user.language)
# Get state data
state_data = await state.get_data()
payment_method = state_data.get("payment_method")
if payment_method != "cloudpayments":
return # Not our payment method
# Parse amount
try:
amount_text = message.text.strip().replace(",", ".").replace(" ", "")
amount_rub = float(amount_text)
amount_kopeks = int(amount_rub * 100)
except (ValueError, TypeError):
await message.answer(
texts.t("INVALID_AMOUNT", "Введите корректную сумму числом"),
parse_mode="HTML",
)
return
# Validate amount
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
min_rub = settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS / 100
await message.answer(
texts.t(
"AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {min_amount:.0f}",
).format(min_amount=min_rub),
parse_mode="HTML",
)
return
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
max_rub = settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS / 100
await message.answer(
texts.t(
"AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {max_amount:,.0f}",
).format(max_amount=max_rub),
parse_mode="HTML",
)
return
# Clear state
await state.clear()
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
)
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
telegram_id=db_user.telegram_id,
language=db_user.language,
)
if not result:
await message.answer(
texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
),
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
await message.answer(
texts.t(
"CLOUDPAYMENTS_PAYMENT_CREATED",
"💳 <b>Оплата банковской картой</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(amount=f"{amount_rub:.2f}"),
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"CloudPayments payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def handle_cloudpayments_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Handle quick amount selection for CloudPayments.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_cloudpayments_enabled():
await callback.answer(
texts.t("CLOUDPAYMENTS_NOT_AVAILABLE", "CloudPayments временно недоступен"),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|cloudpayments|{amount_kopeks}
try:
parts = callback.data.split("|")
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer("Invalid callback data", show_alert=True)
return
except (ValueError, IndexError):
await callback.answer("Invalid amount", show_alert=True)
return
amount_rub = amount_kopeks / 100
# Validate amount
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
await callback.answer(
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
show_alert=True,
)
return
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
await callback.answer(
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
show_alert=True,
)
return
await callback.answer()
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
)
result = await payment_service.create_cloudpayments_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
telegram_id=db_user.telegram_id,
language=db_user.language,
)
if not result:
await callback.message.edit_text(
texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
),
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
await callback.message.edit_text(
texts.t(
"CLOUDPAYMENTS_PAYMENT_CREATED",
"💳 <b>Оплата банковской картой</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(amount=f"{amount_rub:.2f}"),
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"CloudPayments payment created (quick): user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
+38 -1
View File
@@ -22,7 +22,24 @@ async def start_cryptobot_payment(
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_cryptobot_enabled():
await callback.answer("❌ Оплата криптовалютой временно недоступна", show_alert=True)
return
@@ -97,6 +114,26 @@ async def process_cryptobot_payment_amount(
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
+35
View File
@@ -26,6 +26,23 @@ async def start_heleket_payment(
) -> None:
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_heleket_enabled():
await callback.answer("❌ Оплата через Heleket недоступна", show_alert=True)
return
@@ -86,6 +103,24 @@ async def process_heleket_payment_amount(
) -> None:
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
if not settings.is_heleket_enabled():
await message.answer("❌ Оплата через Heleket недоступна")
return
+48
View File
@@ -242,6 +242,24 @@ async def show_payment_methods(
from app.services.subscription_service import SubscriptionService
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
payment_text = get_payment_methods_text(db_user.language)
# Добавляем информацию о текущем тарифе пользователя
@@ -594,6 +612,11 @@ async def process_topup_amount(
from .heleket import process_heleket_payment_amount
async with AsyncSessionLocal() as db:
await process_heleket_payment_amount(message, db_user, db, amount_kopeks, state)
elif payment_method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_amount(message, db_user, db, state)
else:
await message.answer("Неизвестный способ оплаты")
@@ -723,6 +746,14 @@ async def handle_quick_amount_selection(
await process_heleket_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_payment_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif payment_method == "stars":
from .stars import process_stars_payment_amount
@@ -822,6 +853,13 @@ async def handle_topup_amount_callback(
await process_wata_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "cloudpayments":
from app.database.database import AsyncSessionLocal
from .cloudpayments import process_cloudpayments_payment_amount
async with AsyncSessionLocal() as db:
await process_cloudpayments_payment_amount(
callback.message, db_user, db, amount_kopeks, state
)
elif method == "stars":
from .stars import process_stars_payment_amount
await process_stars_payment_amount(
@@ -965,6 +1003,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("check_heleket_")
)
from .cloudpayments import start_cloudpayments_payment, handle_cloudpayments_quick_amount
dp.callback_query.register(
start_cloudpayments_payment,
F.data == "topup_cloudpayments"
)
dp.callback_query.register(
handle_cloudpayments_quick_amount,
F.data.startswith("topup_amount|cloudpayments|")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
+37
View File
@@ -21,6 +21,24 @@ async def start_mulenpay_payment(
state: FSMContext,
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
@@ -76,6 +94,25 @@ async def process_mulenpay_payment_amount(
state: FSMContext,
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
mulenpay_name = settings.get_mulenpay_display_name()
mulenpay_name_html = settings.get_mulenpay_display_name_html()
+35
View File
@@ -264,6 +264,23 @@ async def start_pal24_payment(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_pal24_enabled():
await callback.answer("❌ Оплата через PayPalych временно недоступна", show_alert=True)
return
@@ -321,6 +338,24 @@ async def process_pal24_payment_amount(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
if not settings.is_pal24_enabled():
await message.answer("❌ Оплата через PayPalych временно недоступна")
return
+35
View File
@@ -112,6 +112,23 @@ async def start_platega_payment(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_platega_enabled():
await callback.answer(
texts.t(
@@ -200,6 +217,24 @@ async def process_platega_payment_amount(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
if not settings.is_platega_enabled():
await message.answer(
texts.t(
+37
View File
@@ -27,6 +27,23 @@ async def start_stars_payment(
await callback.answer("❌ Пополнение через Stars временно недоступно", show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
# Формируем текст сообщения в зависимости от настройки
if settings.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not settings.DISABLE_TOPUP_BUTTONS:
message_text = (
@@ -69,6 +86,26 @@ async def process_stars_payment_amount(
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
+17
View File
@@ -17,6 +17,23 @@ async def start_tribute_payment(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.TRIBUTE_ENABLED:
await callback.answer("❌ Оплата картой временно недоступна", show_alert=True)
return
+35
View File
@@ -26,6 +26,23 @@ async def start_wata_payment(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_wata_enabled():
await callback.answer("❌ Оплата через WATA временно недоступна", show_alert=True)
return
@@ -76,6 +93,24 @@ async def process_wata_payment_amount(
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
if not settings.is_wata_enabled():
await message.answer("❌ Оплата через WATA временно недоступна")
return
+76 -2
View File
@@ -25,7 +25,24 @@ async def start_yookassa_payment(
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
@@ -79,7 +96,24 @@ async def start_yookassa_sbp_payment(
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
await callback.answer("❌ Оплата через СБП временно недоступна", show_alert=True)
return
@@ -134,6 +168,26 @@ async def process_yookassa_payment_amount(
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
@@ -280,6 +334,26 @@ async def process_yookassa_sbp_payment_amount(
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, 'restriction_topup', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")])
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
parse_mode="HTML"
)
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id,
+4 -2
View File
@@ -67,7 +67,7 @@ async def handle_cancel(
async def handle_unknown_message(
message: types.Message,
db_user: User
db_user: User | None = None,
):
texts = get_texts(db_user.language if db_user else "ru")
@@ -126,6 +126,8 @@ def register_handlers(dp: Dispatcher):
dp.message.register(
handle_unknown_message,
StateFilter(None),
F.successful_payment.is_(None)
F.successful_payment.is_(None),
F.text.is_not(None),
~F.text.startswith("/"),
)
+209 -282
View File
@@ -1,41 +1,73 @@
"""Contest handlers for daily games."""
import logging
import random
from datetime import datetime, timedelta
from datetime import datetime
from typing import Optional
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import (
get_active_rounds,
get_attempt,
create_attempt,
increment_winner_count,
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus
from app.localization.texts import get_texts
from app.services.contest_rotation_service import (
GAME_QUEST,
GAME_LOCKS,
GAME_CIPHER,
GAME_SERVER,
GAME_BLITZ,
GAME_EMOJI,
GAME_ANAGRAM,
)
from app.database.crud.contest import get_active_rounds, get_attempt
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import extend_subscription
from app.utils.decorators import auth_required, error_handler
from app.database.database import AsyncSessionLocal
from app.database.models import ContestRound, SubscriptionStatus
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.contests import (
ContestAttemptService,
GameType,
get_game_strategy,
)
from app.states import ContestStates
from app.utils.decorators import auth_required, error_handler
logger = logging.getLogger(__name__)
# Rate limiting storage
_rate_limits: dict = {}
# Service instance
_attempt_service = ContestAttemptService()
def _check_rate_limit(user_id: int, action: str, limit: int = 1, window_seconds: int = 5) -> bool:
"""Check if user exceeds rate limit for contest actions."""
key = f"{user_id}_{action}"
now = datetime.utcnow().timestamp()
if key not in _rate_limits:
_rate_limits[key] = []
# Clean old entries
_rate_limits[key] = [t for t in _rate_limits[key] if now - t < window_seconds]
if len(_rate_limits[key]) >= limit:
return False
_rate_limits[key].append(now)
return True
def _validate_callback_data(data: str) -> Optional[list]:
"""Validate and parse callback data safely."""
if not data or not isinstance(data, str):
return None
parts = data.split("_")
if len(parts) < 2 or parts[0] != "contest":
return None
for part in parts:
if not part or len(part) > 50:
return None
return parts
def _user_allowed(subscription) -> bool:
"""Check if user has active or trial subscription."""
if not subscription:
return False
return subscription.status in {
@@ -44,22 +76,13 @@ def _user_allowed(subscription) -> bool:
}
async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
return ""
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
await extend_subscription(db, subscription, prize_days)
texts = get_texts(language)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days)
async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
"""Reply that user is not eligible to play."""
texts = get_texts(language)
await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True)
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."),
show_alert=True,
)
# ---------- Handlers ----------
@@ -68,13 +91,17 @@ async def _reply_not_eligible(callback: types.CallbackQuery, language: str):
@auth_required
@error_handler
async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Show menu with available contest games."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
active_rounds = await get_active_rounds(db)
# Group by template, take one round per template
unique_templates = {}
for rnd in active_rounds:
if not rnd.template or not rnd.template.is_enabled:
@@ -86,19 +113,24 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
buttons = []
for tpl_slug, rnd in unique_templates.items():
title = rnd.template.name if rnd.template else tpl_slug
buttons.append(
[
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
]
)
buttons.append([
types.InlineKeyboardButton(
text=f"▶️ {title}",
callback_data=f"contest_play_{tpl_slug}_{rnd.id}",
)
])
if not buttons:
buttons.append(
[types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")]
)
buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
buttons.append([
types.InlineKeyboardButton(
text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"),
callback_data="noop",
)
])
buttons.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
await callback.message.edit_text(
texts.t("CONTEST_MENU_TITLE", "🎲 <b>Игры/Конкурсы</b>\nВыберите игру:"),
@@ -110,14 +142,25 @@ async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSe
@auth_required
@error_handler
async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession):
"""Start playing a specific contest."""
texts = get_texts(db_user.language)
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await _reply_not_eligible(callback, db_user.language)
return
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "play":
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_play", limit=2, window_seconds=10):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "play":
await callback.answer("Некорректные данные", show_alert=True)
return
@@ -128,285 +171,169 @@ async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user
await callback.answer("Некорректные данные", show_alert=True)
return
slug = "_".join(parts[2:-1])
# reload round with template
# Get round with template
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True)
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."),
show_alert=True,
)
return
if not round_obj.template or not round_obj.template.is_enabled:
await callback.answer(texts.t("CONTEST_DISABLED", "Игра отключена."), show_alert=True)
await callback.answer(
texts.t("CONTEST_DISABLED", "Игра отключена."),
show_alert=True,
)
return
# Check if user already played
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True)
await callback.answer(
texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."),
show_alert=True,
)
return
# Get game strategy and render
tpl = round_obj.template
if tpl.slug == GAME_QUEST:
await _render_quest(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_LOCKS:
await _render_locks(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_SERVER:
await _render_server_lottery(callback, db_user, round_obj, tpl)
elif tpl.slug == GAME_CIPHER:
await _render_cipher(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_EMOJI:
await _render_emoji(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_ANAGRAM:
await _render_anagram(callback, db_user, round_obj, tpl, state)
elif tpl.slug == GAME_BLITZ:
await _render_blitz(callback, db_user, round_obj, tpl)
else:
await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True)
strategy = get_game_strategy(tpl.slug)
async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
rows = round_obj.payload.get("rows", 3)
cols = round_obj.payload.get("cols", 3)
keyboard = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_obj.id}_quest_{idx}"
)
if not strategy:
await callback.answer(
texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."),
show_alert=True,
)
keyboard.append(row_buttons)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
return
render_result = strategy.render(
round_id=round_obj.id,
payload=round_obj.payload or {},
language=db_user.language,
)
async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
total = round_obj.payload.get("total", 20)
keyboard = []
row = []
for i in range(total):
row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_locks_{i}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
# For text input games, create pending attempt and set FSM state
if render_result.requires_text_input:
await _attempt_service.create_pending_attempt(db2, round_obj.id, db_user.id)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
flags = round_obj.payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard = []
row = []
for flag in shuffled_flags:
row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{flag}"))
if len(row) == 5:
keyboard.append(row)
row = []
if row:
keyboard.append(row)
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")])
await callback.message.edit_text(
texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
question = round_obj.payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=shuffled_question),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext):
texts = get_texts(db_user.language)
letters = round_obj.payload.get("letters", "")
await state.set_state(ContestStates.waiting_for_answer)
await state.update_data(contest_round_id=round_obj.id)
await callback.message.edit_text(
texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate):
texts = get_texts(db_user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")]
]
)
await callback.message.edit_text(
texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
reply_markup=keyboard,
)
await callback.answer()
await callback.message.edit_text(
render_result.text,
reply_markup=render_result.keyboard,
)
await callback.answer()
@auth_required
@error_handler
async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession):
"""Handle button pick in contest games."""
texts = get_texts(db_user.language)
parts = callback.data.split("_")
if len(parts) < 4 or parts[0] != "contest" or parts[1] != "pick":
# Rate limit check
if not _check_rate_limit(db_user.id, "contest_pick", limit=1, window_seconds=3):
await callback.answer(
texts.t("CONTEST_TOO_FAST", "Слишком быстро! Подождите."),
show_alert=True,
)
return
# Validate callback data
parts = _validate_callback_data(callback.data)
if not parts or len(parts) < 4 or parts[1] != "pick":
await callback.answer("Некорректные данные", show_alert=True)
return
round_id_str = parts[2]
pick = "_".join(parts[3:])
try:
round_id = int(round_id_str)
except ValueError:
await callback.answer("Некорректные данные", show_alert=True)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True)
return
tpl = round_obj.template
attempt = await get_attempt(db2, round_id, db_user.id)
if attempt:
await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True)
return
secret_idx = round_obj.payload.get("secret_idx")
correct_flag = ""
if tpl.slug == GAME_SERVER:
flags = round_obj.payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_winner = False
if tpl.slug == GAME_SERVER:
is_winner = pick == correct_flag
elif tpl.slug == GAME_QUEST:
# Format: quest_{idx}
try:
if pick.startswith("quest_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_LOCKS:
# Format: locks_{idx}
try:
if pick.startswith("locks_"):
idx = int(pick.split("_")[1])
is_winner = secret_idx is not None and idx == secret_idx
except (ValueError, IndexError):
is_winner = False
elif tpl.slug == GAME_BLITZ:
is_winner = pick == "blitz"
else:
is_winner = False
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True)
else:
responses = {
GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"],
GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"],
GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"],
}.get(tpl.slug, ["Неудача"])
await callback.answer(random.choice(responses), show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
# Re-check subscription
subscription = await get_subscription_by_user_id(db, db_user.id)
if not _user_allowed(subscription):
await callback.answer(
texts.t("CONTEST_NOT_ELIGIBLE", "Игра недоступна без активной подписки."),
show_alert=True,
)
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language))
await callback.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
show_alert=True,
)
return
# Process attempt using service
result = await _attempt_service.process_button_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
pick=pick,
language=db_user.language,
)
await callback.answer(result.message, show_alert=True)
@auth_required
@error_handler
async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession):
"""Handle text answer in contest games."""
texts = get_texts(db_user.language)
data = await state.get_data()
round_id = data.get("contest_round_id")
if not round_id:
await state.clear()
return
async with AsyncSessionLocal() as db2:
active_rounds = await get_active_rounds(db2)
round_obj = next((r for r in active_rounds if r.id == round_id), None)
if not round_obj:
await message.answer(
texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
attempt = await get_attempt(db2, round_obj.id, db_user.id)
if attempt:
await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
return
# Process attempt using service
text_answer = (message.text or "").strip()
result = await _attempt_service.process_text_attempt(
db=db2,
round_obj=round_obj,
user_id=db_user.id,
text_answer=text_answer,
language=db_user.language,
)
answer = (message.text or "").strip().upper()
tpl = round_obj.template
correct = (round_obj.payload.get("answer") or "").upper()
await message.answer(
result.message,
reply_markup=get_back_keyboard(db_user.language),
)
is_winner = correct and answer == correct
# Check if max winners already reached
if is_winner and round_obj.winners_count >= round_obj.max_winners:
is_winner = False # Too late, max winners already reached
await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner)
if is_winner:
await increment_winner_count(db2, round_obj)
prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language)
await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language))
else:
await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language))
await state.clear()
def register_handlers(dp: Dispatcher):
"""Register contest handlers."""
dp.callback_query.register(show_contests_menu, F.data == "contests_menu")
dp.callback_query.register(play_contest, F.data.startswith("contest_play_"))
dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_"))
+134 -42
View File
@@ -40,7 +40,8 @@ from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.public_offer_service import PublicOfferService
from app.services.faq_service import FaqService
from app.utils.timezone import format_local_datetime
from app.handlers.subscription.traffic import handle_add_traffic
from app.utils.pricing_utils import format_period_description
from app.handlers.subscription.traffic import handle_add_traffic, add_traffic
logger = logging.getLogger(__name__)
@@ -1237,39 +1238,68 @@ async def handle_activate_button(
db_user: User,
db: AsyncSession
):
"""
Умная кнопка активации система сама решает что делать:
- Если подписка активна ничего не делать
- Если подписка истекла продлить с теми же параметрами
- Если подписки нет создать новую с дефолтными параметрами
Выбирает максимальный период, который можно оплатить из баланса.
"""
texts = get_texts(db_user.language)
# Получить подписку пользователя
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import get_subscription_by_user_id, create_paid_subscription
from app.database.crud.server_squad import get_server_ids_by_uuids, get_available_server_squads
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import TransactionType, PaymentMethod
from app.services.subscription_service import SubscriptionService
from app.services.subscription_renewal_service import SubscriptionRenewalService
subscription = await get_subscription_by_user_id(db, db_user.id)
# Если подписка активна — ничего не делаем
if subscription and subscription.status == "ACTIVE" and subscription.end_date > datetime.utcnow():
await callback.answer(
texts.t("SUBSCRIPTION_ALREADY_ACTIVE", "✅ Подписка уже активна!"),
show_alert=True,
)
return
# Параметры из подписки или дефолтные
device_limit = subscription.device_limit if subscription else settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb if subscription else 0
connected_squads = subscription.connected_squads if subscription else []
# Получить IDs серверов из UUIDs
from app.database.crud.server_squad import get_server_ids_by_uuids
# Определяем параметры подписки
if subscription:
# Есть подписка (возможно истекшая) — берём её параметры
device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = subscription.traffic_limit_gb or 0
connected_squads = subscription.connected_squads or []
else:
# Нет подписки — дефолтные параметры
device_limit = settings.DEFAULT_DEVICE_LIMIT
traffic_limit_gb = 0
connected_squads = []
# Если серверы не выбраны — берём бесплатные по умолчанию
if not connected_squads:
available_servers = await get_available_server_squads(db, promo_group_id=db_user.promo_group_id)
connected_squads = [
s.squad_uuid for s in available_servers
if s.is_available and s.price_kopeks == 0
]
# Если бесплатных нет — берём первый доступный
if not connected_squads and available_servers:
connected_squads = [available_servers[0].squad_uuid]
server_ids = await get_server_ids_by_uuids(db, connected_squads) if connected_squads else []
balance = db_user.balance_kopeks
available_periods = [int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS]
available_periods = sorted([int(p) for p in settings.AVAILABLE_SUBSCRIPTION_PERIODS], reverse=True)
subscription_service = SubscriptionService()
# Найти максимальный период <= баланса
best_period = None
best_price = 0
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
# Найти максимальный период, цена которого <= баланса
for period in sorted(available_periods, reverse=True):
for period in available_periods:
price, _ = await subscription_service.calculate_subscription_price_with_months(
period,
traffic_limit_gb,
@@ -1282,31 +1312,88 @@ async def handle_activate_button(
best_period = period
best_price = price
break
if best_period:
# Создать новую подписку
from app.database.crud.subscription import create_paid_subscription
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
if not best_period:
# Показать сколько не хватает для минимального периода
min_period = min(available_periods) if available_periods else 30
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
)
# Списать деньги
db_user.balance_kopeks -= best_price
await db.commit()
missing = min_price - balance
await callback.answer(
texts.t("ACTIVATION_SUCCESS", f"✅ Подписка активирована на {best_period} дней за {best_price//100} руб!"),
texts.t(
"INSUFFICIENT_FUNDS_DETAILED",
f"❌ Недостаточно средств. Не хватает {missing // 100}"
),
show_alert=True,
)
else:
return
try:
if subscription:
# Продление существующей подписки
renewal_service = SubscriptionRenewalService()
pricing = await renewal_service.calculate_pricing(
db, db_user, subscription, best_period
)
result = await renewal_service.finalize(
db, db_user, subscription,
pricing,
description=f"Автоматическое продление на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
await callback.answer(
texts.t(
"ACTIVATION_SUCCESS",
f"✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!"
),
show_alert=True,
)
else:
# Создание новой подписки
new_subscription = await create_paid_subscription(
db,
db_user.id,
best_period,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=connected_squads,
update_server_counters=True
)
# Списать баланс правильно
await subtract_user_balance(
db, db_user, best_price,
f"Активация подписки на {best_period} дней"
)
# Создать пользователя в RemnaWave
await subscription_service.create_remnawave_user(db, new_subscription)
# Создать транзакцию
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=best_price,
description=f"Активация подписки на {best_period} дней",
payment_method=PaymentMethod.BALANCE,
)
await callback.answer(
texts.t(
"ACTIVATION_SUCCESS",
f"✅ Подписка активирована на {best_period} дней за {best_price // 100} ₽!"
),
show_alert=True,
)
except Exception as e:
logger.error(f"Ошибка автоматической активации для {db_user.telegram_id}: {e}")
await callback.answer(
texts.t("INSUFFICIENT_FUNDS", "Недостаточно средств для активации подписки"),
texts.t("ACTIVATION_ERROR", "Ошибка активации. Попробуйте позже."),
show_alert=True,
)
@@ -1384,6 +1471,11 @@ def register_handlers(dp: Dispatcher):
F.data == "buy_traffic"
)
dp.callback_query.register(
add_traffic,
F.data.startswith("add_traffic_")
)
dp.callback_query.register(
handle_activate_button,
F.data == "activate_button"
+5 -3
View File
@@ -481,9 +481,11 @@ def register_handlers(dp: Dispatcher):
F.data == "referral_analytics"
)
async def handle_referral_list_page(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
page = int(callback.data.split('_')[-1])
await show_detailed_referral_list(callback, db_user, db, page)
dp.callback_query.register(
lambda callback, db_user, db: show_detailed_referral_list(
callback, db_user, db, int(callback.data.split('_')[-1])
),
handle_referral_list_page,
F.data.startswith("referral_list_page_")
)
+17
View File
@@ -39,6 +39,23 @@ async def start_simple_subscription_purchase(
await callback.answer("❌ Простая покупка подписки временно недоступна", show_alert=True)
return
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or "Действие ограничено администратором"
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append([types.InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)])
keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="subscription")])
await callback.message.edit_text(
f"🚫 <b>Покупка подписки ограничена</b>\n\n{reason}\n\n"
"Если вы считаете это ошибкой, вы можете обжаловать решение.",
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard)
)
await callback.answer()
return
# Проверяем, есть ли у пользователя подписка
from app.database.crud.subscription import get_subscription_by_user_id
current_subscription = await get_subscription_by_user_id(db, db_user.id)
+37 -1
View File
@@ -1,5 +1,6 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import Dispatcher, types, F, Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramForbiddenError
@@ -18,7 +19,7 @@ from app.database.crud.campaign import (
get_campaign_by_start_parameter,
get_campaign_by_id,
)
from app.database.models import UserStatus, SubscriptionStatus
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import (
get_rules_keyboard,
get_privacy_policy_keyboard,
@@ -36,6 +37,10 @@ from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
get_active_pinned_message,
)
from app.utils.user_utils import generate_unique_referral_code
from app.utils.promo_offer import (
build_promo_offer_hint,
@@ -61,6 +66,22 @@ def _calculate_subscription_flags(subscription):
return has_active_subscription, subscription_is_active
async def _send_pinned_message(
bot: Bot,
db: AsyncSession,
user,
pinned_message: Optional[PinnedMessage] = None,
) -> None:
try:
await deliver_pinned_message_to_user(bot, db, user, pinned_message)
except Exception as error: # noqa: BLE001
logger.error(
"Не удалось отправить закрепленное сообщение пользователю %s: %s",
getattr(user, "telegram_id", "unknown"),
error,
)
async def _apply_campaign_bonus_if_needed(
db: AsyncSession,
user,
@@ -404,6 +425,11 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
user.subscription
)
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
@@ -438,6 +464,9 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
reply_markup=keyboard,
parse_mode="HTML"
)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await state.clear()
return
@@ -1094,6 +1123,7 @@ async def complete_registration_from_callback(
reply_markup=keyboard,
parse_mode="HTML"
)
await _send_pinned_message(callback.bot, db, existing_user)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await callback.message.answer(
@@ -1232,6 +1262,7 @@ async def complete_registration_from_callback(
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
await _send_pinned_message(callback.bot, db, user)
except Exception as e:
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
@@ -1277,6 +1308,7 @@ async def complete_registration_from_callback(
reply_markup=keyboard,
parse_mode="HTML"
)
await _send_pinned_message(callback.bot, db, user)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
@@ -1374,6 +1406,7 @@ async def complete_registration(
reply_markup=keyboard,
parse_mode="HTML"
)
await _send_pinned_message(message.bot, db, existing_user)
except Exception as e:
logger.error(f"Ошибка при показе главного меню существующему пользователю: {e}")
await message.answer(
@@ -1535,6 +1568,7 @@ async def complete_registration(
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info(f"✅ Приветственное сообщение отправлено пользователю {user.telegram_id}")
await _send_pinned_message(message.bot, db, user)
except Exception as e:
logger.error(f"Ошибка при отправке приветственного сообщения: {e}")
else:
@@ -1581,6 +1615,7 @@ async def complete_registration(
parse_mode="HTML"
)
logger.info(f"✅ Главное меню показано пользователю {user.telegram_id}")
await _send_pinned_message(message.bot, db, user)
except Exception as e:
logger.error(f"Ошибка при показе главного меню: {e}")
await message.answer(
@@ -1925,6 +1960,7 @@ async def required_sub_channel_check(
reply_markup=keyboard,
parse_mode="HTML",
)
await _send_pinned_message(bot, db, user)
else:
from app.keyboards.inline import get_rules_keyboard
+13
View File
@@ -120,6 +120,14 @@ from .traffic import (
select_traffic,
)
from .modem import (
handle_modem_menu,
handle_modem_enable,
handle_modem_confirm,
handle_modem_disable,
register_modem_handlers,
)
__all__ = [
'activate_trial',
'add_traffic',
@@ -185,6 +193,11 @@ __all__ = [
'handle_subscription_config_back',
'handle_subscription_settings',
'handle_switch_traffic',
'handle_modem_menu',
'handle_modem_enable',
'handle_modem_confirm',
'handle_modem_disable',
'register_modem_handlers',
'load_app_config',
'refresh_traffic_config',
'register_handlers',
+17 -3
View File
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
get_user_active_promo_discount_percent,
)
from .countries import _get_available_countries, _should_show_countries_management
from .countries import (
_build_countries_selection_text,
_get_available_countries,
_get_preselected_free_countries,
_should_show_countries_management,
)
from .pricing import _build_subscription_period_prompt
async def handle_autopay_menu(
@@ -260,9 +265,18 @@ async def _show_previous_configuration_step(
data = await state.get_data()
selected_countries = data.get('countries', [])
# Если страны не выбраны — автоматически предвыбираем бесплатные
if not selected_countries:
selected_countries = _get_preselected_free_countries(countries)
data['countries'] = selected_countries
await state.set_data(data)
# Формируем текст с описаниями сквадов
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language)
selection_text,
reply_markup=get_countries_keyboard(countries, selected_countries, db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
return
+10 -2
View File
@@ -390,9 +390,15 @@ def get_traffic_switch_keyboard(
language: str = "ru",
subscription_end_date: datetime = None,
discount_percent: int = 0,
base_traffic_gb: int = None,
) -> InlineKeyboardMarkup:
from app.config import settings
# Если базовый трафик не передан, используем текущий
# (для обратной совместимости и случаев без докупленного трафика)
if base_traffic_gb is None:
base_traffic_gb = current_traffic_gb
months_multiplier = 1
period_text = ""
if subscription_end_date:
@@ -403,7 +409,8 @@ def get_traffic_switch_keyboard(
packages = settings.get_traffic_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
current_price_per_month = settings.get_traffic_price(current_traffic_gb)
# Используем базовый трафик для определения цены текущего пакета
current_price_per_month = settings.get_traffic_price(base_traffic_gb)
discounted_current_per_month, _ = apply_percentage_discount(
current_price_per_month,
discount_percent,
@@ -422,7 +429,8 @@ def get_traffic_switch_keyboard(
price_diff_per_month = discounted_price_per_month - discounted_current_per_month
total_price_diff = price_diff_per_month * months_multiplier
if gb == current_traffic_gb:
# Сравниваем с базовым трафиком (без докупленного)
if gb == base_traffic_gb:
emoji = ""
action_text = " (текущий)"
price_text = ""
+35 -3
View File
@@ -646,7 +646,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"name": server.display_name,
"price_kopeks": server.price_kopeks,
"country_code": server.country_code,
"is_available": server.is_available and not server.is_full
"is_available": server.is_available and not server.is_full,
"description": server.description or ""
})
if not countries:
@@ -675,7 +676,8 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
"uuid": squad["uuid"],
"name": squad_name,
"price_kopeks": 0,
"is_available": True
"is_available": True,
"description": ""
})
await cache.set(cache_key_value, countries, 300)
@@ -684,7 +686,7 @@ async def _get_available_countries(promo_group_id: Optional[int] = None):
except Exception as e:
logger.error(f"Ошибка получения списка стран: {e}")
fallback_countries = [
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True},
{"uuid": "default-free", "name": "🆓 Бесплатный сервер", "price_kopeks": 0, "is_available": True, "description": ""},
]
await cache.set(cache_key_value, fallback_countries, 60)
@@ -694,6 +696,36 @@ async def _get_countries_info(squad_uuids):
countries = await _get_available_countries()
return [c for c in countries if c['uuid'] in squad_uuids]
def _get_preselected_free_countries(countries: List[dict]) -> List[str]:
"""Получить UUID бесплатных серверов для автоматического предвыбора."""
return [
c['uuid'] for c in countries
if c.get('is_available', True) and c.get('price_kopeks', 0) == 0
]
def _build_countries_selection_text(countries: List[dict], base_text: str) -> str:
"""
Формирует текст выбора серверов с описаниями.
Если у серверов есть description добавляет их под базовым текстом.
"""
descriptions = []
for country in countries:
if not country.get('is_available', True):
continue
desc = country.get('description', '').strip()
if desc:
name = country.get('name', '')
descriptions.append(f"<b>{name}</b>\n{desc}")
if not descriptions:
return base_text
return f"{base_text}\n\n" + "\n\n".join(descriptions)
async def handle_add_country_to_subscription(
callback: types.CallbackQuery,
db_user: User,
+53
View File
@@ -355,6 +355,28 @@ async def confirm_change_devices(
).format(count=new_devices_count)
cost_text = texts.t("DEVICE_CHANGE_NO_REFUND", "Возврат средств не производится")
# Проверяем количество подключённых устройств для предупреждения
devices_warning = ""
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if response and 'response' in response:
connected_count = response['response'].get('total', 0)
if connected_count > new_devices_count:
devices_warning = texts.t(
"DEVICE_CHANGE_RESET_WARNING",
(
"\n⚠️ <b>Внимание!</b>\n"
"У вас подключено {connected} устройств.\n"
"При уменьшении лимита до {new} все устройства будут сброшены.\n"
"Вам нужно будет заново подключить нужные устройства.\n"
),
).format(connected=connected_count, new=new_devices_count)
except Exception as e:
logger.error(f"Ошибка проверки устройств: {e}")
confirm_text = texts.t(
"DEVICE_CHANGE_CONFIRMATION",
(
@@ -372,6 +394,9 @@ async def confirm_change_devices(
cost=cost_text,
)
if devices_warning:
confirm_text += devices_warning
await callback.message.edit_text(
confirm_text,
reply_markup=get_confirm_change_devices_keyboard(new_devices_count, price, db_user.language),
@@ -431,6 +456,28 @@ async def execute_change_devices(
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# При уменьшении лимита - сбросить лишние устройства
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
service = RemnaWaveService()
async with service.get_api_client() as api:
response = await api._make_request('GET', f'/api/hwid/devices/{db_user.remnawave_uuid}')
if response and 'response' in response:
devices_list = response['response'].get('devices', [])
connected_count = len(devices_list)
# Если подключённых устройств больше чем новый лимит - сбросить все
if connected_count > new_devices_count:
logger.info(
f"🔧 Сброс устройств при уменьшении лимита: "
f"подключено {connected_count}, новый лимит {new_devices_count}"
)
await api.reset_user_devices(db_user.remnawave_uuid)
devices_reset_count = connected_count
except Exception as reset_error:
logger.error(f"Ошибка сброса устройств при уменьшении лимита: {reset_error}")
await db.refresh(db_user)
await db.refresh(subscription)
@@ -466,6 +513,12 @@ async def execute_change_devices(
"DEVICE_CHANGE_RESULT_LINE",
"📱 Было: {old} → Стало: {new}\n",
).format(old=current_devices, new=new_devices_count)
if devices_reset_count > 0:
success_text += texts.t(
"DEVICE_CHANGE_DEVICES_RESET",
"\n🔄 Сброшено устройств: {count}\n"
"💡 Подключите заново нужные устройства (до {limit} шт.)\n\n",
).format(count=devices_reset_count, limit=new_devices_count)
success_text += texts.t(
"DEVICE_CHANGE_NO_REFUND_INFO",
"ℹ️ Возврат средств не производится",
+388
View File
@@ -0,0 +1,388 @@
"""
Хендлеры для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from aiogram import Dispatcher, types, F
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard, get_insufficient_balance_keyboard
from app.localization.texts import get_texts
from app.services.modem_service import (
get_modem_service,
ModemError,
MODEM_WARNING_DAYS_CRITICAL,
MODEM_WARNING_DAYS_INFO,
)
from app.utils.decorators import error_handler, modem_available
logger = logging.getLogger(__name__)
def get_modem_keyboard(language: str, modem_enabled: bool):
"""Клавиатура управления модемом."""
texts = get_texts(language)
keyboard = []
if modem_enabled:
keyboard.append([
types.InlineKeyboardButton(
text=texts.t("MODEM_DISABLE_BUTTON", "Отключить модем"),
callback_data="modem_disable"
)
])
else:
keyboard.append([
types.InlineKeyboardButton(
text=texts.t("MODEM_ENABLE_BUTTON", "Подключить модем"),
callback_data="modem_enable"
)
])
keyboard.append([
types.InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_settings"
)
])
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_modem_confirm_keyboard(language: str):
"""Клавиатура подтверждения подключения модема."""
texts = get_texts(language)
return types.InlineKeyboardMarkup(inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("MODEM_CONFIRM_BUTTON", "Подтвердить подключение"),
callback_data="modem_confirm"
)
],
[
types.InlineKeyboardButton(
text=texts.CANCEL,
callback_data="subscription_modem"
)
]
])
@error_handler
@modem_available()
async def handle_modem_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Показывает меню управления модемом."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
modem_enabled = service.get_modem_enabled(subscription)
modem_price = settings.get_modem_price_per_month()
if modem_enabled:
status_text = texts.t("MODEM_STATUS_ENABLED", "Подключен")
info_text = texts.t(
"MODEM_INFO_ENABLED",
(
"<b>Модем</b>\n\n"
"Статус: {status}\n\n"
"Модем подключен к вашей подписке.\n"
"Ежемесячная плата: {price}\n\n"
"При отключении модема возврат средств не производится."
),
).format(
status=status_text,
price=texts.format_price(modem_price),
)
else:
status_text = texts.t("MODEM_STATUS_DISABLED", "Не подключен")
info_text = texts.t(
"MODEM_INFO_DISABLED",
(
"<b>Модем</b>\n\n"
"Статус: {status}\n\n"
"Подключите модем к вашей подписке.\n"
"Ежемесячная плата: {price}\n\n"
"При подключении модема будет добавлено дополнительное устройство."
),
).format(
status=status_text,
price=texts.format_price(modem_price),
)
await callback.message.edit_text(
info_text,
reply_markup=get_modem_keyboard(db_user.language, modem_enabled),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_enable=True)
async def handle_modem_enable(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Обработчик подключения модема - показывает информацию о цене."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
price_info = service.calculate_price(subscription)
modem_price_per_month = settings.get_modem_price_per_month()
has_funds, missing_kopeks = service.check_balance(db_user, price_info.final_price)
if not has_funds:
if price_info.has_discount:
required_text = (
f"{texts.format_price(price_info.final_price)} "
f"(за {price_info.charged_months} мес, скидка {price_info.discount_percent}%)"
)
else:
required_text = (
f"{texts.format_price(price_info.final_price)} "
f"(за {price_info.charged_months} мес)"
)
message_text = texts.t(
"MODEM_INSUFFICIENT_FUNDS",
(
"<b>Недостаточно средств</b>\n\n"
"Стоимость подключения модема: {required}\n"
"На балансе: {balance}\n"
"Не хватает: {missing}\n\n"
"Выберите способ пополнения."
),
).format(
required=required_text,
balance=texts.format_price(db_user.balance_kopeks),
missing=texts.format_price(missing_kopeks),
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
),
parse_mode="HTML",
)
await callback.answer()
return
warning_level = service.get_period_warning_level(price_info.remaining_days)
if warning_level == "critical":
warning_text = texts.t(
"MODEM_SHORT_PERIOD_WARNING",
"\n<b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\n"
"После продления подписки модем нужно будет оплатить заново!"
).format(days=price_info.remaining_days)
elif warning_level == "info":
warning_text = texts.t(
"MODEM_PERIOD_NOTE",
"\nДо окончания подписки: <b>{days} дн.</b>\n"
"После продления модем нужно будет оплатить заново."
).format(days=price_info.remaining_days)
else:
warning_text = ""
if price_info.has_discount:
price_text = texts.t(
"MODEM_PRICE_WITH_DISCOUNT",
"Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n"
"Скидка {discount}%: -{discount_amount}"
).format(
base_price=texts.format_price(price_info.base_price),
final_price=texts.format_price(price_info.final_price),
months=price_info.charged_months,
discount=price_info.discount_percent,
discount_amount=texts.format_price(price_info.discount_amount),
)
else:
price_text = texts.t(
"MODEM_PRICE_NO_DISCOUNT",
"Стоимость: {price} (за {months} мес)"
).format(
price=texts.format_price(price_info.final_price),
months=price_info.charged_months,
)
confirm_text = texts.t(
"MODEM_CONFIRM_ENABLE_BASE",
(
"<b>Подтверждение подключения модема</b>\n\n"
"{price_text}\n\n"
"При подключении модема:\n"
"К подписке добавится дополнительное устройство\n"
"Ежемесячная плата увеличится на {monthly_price}\n\n"
"Подтвердить подключение?"
),
).format(
price_text=price_text,
monthly_price=texts.format_price(modem_price_per_month),
)
end_date_str = price_info.end_date.strftime("%d.%m.%Y")
period_info = texts.t(
"MODEM_PERIOD_INFO",
"\nМодем действует до: <b>{end_date}</b> ({days} дн.)"
).format(end_date=end_date_str, days=price_info.remaining_days)
confirm_text += period_info + warning_text
await callback.message.edit_text(
confirm_text,
reply_markup=get_modem_confirm_keyboard(db_user.language),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_enable=True)
async def handle_modem_confirm(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Подтверждение и активация модема."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
result = await service.enable_modem(db, db_user, subscription)
if not result.success:
error_messages = {
ModemError.INSUFFICIENT_FUNDS: texts.t(
"MODEM_INSUFFICIENT_FUNDS_SHORT",
"Недостаточно средств на балансе"
),
ModemError.CHARGE_ERROR: texts.t(
"PAYMENT_CHARGE_ERROR",
"Ошибка списания средств"
),
ModemError.UPDATE_ERROR: texts.ERROR,
}
error_text = error_messages.get(result.error, texts.ERROR)
if result.error == ModemError.INSUFFICIENT_FUNDS:
await callback.message.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language, "modem_enable"),
parse_mode="HTML"
)
else:
await callback.answer(error_text, show_alert=True)
return
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "modem", False, True, result.charged_amount
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о подключении модема: {e}")
success_text = texts.t(
"MODEM_ENABLED_SUCCESS",
(
"<b>Модем успешно подключен!</b>\n\n"
"Модем активирован\n"
"Добавлено устройство для модема\n"
),
)
if result.charged_amount > 0:
success_text += texts.t(
"MODEM_CHARGED",
"Списано: {amount}",
).format(amount=texts.format_price(result.charged_amount))
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
parse_mode="HTML"
)
await callback.answer()
@error_handler
@modem_available(for_disable=True)
async def handle_modem_disable(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
"""Отключение модема."""
texts = get_texts(db_user.language)
subscription = db_user.subscription
service = get_modem_service()
result = await service.disable_modem(db, db_user, subscription)
if not result.success:
await callback.answer(texts.ERROR, show_alert=True)
return
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, "modem", True, False, 0
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления об отключении модема: {e}")
success_text = texts.t(
"MODEM_DISABLED_SUCCESS",
(
"<b>Модем отключен</b>\n\n"
"Модем деактивирован\n"
"Возврат средств не производится"
),
)
await callback.message.edit_text(
success_text,
reply_markup=get_back_keyboard(db_user.language, "subscription_settings"),
parse_mode="HTML"
)
await callback.answer()
def register_modem_handlers(dp: Dispatcher):
"""Регистрация обработчиков модема."""
dp.callback_query.register(
handle_modem_menu,
F.data == "subscription_modem"
)
dp.callback_query.register(
handle_modem_enable,
F.data == "modem_enable"
)
dp.callback_query.register(
handle_modem_confirm,
F.data == "modem_confirm"
)
dp.callback_query.register(
handle_modem_disable,
F.data == "modem_disable"
)
File diff suppressed because it is too large Load Diff
+123 -19
View File
@@ -79,7 +79,12 @@ from app.utils.promo_offer import (
)
from .common import _apply_addon_discount, _get_addon_discount_percent_for_user, _get_period_hint_from_subscription, get_confirm_switch_traffic_keyboard, get_traffic_switch_keyboard, logger
from .countries import _get_available_countries, _should_show_countries_management
from .countries import (
_build_countries_selection_text,
_get_available_countries,
_get_preselected_free_countries,
_should_show_countries_management,
)
from .summary import present_subscription_summary
async def handle_add_traffic(
@@ -91,7 +96,18 @@ async def handle_add_traffic(
texts = get_texts(db_user.language)
if settings.is_traffic_fixed():
# Проверяем, включена ли функция докупки трафика
if not settings.is_traffic_topup_enabled():
await callback.answer(
texts.t(
"TRAFFIC_TOPUP_DISABLED",
"⚠️ Функция докупки трафика отключена",
),
show_alert=True,
)
return
if settings.is_traffic_topup_blocked():
await callback.answer(
texts.t(
"TRAFFIC_FIXED_MODE",
@@ -146,6 +162,43 @@ async def handle_add_traffic(
await callback.answer()
def _calculate_traffic_reset_price(subscription) -> int:
"""Рассчитывает цену сброса трафика в зависимости от настроек."""
mode = settings.get_traffic_reset_price_mode()
base_price = settings.get_traffic_reset_base_price()
# Если базовая цена не задана, используем цену периода 30 дней
if base_price == 0:
base_price = PERIOD_PRICES.get(30, 0)
if mode == "period":
# Старое поведение: фиксированная цена = стоимость периода
return base_price
elif mode == "traffic":
# Цена = стоимость текущего пакета трафика
traffic_price = settings.get_traffic_price(subscription.traffic_limit_gb)
return max(traffic_price, base_price)
elif mode == "traffic_with_purchased":
# Цена = стоимость базового трафика + докупленного
# Базовый трафик = текущий лимит - докупленный
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
# Получаем цену базового трафика
base_traffic_price = settings.get_traffic_price(base_traffic_gb) if base_traffic_gb > 0 else 0
# Получаем цену докупленного трафика
purchased_traffic_price = settings.get_traffic_price(purchased_gb) if purchased_gb > 0 else 0
total_price = base_traffic_price + purchased_traffic_price
return max(total_price, base_price)
# Fallback на базовую цену
return base_price
async def handle_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -153,7 +206,7 @@ async def handle_reset_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный и не может быть сброшен", show_alert=True)
return
@@ -168,19 +221,40 @@ async def handle_reset_traffic(
await callback.answer("⌛ У вас безлимитный трафик", show_alert=True)
return
reset_price = PERIOD_PRICES[30]
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
await callback.answer("⌛ Недостаточно средств на балансе", show_alert=True)
return
# Формируем информацию о расчете цены
purchased_gb = getattr(subscription, 'purchased_traffic_gb', 0) or 0
price_info = ""
if purchased_gb > 0 and settings.get_traffic_reset_price_mode() == "traffic_with_purchased":
base_traffic_gb = subscription.traffic_limit_gb - purchased_gb
price_info = (
f"\n\n💡 <i>Расчет цены:</i>\n"
f"• Базовый трафик: {texts.format_traffic(base_traffic_gb)}\n"
f"• Докупленный: {texts.format_traffic(purchased_gb)}"
)
# Проверяем достаточно ли средств
has_enough_balance = db_user.balance_kopeks >= reset_price
missing_kopeks = max(0, reset_price - db_user.balance_kopeks)
# Формируем текст о балансе
balance_info = f"\n\n💰 На балансе: {texts.format_price(db_user.balance_kopeks)}"
if not has_enough_balance:
balance_info += f"\n⚠️ Не хватает: {texts.format_price(missing_kopeks)}"
await callback.message.edit_text(
f"🔄 <b>Сброс трафика</b>\n\n"
f"Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n"
f"Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}\n\n"
f"Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n"
"После сброса счетчик использованного трафика станет равным 0.",
reply_markup=get_reset_traffic_confirm_keyboard(reset_price, db_user.language)
reply_markup=get_reset_traffic_confirm_keyboard(
reset_price,
db_user.language,
has_enough_balance=has_enough_balance,
missing_kopeks=missing_kopeks,
)
)
await callback.answer()
@@ -192,14 +266,14 @@ async def confirm_reset_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
texts = get_texts(db_user.language)
subscription = db_user.subscription
reset_price = PERIOD_PRICES[30]
reset_price = _calculate_traffic_reset_price(subscription)
if db_user.balance_kopeks < reset_price:
missing_kopeks = reset_price - db_user.balance_kopeks
@@ -348,9 +422,16 @@ async def select_traffic(
if await _should_show_countries_management(db_user):
countries = await _get_available_countries(db_user.promo_group_id)
# Автоматически предвыбираем бесплатные серверы
preselected = _get_preselected_free_countries(countries)
data['countries'] = preselected
await state.set_data(data)
# Формируем текст с описаниями сквадов
selection_text = _build_countries_selection_text(countries, texts.SELECT_COUNTRIES)
await callback.message.edit_text(
texts.SELECT_COUNTRIES,
reply_markup=get_countries_keyboard(countries, [], db_user.language)
selection_text,
reply_markup=get_countries_keyboard(countries, preselected, db_user.language),
parse_mode="HTML"
)
await state.set_state(SubscriptionStates.selecting_countries)
await callback.answer()
@@ -380,7 +461,7 @@ async def add_traffic(
db_user: User,
db: AsyncSession
):
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
@@ -388,7 +469,7 @@ async def add_traffic(
texts = get_texts(db_user.language)
subscription = db_user.subscription
base_price = settings.get_traffic_price(traffic_gb)
base_price = settings.get_traffic_topup_price(traffic_gb)
if base_price == 0 and traffic_gb != 0:
await callback.answer("⚠️ Цена для этого пакета не настроена", show_alert=True)
@@ -458,8 +539,13 @@ async def add_traffic(
if traffic_gb == 0:
subscription.traffic_limit_gb = 0
# При переходе на безлимит сбрасываем докупленный трафик
subscription.purchased_traffic_gb = 0
else:
await add_subscription_traffic(db, subscription, traffic_gb)
# Записываем докупленный трафик для корректного расчета цены сброса
current_purchased = getattr(subscription, 'purchased_traffic_gb', 0) or 0
subscription.purchased_traffic_gb = current_purchased + traffic_gb
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
@@ -523,7 +609,7 @@ async def handle_switch_traffic(
):
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
await callback.answer("⚠️ В текущем режиме трафик фиксированный", show_alert=True)
return
@@ -535,6 +621,10 @@ async def handle_switch_traffic(
return
current_traffic = subscription.traffic_limit_gb
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цен
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic = current_traffic - purchased_traffic
period_hint_days = _get_period_hint_from_subscription(subscription)
traffic_discount_percent = _get_addon_discount_percent_for_user(
db_user,
@@ -542,18 +632,25 @@ async def handle_switch_traffic(
period_hint_days,
)
# Показываем информацию о докупленном трафике, если он есть
purchased_info = ""
if purchased_traffic > 0:
purchased_info = f"\n📦 Базовый пакет: {texts.format_traffic(base_traffic)}\n Докуплено: {texts.format_traffic(purchased_traffic)}"
await callback.message.edit_text(
f"🔄 <b>Переключение лимита трафика</b>\n\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}\n"
f"Текущий лимит: {texts.format_traffic(current_traffic)}{purchased_info}\n"
f"Выберите новый лимит трафика:\n\n"
f"💡 <b>Важно:</b>\n"
f"• При увеличении - доплата за разницу\n"
f"• При уменьшении - возврат средств не производится",
f"• При уменьшении - возврат средств не производится\n"
f"• Докупленный трафик будет сброшен",
reply_markup=get_traffic_switch_keyboard(
current_traffic,
db_user.language,
subscription.end_date,
traffic_discount_percent,
base_traffic_gb=base_traffic,
),
parse_mode="HTML"
)
@@ -571,11 +668,16 @@ async def confirm_switch_traffic(
current_traffic = subscription.traffic_limit_gb
# Вычисляем базовый трафик (без докупленного) для корректного расчёта цены
purchased_traffic = getattr(subscription, 'purchased_traffic_gb', 0) or 0
base_traffic = current_traffic - purchased_traffic
if new_traffic_gb == current_traffic:
await callback.answer("ℹ️ Лимит трафика не изменился", show_alert=True)
return
old_price_per_month = settings.get_traffic_price(current_traffic)
# Используем базовый трафик для определения текущей цены пакета
old_price_per_month = settings.get_traffic_price(base_traffic)
new_price_per_month = settings.get_traffic_price(new_traffic_gb)
months_remaining = get_remaining_months(subscription.end_date)
@@ -692,6 +794,8 @@ async def execute_switch_traffic(
)
subscription.traffic_limit_gb = new_traffic_gb
# Сбрасываем докупленный трафик при переключении пакета
subscription.purchased_traffic_gb = 0
subscription.updated_at = datetime.utcnow()
await db.commit()
+101 -5
View File
@@ -464,17 +464,61 @@ async def show_my_tickets_closed(
await callback.answer()
def _split_long_block(block: str, max_len: int) -> list[str]:
"""Разбивает слишком длинный блок на части."""
if len(block) <= max_len:
return [block]
parts = []
remaining = block
while remaining:
if len(remaining) <= max_len:
parts.append(remaining)
break
# Ищем место для разрыва (перенос строки или пробел)
cut_at = max_len
newline_pos = remaining.rfind('\n', 0, max_len)
space_pos = remaining.rfind(' ', 0, max_len)
if newline_pos > max_len // 2:
cut_at = newline_pos + 1
elif space_pos > max_len // 2:
cut_at = space_pos + 1
parts.append(remaining[:cut_at])
remaining = remaining[cut_at:]
return parts
def _split_text_into_pages(header: str, message_blocks: list[str], max_len: int = 3500) -> list[str]:
"""Разбивает текст на страницы с учётом лимита Telegram."""
pages: list[str] = []
current = header
header_len = len(header)
block_max_len = max_len - header_len - 50 # запас для безопасности
for block in message_blocks:
if len(current) + len(block) > max_len:
pages.append(current)
# Если блок сам по себе слишком длинный — разбиваем его
if len(block) > block_max_len:
block_parts = _split_long_block(block, block_max_len)
for part in block_parts:
if len(current) + len(part) > max_len:
if current.strip() and current != header:
pages.append(current)
current = header + part
else:
current += part
elif len(current) + len(block) > max_len:
if current.strip() and current != header:
pages.append(current)
current = header + block
else:
current += block
if current.strip():
pages.append(current)
return pages if pages else [header]
@@ -792,7 +836,7 @@ async def handle_ticket_reply(
)
texts = get_texts(db_user.language)
await message.answer(
texts.t("TICKET_REPLY_SENT", "✅ Ваш ответ отправлен!"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -806,9 +850,13 @@ async def handle_ticket_reply(
)]
])
)
await state.clear()
# Уведомить админов об ответе пользователя
logger.info(f"Attempting to notify admins about ticket reply #{ticket_id}")
await notify_admins_about_ticket_reply(ticket, reply_text, db)
except Exception as e:
logger.error(f"Error adding ticket reply: {e}")
texts = get_texts(db_user.language)
@@ -971,6 +1019,54 @@ async def notify_admins_about_new_ticket(ticket: Ticket, db: AsyncSession):
logger.error(f"Error notifying admins about new ticket: {e}")
async def notify_admins_about_ticket_reply(ticket: Ticket, reply_text: str, db: AsyncSession):
"""Уведомить админов об ответе пользователя на тикет"""
logger.info(f"notify_admins_about_ticket_reply called for ticket #{ticket.id}")
try:
from app.config import settings
if not settings.is_admin_notifications_enabled():
logger.info(f"Admin notifications disabled. Reply to ticket #{ticket.id}")
return
title = (ticket.title or '').strip()
if len(title) > 60:
title = title[:57] + "..."
# Загрузим пользователя
try:
user = await get_user_by_id(db, ticket.user_id)
except Exception:
user = None
full_name = user.full_name if user else "Unknown"
telegram_id_display = user.telegram_id if user else ""
username_display = (user.username or "отсутствует") if user else "отсутствует"
# Обрезаем текст ответа для уведомления
reply_preview = reply_text[:150] + "..." if len(reply_text) > 150 else reply_text
notification_text = (
f"💬 <b>ОТВЕТ НА ТИКЕТ</b>\n\n"
f"🆔 <b>ID тикета:</b> <code>{ticket.id}</code>\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"👤 <b>Пользователь:</b> {full_name}\n"
f"🆔 <b>Telegram ID:</b> <code>{telegram_id_display}</code>\n"
f"📱 <b>Username:</b> @{username_display}\n\n"
f"📩 <b>Сообщение:</b>\n{reply_preview}\n"
)
from app.services.maintenance_service import maintenance_service
bot = maintenance_service._bot or None
if bot is None:
logger.warning("Bot instance is not available for admin notifications")
return
service = AdminNotificationService(bot)
result = await service.send_ticket_event_notification(notification_text, None)
logger.info(f"Ticket #{ticket.id} reply notification sent: {result}")
except Exception as e:
logger.error(f"Error notifying admins about ticket reply: {e}")
def register_handlers(dp: Dispatcher):
"""Регистрация обработчиков тикетов"""
+156
View File
@@ -628,6 +628,16 @@ def get_referral_contest_manage_keyboard(
callback_data=f"admin_contest_edit_times_{contest_id}",
),
],
[
InlineKeyboardButton(
text="🔄 Синхронизация",
callback_data=f"admin_contest_sync_{contest_id}",
),
InlineKeyboardButton(
text="🔍 Отладка",
callback_data=f"admin_contest_debug_{contest_id}",
),
],
]
if can_delete:
@@ -837,12 +847,91 @@ def get_admin_messages_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="admin_msg_history"
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_PINNED_MESSAGE", "📌 Закрепленное сообщение"),
callback_data="admin_pinned_message",
)
],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
]
])
def get_pinned_message_keyboard(
language: str = "ru",
send_before_menu: bool = True,
send_on_every_start: bool = True,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
position_label = (
_t(texts, "ADMIN_PINNED_POSITION_BEFORE", "⬆️ Показать перед меню")
if send_before_menu
else _t(texts, "ADMIN_PINNED_POSITION_AFTER", "⬇️ Показать после меню")
)
toggle_callback = "admin_pinned_message_position"
start_mode_label = (
_t(texts, "ADMIN_PINNED_START_EVERY_TIME", "🔁 Показать при каждом /start")
if send_on_every_start
else _t(texts, "ADMIN_PINNED_START_ONCE", "🚫 Показывать только один раз")
)
start_mode_callback = "admin_pinned_message_start_mode"
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_PINNED_MESSAGE_UPDATE", "✏️ Обновить"),
callback_data="admin_pinned_message_edit",
)
],
[
InlineKeyboardButton(
text=position_label,
callback_data=toggle_callback,
)
],
[
InlineKeyboardButton(
text=start_mode_label,
callback_data=start_mode_callback,
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_PINNED_MESSAGE_DELETE", "🗑️ Удалить и отключить"),
callback_data="admin_pinned_message_delete",
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data="admin_messages")],
])
def get_pinned_broadcast_confirm_keyboard(
language: str = "ru",
pinned_message_id: int = 0,
) -> InlineKeyboardMarkup:
"""Клавиатура для выбора: разослать сейчас или только при /start."""
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_PINNED_BROADCAST_NOW", "📨 Разослать сейчас всем"),
callback_data=f"admin_pinned_broadcast_now:{pinned_message_id}",
)
],
[
InlineKeyboardButton(
text=_t(texts, "ADMIN_PINNED_BROADCAST_ON_START", "⏳ Только при /start"),
callback_data=f"admin_pinned_broadcast_skip:{pinned_message_id}",
)
],
])
def get_admin_monitoring_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -1004,6 +1093,14 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
)
])
# Кнопка управления ограничениями
keyboard.append([
InlineKeyboardButton(
text=_t(texts, "ADMIN_USER_RESTRICTIONS", "⚠️ Ограничить"),
callback_data=f"admin_user_restrictions_{user_id}"
)
])
if user_status == "active":
keyboard.append([
InlineKeyboardButton(
@@ -1041,6 +1138,65 @@ def get_user_management_keyboard(user_id: int, user_status: str, language: str =
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_user_restrictions_keyboard(
user_id: int,
restriction_topup: bool,
restriction_subscription: bool,
language: str = "ru"
) -> InlineKeyboardMarkup:
"""Клавиатура управления ограничениями пользователя."""
texts = get_texts(language)
keyboard = []
# Toggle для ограничения пополнения
topup_emoji = "🚫" if restriction_topup else ""
topup_text = f"{topup_emoji} Пополнение"
keyboard.append([
InlineKeyboardButton(
text=topup_text,
callback_data=f"admin_user_restriction_toggle_topup_{user_id}"
)
])
# Toggle для ограничения подписки
sub_emoji = "🚫" if restriction_subscription else ""
sub_text = f"{sub_emoji} Продление/покупка"
keyboard.append([
InlineKeyboardButton(
text=sub_text,
callback_data=f"admin_user_restriction_toggle_sub_{user_id}"
)
])
# Кнопка изменения причины
keyboard.append([
InlineKeyboardButton(
text="📝 Изменить причину",
callback_data=f"admin_user_restriction_reason_{user_id}"
)
])
# Кнопка снятия всех ограничений (если есть хотя бы одно)
if restriction_topup or restriction_subscription:
keyboard.append([
InlineKeyboardButton(
text="🔓 Снять все ограничения",
callback_data=f"admin_user_restriction_clear_{user_id}"
)
])
# Кнопка назад
keyboard.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data=f"admin_user_manage_{user_id}"
)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_user_promo_group_keyboard(
promo_groups: List[Tuple[Any, int]],
user_id: int,
+77 -20
View File
@@ -438,13 +438,14 @@ def get_main_menu_keyboard(
if settings.DEBUG:
print(f"DEBUG KEYBOARD: language={language}, is_admin={is_admin}, has_had_paid={has_had_paid_subscription}, has_active={has_active_subscription}, sub_active={subscription_is_active}, balance={balance_kopeks}")
if hasattr(texts, 'BALANCE_BUTTON') and balance_kopeks > 0:
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(balance_kopeks))
safe_balance = balance_kopeks or 0
if hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_button_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_button_text = texts.t(
"BALANCE_BUTTON_DEFAULT",
"💰 Баланс: {balance}",
).format(balance=texts.format_price(balance_kopeks))
).format(balance=texts.format_price(safe_balance))
keyboard: list[list[InlineKeyboardButton]] = []
paired_buttons: list[InlineKeyboardButton] = []
@@ -507,7 +508,14 @@ def get_main_menu_keyboard(
)
# Добавляем кнопку докупки трафика для лимитированных подписок
if subscription and not subscription.is_trial and subscription.traffic_limit_gb > 0:
if (
settings.BUY_TRAFFIC_BUTTON_VISIBLE
and settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and not subscription.is_trial
and (subscription.traffic_limit_gb or 0) > 0
):
paired_buttons.append(
InlineKeyboardButton(text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"), callback_data="buy_traffic")
)
@@ -985,7 +993,20 @@ def get_subscription_keyboard(
callback_data="subscription_settings",
)
])
# Кнопка докупки трафика для платных подписок
if (
settings.is_traffic_topup_enabled()
and not settings.is_traffic_topup_blocked()
and subscription
and (subscription.traffic_limit_gb or 0) > 0
):
keyboard.append([
InlineKeyboardButton(
text=texts.t("BUY_TRAFFIC_BUTTON", "📈 Докупить трафик"),
callback_data="buy_traffic"
)
])
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
])
@@ -1127,10 +1148,10 @@ def get_subscription_period_keyboard(
def get_traffic_packages_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
import logging
logger = logging.getLogger(__name__)
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
return get_back_keyboard(language)
logger.info(f"🔍 RAW CONFIG: '{settings.TRAFFIC_PACKAGES_CONFIG}'")
@@ -1372,9 +1393,10 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
has_direct_payment_methods = True
if settings.is_platega_enabled() and settings.get_platega_active_methods():
platega_name = settings.get_platega_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_PLATEGA", "💳 Platega"),
text=texts.t("PAYMENT_PLATEGA", f"💳 {platega_name}"),
callback_data=_build_callback("platega"),
)
])
@@ -1398,6 +1420,15 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
has_direct_payment_methods = True
if settings.is_cloudpayments_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_CLOUDPAYMENTS", "💳 Банковская карта (CloudPayments)"),
callback_data=_build_callback("cloudpayments")
)
])
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
@@ -1683,7 +1714,7 @@ def get_add_traffic_keyboard(
if months_multiplier > 1:
period_text = f" (за {months_multiplier} мес)"
packages = settings.get_traffic_packages()
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
if not enabled_packages:
@@ -1839,27 +1870,45 @@ def get_confirm_change_devices_keyboard(new_devices_count: int, price: int, lang
])
def get_reset_traffic_confirm_keyboard(price_kopeks: int, language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
def get_reset_traffic_confirm_keyboard(
price_kopeks: int,
language: str = DEFAULT_LANGUAGE,
has_enough_balance: bool = True,
missing_kopeks: int = 0,
) -> InlineKeyboardMarkup:
from app.config import settings
if settings.is_traffic_fixed():
if settings.is_traffic_topup_blocked():
return get_back_keyboard(language)
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
buttons = []
if has_enough_balance:
# Достаточно средств - показываем кнопку сброса
buttons.append([
InlineKeyboardButton(
text=f"✅ Сбросить за {settings.format_price(price_kopeks)}",
callback_data="confirm_reset_traffic"
)
],
[
])
else:
# Не хватает средств - показываем кнопку пополнения
buttons.append([
InlineKeyboardButton(
text=texts.t("PENDING_CANCEL_BUTTON", "⌛ Отмена"),
callback_data="menu_subscription",
text=texts.t("TOPUP_BALANCE_BUTTON", "💳 Пополнить баланс"),
callback_data=f"topup_amount_{missing_kopeks}"
)
]
])
buttons.append([
InlineKeyboardButton(
text=texts.BACK,
callback_data="subscription_settings",
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_manage_countries_keyboard(
countries: List[dict],
@@ -2370,6 +2419,14 @@ def get_updated_subscription_settings_keyboard(language: str = DEFAULT_LANGUAGE,
)
])
if settings.is_modem_enabled():
keyboard.append([
InlineKeyboardButton(
text=texts.t("MODEM_BUTTON", "📡 Модем"),
callback_data="subscription_modem"
)
])
keyboard.append([
InlineKeyboardButton(
text=texts.t("MANAGE_DEVICES_BUTTON", "🔧 Управление устройствами"),
+1
View File
@@ -0,0 +1 @@
# Local libraries
+37
View File
@@ -0,0 +1,37 @@
"""
Asynchronous Python client for Russian self-employed tax service (Moy Nalog).
This is a Python port of the PHP library shoman4eg/moy-nalog,
providing async HTTP client for interaction with lknpd.nalog.ru API.
Original PHP library: https://github.com/shoman4eg/moy-nalog
Author: Artem Dubinin <artem@dubinin.me>
License: MIT
"""
from .client import Client
from .exceptions import (
ClientException,
DomainException,
ForbiddenException,
NotFoundException,
PhoneException,
ServerException,
UnauthorizedException,
UnknownErrorException,
ValidationException,
)
__version__ = "1.0.0"
__all__ = [
"Client",
"ClientException",
"DomainException",
"ForbiddenException",
"NotFoundException",
"PhoneException",
"ServerException",
"UnauthorizedException",
"UnknownErrorException",
"ValidationException",
]
+188
View File
@@ -0,0 +1,188 @@
"""
Internal HTTP client and authentication middleware.
Based on PHP library's AuthenticationPlugin and HTTP architecture.
"""
import asyncio
from abc import ABC, abstractmethod
from http import HTTPStatus
from typing import Any
import httpx
from .exceptions import raise_for_status
class AuthProvider(ABC):
"""Abstract interface for authentication provider."""
@abstractmethod
async def get_token(self) -> dict[str, Any] | None:
"""Get current access token data."""
@abstractmethod
async def refresh(self, refresh_token: str) -> dict[str, Any] | None:
"""Refresh access token using refresh token."""
class AsyncHTTPClient:
"""
Async HTTP client with automatic token refresh on 401 responses.
Based on PHP's AuthenticationPlugin behavior:
- Adds Bearer authorization header
- On 401 response, attempts token refresh once
- Retries request with new token (max 2 attempts)
"""
def __init__(
self,
base_url: str,
auth_provider: AuthProvider,
default_headers: dict[str, str] | None = None,
timeout: float = 10.0,
):
self.base_url = base_url
self.auth_provider = auth_provider
self.default_headers = default_headers or {}
self.timeout = timeout
self._refresh_lock = asyncio.Lock()
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
async def _get_auth_headers(self) -> dict[str, str]:
"""Get authorization headers from current token."""
token_data = await self.auth_provider.get_token()
if not token_data or "token" not in token_data:
return {}
return {"Authorization": f"Bearer {token_data['token']}"}
async def _handle_401_response(
self, client: httpx.AsyncClient, request: httpx.Request
) -> httpx.Response | None:
"""
Handle 401 response by refreshing token and retrying request.
Uses asyncio.Lock to prevent concurrent refresh attempts,
similar to PHP's retry storage mechanism.
"""
async with self._refresh_lock:
token_data = await self.auth_provider.get_token()
if not token_data or "refreshToken" not in token_data:
return None
# Attempt token refresh
new_token_data = await self.auth_provider.refresh(
token_data["refreshToken"]
)
if not new_token_data or "token" not in new_token_data:
return None
# Update request with new authorization header
new_auth_headers = {"Authorization": f"Bearer {new_token_data['token']}"}
request.headers.update(new_auth_headers)
# Retry request with new token
return await client.send(request)
async def request(
self,
method: str,
path: str,
headers: dict[str, str] | None = None,
json_data: dict[str, Any] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""
Make HTTP request with automatic auth and 401 retry logic.
Args:
method: HTTP method (GET, POST, etc.)
path: API path (e.g., "/income")
headers: Additional headers
json_data: JSON request body
**kwargs: Additional httpx.AsyncClient.request arguments
Returns:
httpx.Response object
Raises:
Domain exceptions via raise_for_status()
"""
# Prepare headers
request_headers = self.default_headers.copy()
auth_headers = await self._get_auth_headers()
request_headers.update(auth_headers)
if headers:
request_headers.update(headers)
# Prepare request parameters
request_kwargs = {
"method": method,
"url": self.base_url + path,
"headers": request_headers,
"timeout": self.timeout,
**kwargs,
}
if json_data is not None:
request_kwargs["json"] = json_data
async with httpx.AsyncClient() as client:
# Initial request
response = await client.request(**request_kwargs)
# Handle 401 with token refresh (max 1 retry)
if response.status_code == HTTPStatus.UNAUTHORIZED:
# Build request object for retry
request = client.build_request(**request_kwargs)
retry_response = await self._handle_401_response(client, request)
if retry_response is not None:
response = retry_response
# Check for domain exceptions
raise_for_status(response)
return response
async def get(
self,
path: str,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""GET request."""
return await self.request("GET", path, headers=headers, **kwargs)
async def post(
self,
path: str,
json_data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""POST request with JSON data."""
return await self.request(
"POST", path, headers=headers, json_data=json_data, **kwargs
)
async def put(
self,
path: str,
json_data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""PUT request with JSON data."""
return await self.request(
"PUT", path, headers=headers, json_data=json_data, **kwargs
)
async def delete(
self,
path: str,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""DELETE request."""
return await self.request("DELETE", path, headers=headers, **kwargs)
+257
View File
@@ -0,0 +1,257 @@
"""
Authentication provider implementation.
Based on PHP library's Authenticator class.
"""
import json
import uuid
from http import HTTPStatus
from pathlib import Path
from typing import Any
import httpx
from ._http import AuthProvider
from .dto.device import DeviceInfo
from .exceptions import raise_for_status
def generate_device_id() -> str:
"""Generate device ID similar to PHP's DeviceIdGenerator."""
return str(uuid.uuid4()).replace("-", "")[:21].lower()
# DeviceInfo is now imported from dto.device
class AuthProviderImpl(AuthProvider):
"""
Authentication provider implementation.
Provides methods for:
- Username/password authentication (INN + password)
- Phone-based authentication (2-step: challenge + verify)
- Token refresh
- Token storage (in-memory or file-based)
"""
def __init__(
self,
base_url: str = "https://lknpd.nalog.ru/api",
storage_path: str | None = None,
device_id: str | None = None,
):
self.base_url_v1 = f"{base_url}/v1"
self.base_url_v2 = f"{base_url}/v2"
self.storage_path = storage_path
self.device_id = device_id or generate_device_id()
self.device_info = DeviceInfo(sourceDeviceId=self.device_id)
self._token_data: dict[str, Any] | None = None
# Default headers similar to PHP Authenticator
self.default_headers = {
"Referrer": "https://lknpd.nalog.ru/auth/login",
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
}
# Load token from storage if available
if self.storage_path:
self._load_token_from_storage()
def _load_token_from_storage(self) -> None:
"""Load token from file storage."""
if not self.storage_path:
return
storage_path = Path(self.storage_path)
if not storage_path.exists():
return
try:
with storage_path.open(encoding="utf-8") as f:
self._token_data = json.load(f)
except (json.JSONDecodeError, OSError):
# Ignore errors, token will be None
pass
def _save_token_to_storage(self) -> None:
"""Save token to file storage."""
if not self.storage_path or not self._token_data:
return
storage_path = Path(self.storage_path)
try:
# Ensure directory exists
storage_path.parent.mkdir(parents=True, exist_ok=True)
with storage_path.open("w", encoding="utf-8") as f:
json.dump(self._token_data, f, ensure_ascii=False, indent=2)
except OSError:
# Ignore storage errors
pass
async def get_token(self) -> dict[str, Any] | None:
"""Get current access token data."""
return self._token_data
async def set_token(self, token_json: str) -> None:
"""
Set access token from JSON string.
Args:
token_json: JSON string containing token data
"""
try:
self._token_data = json.loads(token_json)
self._save_token_to_storage()
except json.JSONDecodeError as e:
raise ValueError(f"Invalid token JSON: {e}") from e
async def create_new_access_token(self, username: str, password: str) -> str:
"""
Create new access token using INN and password.
Mirrors PHP Authenticator::createAccessToken().
Args:
username: INN (tax identification number)
password: Password
Returns:
JSON string with token data
Raises:
Domain exceptions for authentication errors
"""
request_data = {
"username": username,
"password": password,
"deviceInfo": self.device_info.model_dump(),
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url_v1}/auth/lkfl",
json=request_data,
headers=self.default_headers,
timeout=10.0,
)
raise_for_status(response)
# Store and return token
token_json = response.text
await self.set_token(token_json)
return token_json
async def create_phone_challenge(self, phone: str) -> dict[str, Any]:
"""
Start phone-based authentication challenge.
Mirrors PHP ApiClient::createPhoneChallenge() - uses v2 API.
Args:
phone: Phone number (e.g., "79000000000")
Returns:
Dictionary with challengeToken, expireDate, expireIn
Raises:
Domain exceptions for API errors
"""
request_data = {
"phone": phone,
"requireTpToBeActive": True,
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url_v2}/auth/challenge/sms/start",
json=request_data,
headers=self.default_headers,
timeout=10.0,
)
raise_for_status(response)
return response.json() # type: ignore[no-any-return]
async def create_new_access_token_by_phone(
self, phone: str, challenge_token: str, verification_code: str
) -> str:
"""
Complete phone-based authentication with SMS code.
Mirrors PHP Authenticator::createAccessTokenByPhone().
Args:
phone: Phone number
challenge_token: Token from create_phone_challenge()
verification_code: SMS verification code
Returns:
JSON string with token data
Raises:
Domain exceptions for authentication errors
"""
request_data = {
"phone": phone,
"code": verification_code,
"challengeToken": challenge_token,
"deviceInfo": self.device_info.model_dump(),
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url_v1}/auth/challenge/sms/verify",
json=request_data,
headers=self.default_headers,
timeout=10.0,
)
raise_for_status(response)
# Store and return token
token_json = response.text
await self.set_token(token_json)
return token_json
async def refresh(self, refresh_token: str) -> dict[str, Any] | None:
"""
Refresh access token using refresh token.
Mirrors PHP Authenticator::refreshAccessToken().
Args:
refresh_token: Refresh token string
Returns:
New token data dictionary or None if refresh failed
"""
request_data = {
"deviceInfo": self.device_info.model_dump(),
"refreshToken": refresh_token,
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url_v1}/auth/token",
json=request_data,
headers=self.default_headers,
timeout=10.0,
)
# PHP version only checks for 200 status
if response.status_code != HTTPStatus.OK:
return None
# Store and return new token data
token_json = response.text
await self.set_token(token_json)
return self._token_data
except Exception:
# Silently fail refresh attempts like PHP version
return None
+235
View File
@@ -0,0 +1,235 @@
"""
Main client facade for Moy Nalog API.
Based on PHP library's ApiClient class.
"""
import json
from typing import Any
from ._http import AsyncHTTPClient
from .auth import AuthProviderImpl
from .income import IncomeAPI
from .payment_type import PaymentTypeAPI
from .receipt import ReceiptAPI
from .tax import TaxAPI
from .user import UserAPI
class Client:
"""
Main async client for Moy Nalog API.
Provides factory methods for API modules and authentication methods.
Maps to PHP ApiClient functionality with async support.
Example:
>>> client = Client()
>>> token = await client.create_new_access_token("inn", "password")
>>> await client.authenticate(token)
>>> income_api = client.income()
>>> result = await income_api.create("Service", 100, 1)
"""
def __init__(
self,
base_url: str = "https://lknpd.nalog.ru/api",
storage_path: str | None = None,
device_id: str | None = None,
timeout: float = 10.0,
):
"""
Initialize Moy Nalog API client.
Args:
base_url: API base URL (default: https://lknpd.nalog.ru/api)
storage_path: Optional file path for token storage
device_id: Optional device ID (auto-generated if not provided)
timeout: HTTP request timeout in seconds
"""
self.base_url = base_url
self.timeout = timeout
# Initialize auth provider
self.auth_provider = AuthProviderImpl(
base_url=base_url,
storage_path=storage_path,
device_id=device_id,
)
# Initialize HTTP client with auth middleware
self.http_client = AsyncHTTPClient(
base_url=f"{base_url}/v1",
auth_provider=self.auth_provider,
default_headers={
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Referrer": "https://lknpd.nalog.ru/auth/login",
},
timeout=timeout,
)
# User profile data (for receipt operations)
self._user_profile: dict[str, Any] | None = None
async def create_new_access_token(self, username: str, password: str) -> str:
"""
Create new access token using INN and password.
Maps to PHP ApiClient::createNewAccessToken().
Args:
username: INN (tax identification number)
password: Password
Returns:
JSON string with access token data
Raises:
UnauthorizedException: For invalid credentials
DomainException: For other API errors
"""
return await self.auth_provider.create_new_access_token(username, password)
async def create_phone_challenge(self, phone: str) -> dict[str, Any]:
"""
Start phone-based authentication challenge.
Maps to PHP ApiClient::createPhoneChallenge().
Args:
phone: Phone number (e.g., "79000000000")
Returns:
Dictionary with challengeToken, expireDate, expireIn
Raises:
DomainException: For API errors
"""
return await self.auth_provider.create_phone_challenge(phone)
async def create_new_access_token_by_phone(
self, phone: str, challenge_token: str, verification_code: str
) -> str:
"""
Complete phone-based authentication with SMS code.
Maps to PHP ApiClient::createNewAccessTokenByPhone().
Args:
phone: Phone number
challenge_token: Token from create_phone_challenge()
verification_code: SMS verification code
Returns:
JSON string with access token data
Raises:
UnauthorizedException: For invalid verification
DomainException: For other API errors
"""
return await self.auth_provider.create_new_access_token_by_phone(
phone, challenge_token, verification_code
)
async def authenticate(self, access_token: str) -> None:
"""
Authenticate client with access token.
Maps to PHP ApiClient::authenticate().
Args:
access_token: JSON string with token data
Raises:
ValueError: For invalid token JSON
"""
await self.auth_provider.set_token(access_token)
# Parse token to extract user profile (like PHP version)
try:
token_data = json.loads(access_token)
if "profile" in token_data:
self._user_profile = token_data["profile"]
except json.JSONDecodeError:
# If token parsing fails, profile will remain None
pass
async def get_access_token(self) -> str | None:
"""
Get current access token (may be refreshed).
Maps to PHP ApiClient::getAccessToken().
Returns:
Current access token JSON string or None
"""
token_data = await self.auth_provider.get_token()
if token_data:
return json.dumps(token_data)
return None
def income(self) -> IncomeAPI:
"""
Get Income API instance.
Maps to PHP ApiClient::income().
Returns:
IncomeAPI instance for creating/cancelling receipts
"""
return IncomeAPI(self.http_client)
def receipt(self) -> ReceiptAPI:
"""
Get Receipt API instance.
Maps to PHP ApiClient::receipt().
Returns:
ReceiptAPI instance for accessing receipt data
Raises:
ValueError: If user is not authenticated (no profile data)
"""
if not self._user_profile or "inn" not in self._user_profile:
raise ValueError("User profile not available. Please authenticate first.")
return ReceiptAPI(
http_client=self.http_client,
base_endpoint=self.base_url,
user_inn=self._user_profile["inn"],
)
def payment_type(self) -> PaymentTypeAPI:
"""
Get PaymentType API instance.
Maps to PHP ApiClient::paymentType().
Returns:
PaymentTypeAPI instance for managing payment methods
"""
return PaymentTypeAPI(self.http_client)
def tax(self) -> TaxAPI:
"""
Get Tax API instance.
Maps to PHP ApiClient::tax().
Returns:
TaxAPI instance for tax information and history
"""
return TaxAPI(self.http_client)
def user(self) -> UserAPI:
"""
Get User API instance.
Maps to PHP ApiClient::user().
Returns:
UserAPI instance for user information
"""
return UserAPI(self.http_client)
+46
View File
@@ -0,0 +1,46 @@
"""Data Transfer Objects for Moy Nalog API."""
from .device import DeviceInfo
from .income import (
AtomDateTime,
CancelCommentType,
CancelRequest,
IncomeClient,
IncomeRequest,
IncomeServiceItem,
IncomeType,
PaymentType,
)
from .invoice import InvoiceClient, InvoiceServiceItem
from .payment_type import PaymentType as PaymentTypeModel
from .payment_type import PaymentTypeCollection
from .tax import History, HistoryRecords, Payment, PaymentRecords, Tax
from .user import UserType
__all__ = [
"AtomDateTime",
"CancelCommentType",
"CancelRequest",
# Device DTOs
"DeviceInfo",
"History",
"HistoryRecords",
"IncomeClient",
"IncomeRequest",
"IncomeServiceItem",
# Income DTOs
"IncomeType",
"InvoiceClient",
# Invoice DTOs
"InvoiceServiceItem",
"Payment",
"PaymentRecords",
"PaymentType",
"PaymentTypeCollection",
# Payment Type DTOs
"PaymentTypeModel",
# Tax DTOs
"Tax",
# User DTOs
"UserType",
]
+44
View File
@@ -0,0 +1,44 @@
"""
Device-related DTO models.
Based on PHP library's DTO\\DeviceInfo class.
"""
from typing import Any, ClassVar
from pydantic import BaseModel, Field
class DeviceInfo(BaseModel):
"""
Device information model for API requests.
Maps to PHP DTO\\DeviceInfo.
"""
# Constants from PHP class
SOURCE_TYPE_WEB: ClassVar[str] = "WEB"
APP_VERSION: ClassVar[str] = "1.0.0"
USER_AGENT: ClassVar[str] = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_2) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/88.0.4324.192 Safari/537.36"
)
source_type: str = Field(
default=SOURCE_TYPE_WEB,
alias="sourceType",
description="Source type (usually WEB)",
)
source_device_id: str = Field(..., alias="sourceDeviceId", description="Device ID")
app_version: str = Field(
default=APP_VERSION, alias="appVersion", description="Application version"
)
user_agent: str = Field(default=USER_AGENT, description="User agent string")
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match PHP jsonSerialize format."""
_ = kwargs
return {
"sourceType": self.source_type,
"sourceDeviceId": self.source_device_id,
"appVersion": self.app_version,
"metaDetails": {"userAgent": self.user_agent},
}
+255
View File
@@ -0,0 +1,255 @@
"""
Income-related DTO models.
Based on PHP library's DTO and Enum classes.
MODIFIED: Исправлена работа с временем для nalog.ru
"""
from datetime import UTC, datetime, timezone, timedelta
from decimal import Decimal
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, field_serializer, field_validator
class IncomeType(str, Enum):
"""Income type enumeration. Maps to PHP Enum\\IncomeType."""
FROM_INDIVIDUAL = "FROM_INDIVIDUAL"
FROM_LEGAL_ENTITY = "FROM_LEGAL_ENTITY"
FROM_FOREIGN_AGENCY = "FROM_FOREIGN_AGENCY"
class PaymentType(str, Enum):
"""Payment type enumeration. Maps to PHP Enum\\PaymentType."""
CASH = "CASH"
ACCOUNT = "ACCOUNT"
class CancelCommentType(str, Enum):
"""Cancel comment type enumeration. Maps to PHP Enum\\CancelCommentType."""
CANCEL = "Чек сформирован ошибочно"
REFUND = "Возврат средств"
# Московская таймзона UTC+3
MOSCOW_TZ = timezone(timedelta(hours=3))
class AtomDateTime(BaseModel):
"""
DateTime wrapper for ISO/ATOM serialization.
Maps to PHP DTO\\DateTime behavior.
Используем московское время с явным offset +03:00
"""
value: datetime = Field(default_factory=lambda: datetime.now(MOSCOW_TZ))
@field_serializer("value")
def serialize_datetime(self, dt: datetime) -> str:
"""Serialize datetime with timezone offset (NOT Z suffix)."""
# Убеждаемся что есть московская таймзона
if dt.tzinfo is None:
dt = dt.replace(tzinfo=MOSCOW_TZ)
elif dt.tzinfo == UTC:
dt = dt.astimezone(MOSCOW_TZ)
# Возвращаем с offset +03:00, НЕ конвертируем в UTC
return dt.isoformat()
@classmethod
def now(cls) -> "AtomDateTime":
"""Create AtomDateTime with current Moscow time."""
return cls(value=datetime.now(MOSCOW_TZ))
@classmethod
def from_datetime(cls, dt: datetime) -> "AtomDateTime":
"""Create AtomDateTime from datetime object."""
return cls(value=dt)
class IncomeServiceItem(BaseModel):
"""
Service item for income creation.
Maps to PHP DTO\\IncomeServiceItem.
"""
name: str = Field(..., description="Service name/description")
amount: Decimal = Field(..., description="Service amount", gt=0)
quantity: Decimal = Field(..., description="Service quantity", gt=0)
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
"""Validate name is not empty."""
if not v.strip():
raise ValueError("Name cannot be empty")
return v.strip()
@field_validator("amount", "quantity")
@classmethod
def validate_positive_decimal(cls, v: Decimal) -> Decimal:
"""Validate decimal values are positive."""
if v <= 0:
raise ValueError("Amount and quantity must be greater than 0")
return v
@field_serializer("amount", "quantity")
def serialize_decimal(self, value: Decimal) -> str:
"""Serialize Decimal as string (like PHP BigDecimal)."""
return str(value)
def get_total_amount(self) -> Decimal:
"""Calculate total amount (amount * quantity)."""
return self.amount * self.quantity
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match PHP jsonSerialize format."""
_ = kwargs
return {
"name": self.name,
"amount": str(self.amount),
"quantity": str(self.quantity),
}
class IncomeClient(BaseModel):
"""
Client information for income creation.
Maps to PHP DTO\\IncomeClient.
"""
contact_phone: str | None = Field(default=None, description="Client contact phone")
display_name: str | None = Field(default=None, description="Client display name")
income_type: IncomeType = Field(
default=IncomeType.FROM_INDIVIDUAL, description="Income type"
)
inn: str | None = Field(default=None, description="Client INN (tax ID)")
@field_validator("inn")
@classmethod
def validate_inn(cls, v: str | None, info: Any) -> str | None:
"""Validate INN format for legal entities."""
_ = info
if v is None:
return v
# Remove any whitespace
v = v.strip()
if not v:
return None
# Check if it's numeric
if not v.isdigit():
raise ValueError("INN must contain only numbers")
# Check length (10 for legal entities, 12 for individuals)
if len(v) not in [10, 12]:
raise ValueError("INN length must be 10 or 12 digits")
return v
@field_validator("display_name")
@classmethod
def validate_display_name_for_legal_entity(
cls, v: str | None, info: Any
) -> str | None:
"""Validate display name is provided for legal entities."""
_ = info
# Note: This validation is applied in the API layer in PHP,
# but we can do basic validation here
if v is not None:
v = v.strip()
if not v:
return None
return v
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match PHP jsonSerialize format."""
_ = kwargs
return {
"contactPhone": self.contact_phone,
"displayName": self.display_name,
"incomeType": self.income_type.value,
"inn": self.inn,
}
class IncomeRequest(BaseModel):
"""
Complete income creation request.
Maps to PHP request structure in Income::createMultipleItems().
"""
operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
request_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
services: list[IncomeServiceItem] = Field(..., min_length=1)
total_amount: str = Field(..., description="Total amount as string")
client: IncomeClient = Field(default_factory=IncomeClient)
payment_type: PaymentType = Field(default=PaymentType.CASH)
ignore_max_total_income_restriction: bool = Field(default=False)
@field_validator("services")
@classmethod
def validate_services(cls, v: list[IncomeServiceItem]) -> list[IncomeServiceItem]:
"""Validate services list is not empty."""
if not v:
raise ValueError("Services cannot be empty")
return v
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match PHP request format."""
_ = kwargs
return {
"operationTime": self.operation_time.serialize_datetime(
self.operation_time.value
),
"requestTime": self.request_time.serialize_datetime(
self.request_time.value
),
"services": [service.model_dump() for service in self.services],
"totalAmount": self.total_amount,
"client": self.client.model_dump(),
"paymentType": self.payment_type.value,
"ignoreMaxTotalIncomeRestriction": self.ignore_max_total_income_restriction,
}
class CancelRequest(BaseModel):
"""
Income cancellation request.
Maps to PHP request structure in Income::cancel().
"""
operation_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
request_time: AtomDateTime = Field(default_factory=AtomDateTime.now)
comment: CancelCommentType = Field(..., description="Cancellation reason")
receipt_uuid: str = Field(..., description="Receipt UUID to cancel")
partner_code: str | None = Field(default=None, description="Partner code")
@field_validator("receipt_uuid")
@classmethod
def validate_receipt_uuid(cls, v: str) -> str:
"""Validate receipt UUID is not empty."""
if not v.strip():
raise ValueError("Receipt UUID cannot be empty")
return v.strip()
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match PHP request format."""
_ = kwargs
return {
"operationTime": self.operation_time.serialize_datetime(
self.operation_time.value
),
"requestTime": self.request_time.serialize_datetime(
self.request_time.value
),
"comment": self.comment.value,
"receiptUuid": self.receipt_uuid,
"partnerCode": self.partner_code,
}
+69
View File
@@ -0,0 +1,69 @@
"""
Invoice-related DTO models.
Based on PHP library's DTO classes for invoices.
Note: Invoice functionality is marked as "Not implemented" in the PHP library,
but DTO structures are provided for future implementation.
"""
from decimal import Decimal
from typing import Any
from pydantic import BaseModel, Field, field_serializer
class InvoiceServiceItem(BaseModel):
"""
Invoice service item model.
Maps to PHP DTO\\InvoiceServiceItem.
Similar to IncomeServiceItem but for invoices.
"""
name: str = Field(..., description="Service name/description")
amount: Decimal = Field(..., description="Service amount", gt=0)
quantity: Decimal = Field(..., description="Service quantity", gt=0)
@field_serializer("amount", "quantity")
def serialize_decimal(self, value: Decimal) -> str:
"""Serialize Decimal as string."""
return str(value)
def get_total_amount(self) -> Decimal:
"""Calculate total amount (amount * quantity)."""
return self.amount * self.quantity
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match API format."""
_ = kwargs
return {
"name": self.name,
"amount": str(self.amount),
"quantity": str(self.quantity),
}
class InvoiceClient(BaseModel):
"""
Invoice client information model.
Maps to PHP DTO\\InvoiceClient.
Similar to IncomeClient but for invoices.
"""
contact_phone: str | None = Field(
None, alias="contactPhone", description="Client contact phone"
)
display_name: str | None = Field(
None, alias="displayName", description="Client display name"
)
inn: str | None = Field(None, description="Client INN")
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match API format."""
_ = kwargs
return {
"contactPhone": self.contact_phone,
"displayName": self.display_name,
"inn": self.inn,
}
+73
View File
@@ -0,0 +1,73 @@
"""
PaymentType-related DTO models.
Based on PHP library's Model/PaymentType classes.
"""
from typing import Any
from pydantic import BaseModel, Field
class PaymentType(BaseModel):
"""
Payment type model.
Maps to PHP Model\\PaymentType\\PaymentType.
"""
id: int = Field(..., description="Payment type ID")
type: str = Field(..., description="Payment type")
bank_name: str = Field(..., alias="bankName", description="Bank name")
bank_bik: str = Field(..., alias="bankBik", description="Bank BIK")
corr_account: str = Field(
..., alias="corrAccount", description="Correspondent account"
)
favorite: bool = Field(..., description="Is favorite payment type")
phone: str | None = Field(None, description="Phone number")
bank_id: str | None = Field(None, alias="bankId", description="Bank ID")
current_account: str = Field(
..., alias="currentAccount", description="Current account"
)
available_for_pa: bool = Field(
..., alias="availableForPa", description="Available for PA"
)
def is_favorite(self) -> bool:
"""Check if this payment type is marked as favorite."""
return self.favorite
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match API format."""
_ = kwargs
return {
"id": self.id,
"type": self.type,
"bankName": self.bank_name,
"bankBik": self.bank_bik,
"corrAccount": self.corr_account,
"favorite": self.favorite,
"phone": self.phone,
"bankId": self.bank_id,
"currentAccount": self.current_account,
"availableForPa": self.available_for_pa,
}
class PaymentTypeCollection(BaseModel):
"""
Collection of payment types.
Maps to PHP Model\\PaymentType\\PaymentTypeCollection.
"""
payment_types: list[PaymentType] = Field(default_factory=list)
def __iter__(self) -> Any:
"""Make collection iterable."""
return iter(self.payment_types)
def __len__(self) -> int:
"""Get collection length."""
return len(self.payment_types)
def __getitem__(self, index: int) -> PaymentType:
"""Get item by index."""
return self.payment_types[index]
+96
View File
@@ -0,0 +1,96 @@
"""
Tax-related DTO models.
Based on PHP library's Model/Tax classes.
"""
from typing import Any
from pydantic import BaseModel, Field
class Tax(BaseModel):
"""
Tax information model.
Maps to PHP Model\\Tax\\Tax.
"""
# Tax model fields would be defined based on API response structure
# Since we don't have the exact PHP model structure, we'll use flexible Dict
data: dict[str, Any] = Field(default_factory=dict, description="Tax data")
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Return the raw data dictionary."""
_ = kwargs
return self.data
class History(BaseModel):
"""
Tax history entry model.
Maps to PHP Model\\Tax\\History.
"""
# History fields would be defined based on API response
data: dict[str, Any] = Field(default_factory=dict, description="History entry data")
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Return the raw data dictionary."""
_ = kwargs
return self.data
class HistoryRecords(BaseModel):
"""
Collection of tax history records.
Maps to PHP Model\\Tax\\HistoryRecords.
"""
records: list[History] = Field(default_factory=list, description="History records")
def __iter__(self) -> Any:
"""Make collection iterable."""
return iter(self.records)
def __len__(self) -> int:
"""Get collection length."""
return len(self.records)
def __getitem__(self, index: int) -> History:
"""Get item by index."""
return self.records[index]
class Payment(BaseModel):
"""
Tax payment model.
Maps to PHP Model\\Tax\\Payment.
"""
# Payment fields would be defined based on API response
data: dict[str, Any] = Field(default_factory=dict, description="Payment data")
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Return the raw data dictionary."""
_ = kwargs
return self.data
class PaymentRecords(BaseModel):
"""
Collection of tax payment records.
Maps to PHP Model\\Tax\\PaymentRecords.
"""
records: list[Payment] = Field(default_factory=list, description="Payment records")
def __iter__(self) -> Any:
"""Make collection iterable."""
return iter(self.records)
def __len__(self) -> int:
"""Get collection length."""
return len(self.records)
def __getitem__(self, index: int) -> Payment:
"""Get item by index."""
return self.records[index]
+122
View File
@@ -0,0 +1,122 @@
"""
User-related DTO models.
Based on PHP library's Model/User classes.
"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field, field_validator
class UserType(BaseModel):
"""
User profile model.
Maps to PHP Model\\User\\UserType.
"""
id: int = Field(..., description="User ID")
last_name: str | None = Field(None, alias="lastName", description="Last name")
display_name: str = Field(..., alias="displayName", description="Display name")
middle_name: str | None = Field(None, alias="middleName", description="Middle name")
email: str | None = Field(None, description="Email address")
phone: str = Field(..., description="Phone number")
inn: str = Field(..., description="INN (tax identification number)")
snils: str | None = Field(None, description="SNILS")
avatar_exists: bool = Field(..., alias="avatarExists", description="Avatar exists")
initial_registration_date: datetime | None = Field(
None, alias="initialRegistrationDate", description="Initial registration date"
)
registration_date: datetime | None = Field(
None, alias="registrationDate", description="Registration date"
)
first_receipt_register_time: datetime | None = Field(
None,
alias="firstReceiptRegisterTime",
description="First receipt registration time",
)
first_receipt_cancel_time: datetime | None = Field(
None,
alias="firstReceiptCancelTime",
description="First receipt cancellation time",
)
hide_cancelled_receipt: bool = Field(
..., alias="hideCancelledReceipt", description="Hide cancelled receipts"
)
register_available: bool | str | None = Field(
None, alias="registerAvailable", description="Register available (mixed type)"
)
status: str | None = Field(None, description="User status")
restricted_mode: bool = Field(
..., alias="restrictedMode", description="Restricted mode"
)
pfr_url: str | None = Field(None, alias="pfrUrl", description="PFR URL")
login: str | None = Field(None, description="Login")
@field_validator(
"initial_registration_date",
"registration_date",
"first_receipt_register_time",
"first_receipt_cancel_time",
mode="before",
)
@classmethod
def parse_datetime(cls, v: Any) -> datetime | None:
"""Parse datetime strings from API."""
if v is None or v == "":
return None
if isinstance(v, str):
try:
return datetime.fromisoformat(v.replace("Z", "+00:00"))
except ValueError:
return None
return v # type: ignore[no-any-return]
def is_avatar_exists(self) -> bool:
"""Check if user has avatar."""
return self.avatar_exists
def is_hide_cancelled_receipt(self) -> bool:
"""Check if cancelled receipts are hidden."""
return self.hide_cancelled_receipt
def is_restricted_mode(self) -> bool:
"""Check if user is in restricted mode."""
return self.restricted_mode
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
"""Custom serialization to match API format."""
_ = kwargs
def serialize_datetime(dt: datetime | None) -> str | None:
if dt is None:
return None
return dt.isoformat()
return {
"id": self.id,
"lastName": self.last_name,
"displayName": self.display_name,
"middleName": self.middle_name,
"email": self.email,
"phone": self.phone,
"inn": self.inn,
"snils": self.snils,
"avatarExists": self.avatar_exists,
"initialRegistrationDate": serialize_datetime(
self.initial_registration_date
),
"registrationDate": serialize_datetime(self.registration_date),
"firstReceiptRegisterTime": serialize_datetime(
self.first_receipt_register_time
),
"firstReceiptCancelTime": serialize_datetime(
self.first_receipt_cancel_time
),
"hideCancelledReceipt": self.hide_cancelled_receipt,
"registerAvailable": self.register_available,
"status": self.status,
"restrictedMode": self.restricted_mode,
"pfrUrl": self.pfr_url,
"login": self.login,
}
+158
View File
@@ -0,0 +1,158 @@
"""
Domain exceptions for Moy Nalog API.
Mirrors PHP library's exception hierarchy and error handling.
"""
import logging
import re
from http import HTTPStatus
import httpx
logger = logging.getLogger(__name__)
class DomainException(Exception): # noqa: N818 для совместимости публичного API
"""Base domain exception for all Moy Nalog API errors."""
def __init__(self, message: str, response: httpx.Response | None = None):
super().__init__(message)
self.response = response
# Log the error with response details (without sensitive data)
if response:
self._log_error_details(message, response)
def _log_error_details(self, message: str, response: httpx.Response) -> None:
"""Log error details while avoiding sensitive information."""
# Mask potential sensitive data in URLs and headers
safe_url = self._mask_sensitive_url(str(response.url))
safe_headers = self._mask_sensitive_headers(dict(response.headers))
logger.error(
"API Error: %s | Status: %d | URL: %s | Headers: %s | Body: %s",
message,
response.status_code,
safe_url,
safe_headers,
self._get_safe_response_body(response),
)
def _mask_sensitive_url(self, url: str) -> str:
"""Mask potential sensitive data in URL."""
# Replace tokens/keys with asterisks
patterns = [
(r"(token=)[^&]*", r"\1***"),
(r"(key=)[^&]*", r"\1***"),
(r"(secret=)[^&]*", r"\1***"),
]
for pattern, replacement in patterns:
url = re.sub(pattern, replacement, url)
return url
def _mask_sensitive_headers(self, headers: dict[str, str]) -> dict[str, str]:
"""Mask sensitive headers."""
safe_headers = headers.copy()
sensitive_keys = ["authorization", "x-api-key", "cookie", "set-cookie"]
for key in sensitive_keys:
if key.lower() in [h.lower() for h in safe_headers]:
# Find the actual key (case-insensitive)
actual_key = next(k for k in safe_headers if k.lower() == key.lower())
safe_headers[actual_key] = "***"
return safe_headers
def _get_safe_response_body(self, response: httpx.Response) -> str:
"""Get response body with potential sensitive data masked."""
try:
body = response.text[:1000] # Limit body size for logging
# Mask potential tokens in JSON responses
patterns = [
(r'("token":\s*")[^"]*(")', r"\1***\2"),
(r'("refreshToken":\s*")[^"]*(")', r"\1***\2"),
(r'("password":\s*")[^"]*(")', r"\1***\2"),
(r'("secret":\s*")[^"]*(")', r"\1***\2"),
]
for pattern, replacement in patterns:
body = re.sub(pattern, replacement, body)
return body
except Exception:
return "[Failed to read response body]"
class ValidationException(DomainException):
"""HTTP 400 - Validation error."""
class UnauthorizedException(DomainException):
"""HTTP 401 - Authentication required or invalid credentials."""
class ForbiddenException(DomainException):
"""HTTP 403 - Access forbidden."""
class NotFoundException(DomainException):
"""HTTP 404 - Resource not found."""
class ClientException(DomainException):
"""HTTP 406 - Client error (e.g., wrong Accept headers)."""
class PhoneException(DomainException):
"""HTTP 422 - Phone-related error (SMS, verification, etc.)."""
class ServerException(DomainException):
"""HTTP 500 - Internal server error."""
class UnknownErrorException(DomainException):
"""Unknown HTTP error code."""
def raise_for_status(response: httpx.Response) -> None:
"""
Raise appropriate domain exception based on HTTP status code.
Maps status codes to exceptions exactly like PHP ErrorHandler:
- 400: ValidationException
- 401: UnauthorizedException
- 403: ForbiddenException
- 404: NotFoundException
- 406: ClientException
- 422: PhoneException
- 500: ServerException
- default: UnknownErrorException
Args:
response: httpx.Response object
Raises:
DomainException: Appropriate exception for status code
"""
if response.status_code < HTTPStatus.BAD_REQUEST:
return
body = response.text
if response.status_code == HTTPStatus.BAD_REQUEST:
raise ValidationException(body, response)
if response.status_code == HTTPStatus.UNAUTHORIZED:
raise UnauthorizedException(body, response)
if response.status_code == HTTPStatus.FORBIDDEN:
raise ForbiddenException(body, response)
if response.status_code == HTTPStatus.NOT_FOUND:
raise NotFoundException(body, response)
if response.status_code == HTTPStatus.NOT_ACCEPTABLE:
raise ClientException("Wrong Accept headers", response)
if response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY:
raise PhoneException(body, response)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
raise ServerException(body, response)
raise UnknownErrorException(body, response)
+239
View File
@@ -0,0 +1,239 @@
"""
Income API implementation.
Based on PHP library's Api\\Income class.
"""
from datetime import datetime, date
from decimal import Decimal
from typing import Any, Optional
from ._http import AsyncHTTPClient
from .dto.income import (
AtomDateTime,
CancelCommentType,
CancelRequest,
IncomeClient,
IncomeRequest,
IncomeServiceItem,
IncomeType,
PaymentType,
)
class IncomeAPI:
"""
Income API for creating and managing receipts.
Provides async methods for:
- Creating income receipts (single or multiple items)
- Cancelling income receipts
Maps to PHP Api\\Income functionality.
"""
def __init__(self, http_client: AsyncHTTPClient):
self.http = http_client
async def create(
self,
name: str,
amount: Decimal | float | int | str,
quantity: Decimal | float | int | str = 1,
operation_time: datetime | None = None,
client: IncomeClient | None = None,
) -> dict[str, Any]:
"""
Create income receipt with single service item.
Maps to PHP Income::create() method.
Args:
name: Service name/description
amount: Service amount (converted to Decimal)
quantity: Service quantity (converted to Decimal, default: 1)
operation_time: Operation datetime (default: now)
client: Client information (default: individual client)
Returns:
Dictionary with response data including approvedReceiptUuid
Raises:
ValidationException: For validation errors
DomainException: For other API errors
"""
# Convert to IncomeServiceItem
service_item = IncomeServiceItem(
name=name,
amount=Decimal(str(amount)),
quantity=Decimal(str(quantity)),
)
return await self.create_multiple_items([service_item], operation_time, client)
async def create_multiple_items(
self,
services: list[IncomeServiceItem],
operation_time: datetime | None = None,
client: IncomeClient | None = None,
) -> dict[str, Any]:
"""
Create income receipt with multiple service items.
Maps to PHP Income::createMultipleItems() method.
Args:
services: List of service items
operation_time: Operation datetime (default: now)
client: Client information (default: individual client)
Returns:
Dictionary with response data including approvedReceiptUuid
Raises:
ValidationException: For validation errors (empty items, invalid amounts, etc.)
DomainException: For other API errors
"""
if not services:
raise ValueError("Services cannot be empty")
# Validate client for legal entity (mirrors PHP validation)
if client and client.income_type == IncomeType.FROM_LEGAL_ENTITY:
if not client.inn:
raise ValueError("Client INN cannot be empty for legal entity")
if not client.display_name:
raise ValueError("Client DisplayName cannot be empty for legal entity")
# Calculate total amount (mirrors PHP BigDecimal logic)
total_amount = sum(item.get_total_amount() for item in services)
# Create request object
request = IncomeRequest(
operation_time=(
AtomDateTime.from_datetime(operation_time)
if operation_time
else AtomDateTime.now()
),
request_time=AtomDateTime.now(),
services=services,
total_amount=str(total_amount),
client=client or IncomeClient(),
payment_type=PaymentType.CASH,
ignore_max_total_income_restriction=False,
)
# Make API request
response = await self.http.post("/income", json_data=request.model_dump())
return response.json() # type: ignore[no-any-return]
async def cancel(
self,
receipt_uuid: str,
comment: CancelCommentType | str,
operation_time: datetime | None = None,
request_time: datetime | None = None,
partner_code: str | None = None,
) -> dict[str, Any]:
"""
Cancel income receipt.
Maps to PHP Income::cancel() method.
Args:
receipt_uuid: Receipt UUID to cancel
comment: Cancellation reason (enum or string)
operation_time: Operation datetime (default: now)
request_time: Request datetime (default: now)
partner_code: Partner code (optional)
Returns:
Dictionary with cancellation response data
Raises:
ValidationException: For validation errors (empty UUID, invalid comment)
DomainException: For other API errors
"""
# Validate receipt UUID
if not receipt_uuid.strip():
raise ValueError("Receipt UUID cannot be empty")
# Convert comment to enum if string
if isinstance(comment, str):
# Try to find matching enum value
comment_enum = None
for enum_val in CancelCommentType:
if enum_val.value == comment:
comment_enum = enum_val
break
if comment_enum is None:
valid_comments = [e.value for e in CancelCommentType]
raise ValueError(
f"Comment is invalid. Must be one of: {valid_comments}"
)
comment = comment_enum
# Create request object
request = CancelRequest(
operation_time=(
AtomDateTime.from_datetime(operation_time)
if operation_time
else AtomDateTime.now()
),
request_time=(
AtomDateTime.from_datetime(request_time)
if request_time
else AtomDateTime.now()
),
comment=comment,
receipt_uuid=receipt_uuid.strip(),
partner_code=partner_code,
)
# Make API request
response = await self.http.post("/cancel", json_data=request.model_dump())
return response.json() # type: ignore[no-any-return]
async def get_list(
self,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
limit: int = 100,
offset: int = 0,
) -> dict[str, Any]:
"""
Get list of income records for a period.
Args:
from_date: Start date (default: 30 days ago)
to_date: End date (default: today)
limit: Maximum number of records (default: 100)
offset: Offset for pagination (default: 0)
Returns:
Dictionary with income records list
Raises:
DomainException: For API errors
"""
from datetime import timedelta
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
# API использует GET с query параметрами
params = {
"from": from_date.isoformat(),
"to": to_date.isoformat(),
"limit": str(limit),
"offset": str(offset),
"sortBy": "OPERATION_TIME",
"sortOrder": "DESC",
}
# Формируем query string
query = "&".join(f"{k}={v}" for k, v in params.items())
response = await self.http.get(f"/incomes?{query}")
return response.json() # type: ignore[no-any-return]
+60
View File
@@ -0,0 +1,60 @@
"""
PaymentType API implementation.
Based on PHP library's Api\\PaymentType class.
"""
from typing import Any
from ._http import AsyncHTTPClient
class PaymentTypeAPI:
"""
PaymentType API for managing payment methods.
Provides async methods for:
- Getting payment types table
- Finding favorite payment type
Maps to PHP Api\\PaymentType functionality.
"""
def __init__(self, http_client: AsyncHTTPClient):
self.http = http_client
async def table(self) -> list[dict[str, Any]]:
"""
Get all available payment types.
Maps to PHP PaymentType::table().
Returns:
List of payment type dictionaries with bank information
Raises:
DomainException: For API errors
"""
response = await self.http.get("/payment-type/table")
return response.json() # type: ignore[no-any-return]
async def favorite(self) -> dict[str, Any] | None:
"""
Get favorite payment type.
Maps to PHP PaymentType::favorite().
Finds the first payment type marked as favorite from the table.
Returns:
Payment type dictionary if favorite found, None otherwise
Raises:
DomainException: For API errors
"""
payment_types = await self.table()
# Find first payment type with favorite=True
for payment_type in payment_types:
if payment_type.get("favorite", False):
return payment_type
return None
+73
View File
@@ -0,0 +1,73 @@
"""
Receipt API implementation.
Based on PHP library's Api\\Receipt class.
"""
from typing import Any
from ._http import AsyncHTTPClient
class ReceiptAPI:
"""
Receipt API for accessing receipt information.
Provides async methods for:
- Getting receipt print URL
- Getting receipt JSON data
Maps to PHP Api\\Receipt functionality.
"""
def __init__(self, http_client: AsyncHTTPClient, base_endpoint: str, user_inn: str):
self.http = http_client
self.base_endpoint = base_endpoint
self.user_inn = user_inn
def print_url(self, receipt_uuid: str) -> str:
"""
Compose receipt print URL.
Maps to PHP Receipt::printUrl() method.
This method composes the URL without making HTTP request.
Args:
receipt_uuid: Receipt UUID
Returns:
Complete URL for receipt printing
Raises:
ValueError: If receipt_uuid is empty
"""
if not receipt_uuid.strip():
raise ValueError("Receipt UUID cannot be empty")
# Compose URL like PHP: sprintf('/receipt/%s/%s/print', $this->profile->getInn(), $receiptUuid)
path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/print"
return f"{self.base_endpoint}{path}"
async def json(self, receipt_uuid: str) -> dict[str, Any]:
"""
Get receipt data in JSON format.
Maps to PHP Receipt::json() method.
Args:
receipt_uuid: Receipt UUID
Returns:
Dictionary with receipt JSON data
Raises:
ValueError: If receipt_uuid is empty
DomainException: For API errors
"""
if not receipt_uuid.strip():
raise ValueError("Receipt UUID cannot be empty")
# Make GET request like PHP: sprintf('/receipt/%s/%s/json', $this->profile->getInn(), $receiptUuid)
path = f"/receipt/{self.user_inn}/{receipt_uuid.strip()}/json"
response = await self.http.get(path)
return response.json() # type: ignore[no-any-return]
+83
View File
@@ -0,0 +1,83 @@
"""
Tax API implementation.
Based on PHP library's Api\\Tax class.
"""
from typing import Any
from ._http import AsyncHTTPClient
class TaxAPI:
"""
Tax API for tax information and history.
Provides async methods for:
- Getting current tax information
- Getting tax history by OKTMO
- Getting payment records
Maps to PHP Api\\Tax functionality.
"""
def __init__(self, http_client: AsyncHTTPClient):
self.http = http_client
async def get(self) -> dict[str, Any]:
"""
Get current tax information.
Maps to PHP Tax::get().
Returns:
Dictionary with current tax data
Raises:
DomainException: For API errors
"""
response = await self.http.get("/taxes")
return response.json() # type: ignore[no-any-return]
async def history(self, oktmo: str | None = None) -> dict[str, Any]:
"""
Get tax history.
Maps to PHP Tax::history().
Args:
oktmo: Optional OKTMO code for filtering
Returns:
Dictionary with tax history records
Raises:
DomainException: For API errors
"""
request_data = {"oktmo": oktmo}
response = await self.http.post("/taxes/history", json_data=request_data)
return response.json() # type: ignore[no-any-return]
async def payments(
self, oktmo: str | None = None, only_paid: bool = False
) -> dict[str, Any]:
"""
Get tax payment records.
Maps to PHP Tax::payments().
Args:
oktmo: Optional OKTMO code for filtering
only_paid: If True, return only paid records
Returns:
Dictionary with payment records
Raises:
DomainException: For API errors
"""
request_data = {
"oktmo": oktmo,
"onlyPaid": only_paid,
}
response = await self.http.post("/taxes/payments", json_data=request_data)
return response.json() # type: ignore[no-any-return]
+40
View File
@@ -0,0 +1,40 @@
"""
User API implementation.
Based on PHP library's Api\\User class.
"""
from typing import Any
from ._http import AsyncHTTPClient
class UserAPI:
"""
User API for user information.
Provides async methods for:
- Getting current user information
Maps to PHP Api\\User functionality.
"""
def __init__(self, http_client: AsyncHTTPClient):
self.http = http_client
async def get(self) -> dict[str, Any]:
"""
Get current user information.
Maps to PHP User::get().
Returns:
Dictionary with user profile data including:
- id, inn, displayName, email, phone
- registration dates, status, restrictions
- avatar, receipt settings, etc.
Raises:
DomainException: For API errors
"""
response = await self.http.get("/user")
return response.json() # type: ignore[no-any-return]
+54 -3
View File
@@ -209,6 +209,20 @@
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 By criteria",
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 By subscriptions",
"ADMIN_MESSAGES_HISTORY": "📋 History",
"ADMIN_PINNED_MESSAGE": "📌 Pinned message",
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Update",
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Remove and disable",
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Send before menu",
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Send after menu",
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Show on every /start",
"ADMIN_PINNED_START_ONCE": "🚫 Show only once",
"ADMIN_PINNED_NO_CONTENT": "❌ Could not read text or media from the message, please try again.",
"ADMIN_PINNED_SAVING": "📌 Message saved. Starting broadcast and pinning for users...",
"ADMIN_PINNED_UPDATED": "✅ <b>Pinned message updated</b>\n\n👥 Recipients: {total}\n✅ Sent: {sent}\n⚠️ Errors: {failed}",
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Message saved!</b>\n\nChoose how to deliver the message to users:\n\n• <b>Broadcast now</b> — will send and pin for all active users\n• <b>Only on /start</b> — users will see it on next bot launch",
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Pinned message saved</b>\n\nNo broadcast performed. Users will see the message on their next /start.",
"ADMIN_PINNED_BROADCAST_NOW": "📨 Broadcast now to all",
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Only on /start",
"ADMIN_MONITORING": "🔍 Monitoring",
"ADMIN_MONITORING_ALL_LOGS": "📋 All logs",
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Auto-pay settings",
@@ -1154,8 +1168,8 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "via {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Bank card ({mulenpay_name})</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via Platega (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card (Platega)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "via {platega_name} (cards + SBP)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Bank card ({platega_name})</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
@@ -1506,6 +1520,7 @@
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ You already have unlimited traffic",
"TRAFFIC_FIXED_MODE": "⚠️ Traffic is fixed in the current mode and cannot be changed",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Traffic top-up feature is disabled",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
"TRAFFIC_NO_CHANGE": "️ Traffic limit was not changed",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
@@ -1605,5 +1620,41 @@
"POLL_COMPLETED": "🙏 Thanks for completing the poll!",
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist"
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
"CONTEST_START_ANNOUNCEMENT": "Game starts",
"CONTEST_PRIZE": "Prize",
"DAYS": "days subscription",
"CONTEST_WINNERS": "Winners",
"CONTEST_ATTEMPTS": "Attempts/user",
"CONTEST_ELIGIBILITY": "Only users with active or trial subscription can participate",
"REMINDER": "Reminder",
"CONTEST_REMINDER_TEXT": "Don't forget to participate in contests to get bonuses",
"MODEM_BUTTON": "📡 Modem",
"MODEM_ENABLE_BUTTON": "✅ Connect modem",
"MODEM_DISABLE_BUTTON": "❌ Disconnect modem",
"MODEM_CONFIRM_BUTTON": "✅ Confirm connection",
"MODEM_STATUS_ENABLED": "✅ Connected",
"MODEM_STATUS_DISABLED": "❌ Not connected",
"MODEM_PAID_ONLY": "⚠️ Modem is only available for paid subscriptions",
"MODEM_DISABLED": "⚠️ Modem feature is disabled",
"MODEM_ALREADY_ENABLED": "️ Modem is already connected",
"MODEM_NOT_ENABLED": "️ Modem is not connected",
"MODEM_INFO_ENABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nModem is connected to your subscription.\nMonthly fee: {price}\n\nNo refunds are provided when disconnecting the modem.",
"MODEM_INFO_DISABLED": "📡 <b>Modem</b>\n\nStatus: {status}\n\nConnect a modem to your subscription.\nMonthly fee: {price}\n\nWhen connecting a modem, an additional device will be added.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Insufficient funds</b>\n\nModem connection cost: {required}\nBalance: {balance}\nMissing: {missing}\n\nChoose a top-up method.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Confirm modem connection</b>\n\nCost: {price} (for {months} months)\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Modem successfully connected!</b>\n\n📡 Modem activated\n📱 Device added for modem\n",
"MODEM_CHARGED": "💰 Charged: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Modem disconnected</b>\n\n📡 Modem deactivated\n️ No refunds are provided",
"MODEM_PERIOD_INFO": "\n📅 Modem valid until: <b>{end_date}</b> ({days} days)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Warning!</b> Only <b>{days} days</b> left until subscription ends.\nAfter renewal, modem will need to be paid again!",
"MODEM_PERIOD_NOTE": "\n️ Days until subscription ends: <b>{days}</b>\nAfter renewal, modem will need to be paid again.",
"MODEM_PRICE_WITH_DISCOUNT": "Cost: <s>{base_price}</s> <b>{final_price}</b> (for {months} months)\n🎁 Discount {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Cost: {price} (for {months} months)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Confirm modem connection</b>\n\n{price_text}\n\nWhen connecting a modem:\n• An additional device will be added to your subscription\n• Monthly fee will increase by {monthly_price}\n\nConfirm connection?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Restrict",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Top-up restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Subscription purchase/renewal restricted</b>\n\n{reason}\n\nIf you believe this is an error, you can appeal the decision.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Appeal"
}
+54 -3
View File
@@ -212,6 +212,20 @@
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 По критериям",
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 По подпискам",
"ADMIN_MESSAGES_HISTORY": "📋 История",
"ADMIN_PINNED_MESSAGE": "📌 Закрепленное сообщение",
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Обновить",
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Удалить и отключить",
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показать перед меню",
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Показать после меню",
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Показать при каждом /start",
"ADMIN_PINNED_START_ONCE": "🚫 Показывать только один раз",
"ADMIN_PINNED_NO_CONTENT": "❌ Не удалось прочитать текст или медиа в сообщении, попробуйте снова.",
"ADMIN_PINNED_SAVING": "📌 Сообщение сохранено. Начинаю отправку и закрепление у пользователей...",
"ADMIN_PINNED_UPDATED": "✅ <b>Закрепленное сообщение обновлено</b>\n\n👥 Получателей: {total}\n✅ Отправлено: {sent}\n⚠️ Ошибок: {failed}",
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Сообщение сохранено!</b>\n\nВыберите, как доставить сообщение пользователям:\n\n• <b>Разослать сейчас</b> — отправит и закрепит у всех активных пользователей\n• <b>Только при /start</b> — пользователи увидят при следующем запуске бота",
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Закрепленное сообщение сохранено</b>\n\nРассылка не выполнена. Пользователи увидят сообщение при следующем вводе /start.",
"ADMIN_PINNED_BROADCAST_NOW": "📨 Разослать сейчас всем",
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Только при /start",
"ADMIN_MONITORING": "🔍 Мониторинг",
"ADMIN_MONITORING_ALL_LOGS": "📋 Все логи",
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Настройки автооплаты",
@@ -1169,8 +1183,8 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банковская карта ({mulenpay_name})</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта (Platega)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через {platega_name} (карты + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банковская карта ({platega_name})</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
@@ -1521,6 +1535,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас уже безлимитный трафик",
"TRAFFIC_FIXED_MODE": "⚠️ В текущем режиме трафик фиксированный и не может быть изменен",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функция докупки трафика отключена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
@@ -1620,5 +1635,41 @@
"POLL_COMPLETED": "🙏 Спасибо за участие в опросе!",
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список"
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
"CONTEST_PRIZE": "Приз",
"DAYS": "дн. подписки",
"CONTEST_WINNERS": "Победителей",
"CONTEST_ATTEMPTS": "Попыток/польз",
"CONTEST_ELIGIBILITY": "Участвовать могут только с активной или триальной подпиской",
"REMINDER": "Напоминание",
"CONTEST_REMINDER_TEXT": "Не забудьте участвовать в конкурсах для получения бонусов",
"MODEM_BUTTON": "📡 Модем",
"MODEM_ENABLE_BUTTON": "✅ Подключить модем",
"MODEM_DISABLE_BUTTON": "❌ Отключить модем",
"MODEM_CONFIRM_BUTTON": "✅ Подтвердить подключение",
"MODEM_STATUS_ENABLED": "✅ Подключен",
"MODEM_STATUS_DISABLED": "❌ Не подключен",
"MODEM_PAID_ONLY": "⚠️ Модем доступен только для платных подписок",
"MODEM_DISABLED": "⚠️ Функция модема отключена",
"MODEM_ALREADY_ENABLED": "ℹ️ Модем уже подключен",
"MODEM_NOT_ENABLED": "ℹ️ Модем не подключен",
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем подключен к вашей подписке.\nЕжемесячная плата: {price}\n\nПри отключении модема возврат средств не производится.",
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПодключите модем к вашей подписке.\nЕжемесячная плата: {price}\n\nПри подключении модема будет добавлено дополнительное устройство.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостаточно средств</b>\n\nСтоимость подключения модема: {required}\nНа балансе: {balance}\nНе хватает: {missing}\n\nВыберите способ пополнения.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Подтверждение подключения модема</b>\n\nСтоимость: {price} (за {months} мес)\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успешно подключен!</b>\n\n📡 Модем активирован\n📱 Добавлено устройство для модема\n",
"MODEM_CHARGED": "💰 Списано: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем отключен</b>\n\n📡 Модем деактивирован\nℹ️ Возврат средств не производится",
"MODEM_PERIOD_INFO": "\n📅 Модем действует до: <b>{end_date}</b> ({days} дн.)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Внимание!</b> До окончания подписки осталось всего <b>{days} дн.</b>\nПосле продления подписки модем нужно будет оплатить заново!",
"MODEM_PERIOD_NOTE": "\nℹ️ До окончания подписки: <b>{days} дн.</b>\nПосле продления модем нужно будет оплатить заново.",
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать"
}
+43 -5
View File
@@ -138,8 +138,22 @@
"ADMIN_MESSAGES_ALL_USERS": "📨 Всім користувачам",
"ADMIN_MESSAGES_BY_CRITERIA": "🔍 За критеріями",
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS": "🎯 За підписками",
"ADMIN_MESSAGES_HISTORY": "📋 Історія",
"ADMIN_MONITORING": "🔍 Моніторинг",
"ADMIN_MESSAGES_HISTORY": "📋 Історія",
"ADMIN_PINNED_MESSAGE": "📌 Закріплене повідомлення",
"ADMIN_PINNED_MESSAGE_UPDATE": "✏️ Оновити",
"ADMIN_PINNED_MESSAGE_DELETE": "🗑️ Видалити та вимкнути",
"ADMIN_PINNED_POSITION_BEFORE": "⬆️ Показати перед меню",
"ADMIN_PINNED_POSITION_AFTER": "⬇️ Показати після меню",
"ADMIN_PINNED_START_EVERY_TIME": "🔁 Показувати при кожному /start",
"ADMIN_PINNED_START_ONCE": "🚫 Показувати лише один раз",
"ADMIN_PINNED_NO_CONTENT": "❌ Не вдалося прочитати текст або медіа у повідомленні, спробуйте ще раз.",
"ADMIN_PINNED_SAVING": "📌 Повідомлення збережено. Починаю відправку та закріплення у користувачів...",
"ADMIN_PINNED_UPDATED": "✅ <b>Закріплене повідомлення оновлено</b>\n\n👥 Отримувачів: {total}\n✅ Надіслано: {sent}\n⚠️ Помилок: {failed}",
"ADMIN_PINNED_SAVED_ASK_BROADCAST": "📌 <b>Повідомлення збережено!</b>\n\nОберіть, як доставити повідомлення користувачам:\n\n• <b>Розіслати зараз</b> — відправить і закріпить у всіх активних користувачів\n• <b>Тільки при /start</b> — користувачі побачать при наступному запуску бота",
"ADMIN_PINNED_SAVED_NO_BROADCAST": "✅ <b>Закріплене повідомлення збережено</b>\n\nРозсилка не виконана. Користувачі побачать повідомлення при наступному введенні /start.",
"ADMIN_PINNED_BROADCAST_NOW": "📨 Розіслати зараз всім",
"ADMIN_PINNED_BROADCAST_ON_START": "⏳ Тільки при /start",
"ADMIN_MONITORING": "🔍 Моніторинг",
"ADMIN_MONITORING_ALL_LOGS": "📋 Всі логи",
"ADMIN_MONITORING_AUTOPAY_SETTINGS": "💳 Налаштування автооплати",
"ADMIN_MONITORING_AUTO_CLEANUP": "🧹 Автоочищення логів",
@@ -1095,8 +1109,8 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION": "через {mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME": "💳 <b>Банківська картка ({mulenpay_name})</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через Platega (картки + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка (Platega)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION": "через {platega_name} (картки + СБП)",
"PAYMENT_METHOD_PLATEGA_NAME": "💳 <b>Банківська картка ({platega_name})</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION": "швидко та зручно",
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "інші способи",
@@ -1440,6 +1454,7 @@
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED": "⚠ У вас вже безлімітний трафік",
"TRAFFIC_FIXED_MODE": "⚠️ У поточному режимі трафік фіксований і не може бути змінений",
"TRAFFIC_TOPUP_DISABLED": "⚠️ Функція докупки трафіку вимкнена",
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостатньо коштів!\nПотрібно: {required} (за {months} міс)\nУ вас: {balance}",
"TRAFFIC_NO_CHANGE": "ℹ️ Ліміт трафіку не змінився",
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакети трафіку не налаштовані",
@@ -1537,5 +1552,28 @@
"POLL_EMPTY": "Опитування поки недоступне.",
"POLL_ERROR": "Не вдалося обробити опитування. Спробуйте пізніше.",
"POLL_COMPLETED": "🙏 Дякуємо за участь в опитуванні!",
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс."
"POLL_REWARD_GRANTED": "Нагороду {amount} зараховано на ваш баланс.",
"MODEM_BUTTON": "📡 Модем",
"MODEM_ENABLE_BUTTON": "✅ Підключити модем",
"MODEM_DISABLE_BUTTON": "❌ Відключити модем",
"MODEM_CONFIRM_BUTTON": "✅ Підтвердити підключення",
"MODEM_STATUS_ENABLED": "✅ Підключено",
"MODEM_STATUS_DISABLED": "❌ Не підключено",
"MODEM_PAID_ONLY": "⚠️ Модем доступний тільки для платних підписок",
"MODEM_DISABLED": "⚠️ Функція модема вимкнена",
"MODEM_ALREADY_ENABLED": "ℹ️ Модем вже підключено",
"MODEM_NOT_ENABLED": "ℹ️ Модем не підключено",
"MODEM_INFO_ENABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nМодем підключено до вашої підписки.\nЩомісячна плата: {price}\n\nПри відключенні модема повернення коштів не здійснюється.",
"MODEM_INFO_DISABLED": "📡 <b>Модем</b>\n\nСтатус: {status}\n\nПідключіть модем до вашої підписки.\nЩомісячна плата: {price}\n\nПри підключенні модема буде додано додатковий пристрій.",
"MODEM_INSUFFICIENT_FUNDS": "⚠️ <b>Недостатньо коштів</b>\n\nВартість підключення модема: {required}\nНа балансі: {balance}\nНе вистачає: {missing}\n\nОберіть спосіб поповнення.",
"MODEM_CONFIRM_ENABLE": "📡 <b>Підтвердження підключення модема</b>\n\nВартість: {price} (за {months} міс)\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
"MODEM_ENABLED_SUCCESS": "✅ <b>Модем успішно підключено!</b>\n\n📡 Модем активовано\n📱 Додано пристрій для модема\n",
"MODEM_CHARGED": "💰 Списано: {amount}",
"MODEM_DISABLED_SUCCESS": "✅ <b>Модем відключено</b>\n\n📡 Модем деактивовано\nℹ️ Повернення коштів не здійснюється",
"MODEM_PERIOD_INFO": "\n📅 Модем діє до: <b>{end_date}</b> ({days} дн.)",
"MODEM_SHORT_PERIOD_WARNING": "\n⚠️ <b>Увага!</b> До закінчення підписки залишилось лише <b>{days} дн.</b>\nПісля продовження підписки модем потрібно буде оплатити знову!",
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
}
+42 -3
View File
@@ -138,6 +138,20 @@
"ADMIN_MESSAGES_BY_CRITERIA":"🔍按条件",
"ADMIN_MESSAGES_BY_SUBSCRIPTIONS":"🎯按订阅",
"ADMIN_MESSAGES_HISTORY":"📋历史记录",
"ADMIN_PINNED_MESSAGE":"📌置顶消息",
"ADMIN_PINNED_MESSAGE_UPDATE":"✏️更新",
"ADMIN_PINNED_MESSAGE_DELETE":"🗑️删除并停用",
"ADMIN_PINNED_POSITION_BEFORE":"⬆️菜单前发送",
"ADMIN_PINNED_POSITION_AFTER":"⬇️菜单后发送",
"ADMIN_PINNED_START_EVERY_TIME":"🔁 每次 /start 时发送",
"ADMIN_PINNED_START_ONCE":"🚫 仅发送一次",
"ADMIN_PINNED_NO_CONTENT":"❌ 无法读取消息中的文本或媒体,请重试。",
"ADMIN_PINNED_SAVING":"📌 消息已保存。开始向用户发送并置顶...",
"ADMIN_PINNED_UPDATED":"✅ <b>置顶消息已更新</b>\n\n👥 收件人: {total}\n✅ 已发送: {sent}\n⚠️ 错误: {failed}",
"ADMIN_PINNED_SAVED_ASK_BROADCAST":"📌 <b>消息已保存!</b>\n\n选择如何向用户发送消息:\n\n• <b>立即广播</b> — 将发送并置顶给所有活跃用户\n• <b>仅在 /start 时</b> — 用户将在下次启动机器人时看到",
"ADMIN_PINNED_SAVED_NO_BROADCAST":"✅ <b>置顶消息已保存</b>\n\n未执行广播。用户将在下次输入 /start 时看到消息。",
"ADMIN_PINNED_BROADCAST_NOW":"📨 立即广播给所有人",
"ADMIN_PINNED_BROADCAST_ON_START":"⏳ 仅在 /start 时",
"ADMIN_MONITORING":"🔍监控",
"ADMIN_MONITORING_ALL_LOGS":"📋所有日志",
"ADMIN_MONITORING_AUTOPAY_SETTINGS":"💳自动支付设置",
@@ -1093,8 +1107,8 @@
"PAYMENT_METHOD_CRYPTOBOT_NAME":"🪙<b>加密货币</b>",
"PAYMENT_METHOD_MULENPAY_DESCRIPTION":"通过{mulenpay_name}",
"PAYMENT_METHOD_MULENPAY_NAME":"💳<b>银行卡({mulenpay_name})</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过Platega(银行卡+SBP)",
"PAYMENT_METHOD_PLATEGA_NAME":"💳<b>银行卡(Platega)</b>",
"PAYMENT_METHOD_PLATEGA_DESCRIPTION":"通过{platega_name}(银行卡+SBP)",
"PAYMENT_METHOD_PLATEGA_NAME":"💳<b>银行卡({platega_name})</b>",
"PAYMENT_METHOD_STARS_DESCRIPTION":"快速便捷",
"PAYMENT_METHOD_STARS_NAME":"⭐<b>TelegramStars</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION":"其他方式",
@@ -1438,6 +1452,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1765,6 +1780,7 @@
"TRAFFIC_5GB":"📊5GB-{settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
"TRAFFIC_ALREADY_UNLIMITED":"⚠您已拥有无限流量",
"TRAFFIC_FIXED_MODE":"⚠️当前模式下流量是固定的,无法更改",
"TRAFFIC_TOPUP_DISABLED":"⚠️流量购买功能已禁用",
"TRAFFIC_INSUFFICIENT_BALANCE":"⚠️资金不足!\n需要:{required}(共{months}个月)\n您有:{balance}",
"TRAFFIC_NO_CHANGE":"️流量限制未更改",
"TRAFFIC_PACKAGES_NOT_CONFIGURED":"⚠️未配置流量包",
@@ -1864,5 +1880,28 @@
"POLL_COMPLETED":"🙏感谢您参与民意调查!",
"POLL_REWARD_GRANTED":"奖励{amount}已存入您的余额。",
"DEVICE_GUIDE_WINDOWS":"💻Windows",
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前"
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO":"🕐活跃:很久以前",
"MODEM_BUTTON":"📡 调制解调器",
"MODEM_ENABLE_BUTTON":"✅ 连接调制解调器",
"MODEM_DISABLE_BUTTON":"❌ 断开调制解调器",
"MODEM_CONFIRM_BUTTON":"✅ 确认连接",
"MODEM_STATUS_ENABLED":"✅ 已连接",
"MODEM_STATUS_DISABLED":"❌ 未连接",
"MODEM_PAID_ONLY":"⚠️ 调制解调器仅适用于付费订阅",
"MODEM_DISABLED":"⚠️ 调制解调器功能已禁用",
"MODEM_ALREADY_ENABLED":"️ 调制解调器已连接",
"MODEM_NOT_ENABLED":"️ 调制解调器未连接",
"MODEM_INFO_ENABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n调制解调器已连接到您的订阅。\n月费:{price}\n\n断开调制解调器时不提供退款。",
"MODEM_INFO_DISABLED":"📡 <b>调制解调器</b>\n\n状态:{status}\n\n将调制解调器连接到您的订阅。\n月费:{price}\n\n连接调制解调器时将添加额外设备。",
"MODEM_INSUFFICIENT_FUNDS":"⚠️ <b>余额不足</b>\n\n调制解调器连接费用:{required}\n余额:{balance}\n差额:{missing}\n\n请选择充值方式。",
"MODEM_CONFIRM_ENABLE":"📡 <b>确认连接调制解调器</b>\n\n费用:{price}{months}个月)\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
"MODEM_ENABLED_SUCCESS":"✅ <b>调制解调器连接成功!</b>\n\n📡 调制解调器已激活\n📱 已添加调制解调器设备\n",
"MODEM_CHARGED":"💰 已扣款:{amount}",
"MODEM_DISABLED_SUCCESS":"✅ <b>调制解调器已断开</b>\n\n📡 调制解调器已停用\nℹ️ 不提供退款",
"MODEM_PERIOD_INFO":"\n📅 调制解调器有效期至:<b>{end_date}</b>{days}天)",
"MODEM_SHORT_PERIOD_WARNING":"\n⚠️ <b>注意!</b>距离订阅结束仅剩<b>{days}天</b>。\n续订后需重新支付调制解调器费用!",
"MODEM_PERIOD_NOTE":"\n️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>{months}个月)\n🎁 折扣{discount}%-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}{months}个月)",
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
}
+19 -9
View File
@@ -108,8 +108,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
elif member.status in self.BAD_MEMBER_STATUS:
logger.info(f"❌ Пользователь {telegram_id} не подписан на канал (статус: {member.status})")
if telegram_id and settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
await self._deactivate_trial_subscription(telegram_id)
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._deactivate_subscription_on_unsubscribe(telegram_id)
await self._capture_start_payload(state, event, bot)
@@ -253,8 +253,8 @@ class ChannelCheckerMiddleware(BaseMiddleware):
finally:
break
async def _deactivate_trial_subscription(self, telegram_id: int) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
async def _deactivate_subscription_on_unsubscribe(self, telegram_id: int) -> None:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug(
"ℹ️ Пропускаем деактивацию подписки пользователя %s: отключение при отписке выключено",
telegram_id,
@@ -272,19 +272,29 @@ class ChannelCheckerMiddleware(BaseMiddleware):
break
subscription = user.subscription
if (not subscription.is_trial or
subscription.status != SubscriptionStatus.ACTIVE.value):
if subscription.status != SubscriptionStatus.ACTIVE.value:
logger.debug(
"ℹ️ Подписка пользователя %s не требует деактивации (trial=%s, status=%s)",
"ℹ️ Подписка пользователя %s не активна (status=%s) — пропускаем деактивацию",
telegram_id,
subscription.is_trial,
subscription.status,
)
break
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
logger.debug(
"ℹ️ Подписка пользователя %s платная, CHANNEL_REQUIRED_FOR_ALL=False — пропускаем деактивацию",
telegram_id,
)
break
await deactivate_subscription(db, subscription)
sub_type = "Триальная" if subscription.is_trial else "Платная"
logger.info(
"🚫 Триальная подписка пользователя %s отключена после отписки от канала",
"🚫 %s подписка пользователя %s отключена после отписки от канала",
sub_type,
telegram_id,
)
@@ -1510,6 +1510,7 @@ class AdminNotificationService:
except Exception:
runtime_enabled = True
if not (self._is_enabled() and runtime_enabled):
logger.info(f"Ticket notification skipped: _is_enabled={self._is_enabled()}, runtime_enabled={runtime_enabled}")
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
+52 -5
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import aiofiles
import pyzipper
from aiogram.types import FSInputFile
from sqlalchemy import inspect, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -1534,13 +1535,24 @@ class BackupService:
if not chat_id:
return
password = settings.get_backup_archive_password()
file_to_send = file_path
temp_zip_path = None
if password:
temp_zip_path = await self._create_password_protected_archive(file_path, password)
if temp_zip_path:
file_to_send = temp_zip_path
caption = f"📦 <b>Резервная копия</b>\n\n"
if temp_zip_path:
caption += f"🔐 <b>Архив защищён паролем</b>\n\n"
caption += f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
send_kwargs = {
'chat_id': chat_id,
'document': FSInputFile(file_path),
'caption': (
f"📦 <b>Резервная копия</b>\n\n"
f"⏰ <i>{datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</i>"
),
'document': FSInputFile(file_to_send),
'caption': caption,
'parse_mode': 'HTML'
}
@@ -1549,8 +1561,43 @@ class BackupService:
await self.bot.send_document(**send_kwargs)
logger.info(f"Бекап отправлен в чат {chat_id}")
if temp_zip_path and Path(temp_zip_path).exists():
try:
Path(temp_zip_path).unlink()
except Exception as cleanup_error:
logger.warning(f"Не удалось удалить временный архив: {cleanup_error}")
except Exception as e:
logger.error(f"Ошибка отправки бекапа в чат: {e}")
async def _create_password_protected_archive(self, file_path: str, password: str) -> Optional[str]:
try:
source_path = Path(file_path)
if not source_path.exists():
logger.error(f"Исходный файл бекапа не найден: {file_path}")
return None
zip_filename = source_path.stem + ".zip"
zip_path = source_path.parent / zip_filename
def create_zip():
with pyzipper.AESZipFile(
zip_path,
'w',
compression=pyzipper.ZIP_DEFLATED,
encryption=pyzipper.WZ_AES
) as zf:
zf.setpassword(password.encode('utf-8'))
zf.write(source_path, arcname=source_path.name)
await asyncio.to_thread(create_zip)
logger.info(f"Создан защищённый паролем архив: {zip_path}")
return str(zip_path)
except Exception as e:
logger.error(f"Ошибка создания защищённого архива: {e}")
return None
backup_service = BackupService()
+6 -2
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import record_campaign_registration
from app.database.crud.subscription import (
create_paid_subscription,
create_trial_subscription,
get_subscription_by_user_id,
)
from app.database.crud.user import add_user_balance
@@ -139,7 +139,9 @@ class AdvertisingCampaignService:
error,
)
new_subscription = await create_paid_subscription(
squad_uuid = squads[0] if squads else None
new_subscription = await create_trial_subscription(
db=db,
user_id=user.id,
duration_days=duration_days,
@@ -147,6 +149,8 @@ class AdvertisingCampaignService:
device_limit=device_limit,
connected_squads=squads,
update_server_counters=True,
is_trial=True,
squad_uuid=squad_uuid,
)
try:
+331
View File
@@ -0,0 +1,331 @@
"""High level service for interacting with CloudPayments API."""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
from typing import Any, Dict, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class CloudPaymentsAPIError(RuntimeError):
"""Raised when the CloudPayments API returns an error response."""
def __init__(self, message: str, reason_code: Optional[int] = None):
super().__init__(message)
self.reason_code = reason_code
class CloudPaymentsService:
"""Wrapper around the CloudPayments REST API for balance top-ups."""
def __init__(
self,
*,
public_id: Optional[str] = None,
api_secret: Optional[str] = None,
api_url: Optional[str] = None,
) -> None:
self.public_id = public_id or settings.CLOUDPAYMENTS_PUBLIC_ID
self.api_secret = api_secret or settings.CLOUDPAYMENTS_API_SECRET
self.api_url = (api_url or settings.CLOUDPAYMENTS_API_URL).rstrip("/")
@property
def is_configured(self) -> bool:
return bool(
settings.is_cloudpayments_enabled()
and self.public_id
and self.api_secret
)
def _get_auth_header(self) -> str:
"""Generate Basic Auth header for CloudPayments API."""
if not self.public_id or not self.api_secret:
raise CloudPaymentsAPIError("CloudPayments credentials not configured")
credentials = f"{self.public_id}:{self.api_secret}"
encoded = base64.b64encode(credentials.encode()).decode()
return f"Basic {encoded}"
def _build_headers(self) -> Dict[str, str]:
return {
"Authorization": self._get_auth_header(),
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
*,
json: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Make a request to CloudPayments API."""
if not self.is_configured:
raise CloudPaymentsAPIError("CloudPayments service is not configured")
url = f"{self.api_url}/{path.lstrip('/')}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.request(
method,
url,
json=json,
headers=self._build_headers(),
)
data = response.json()
if response.status_code >= 400:
logger.error(
"CloudPayments API error %s: %s", response.status_code, data
)
raise CloudPaymentsAPIError(
f"CloudPayments API returned status {response.status_code}"
)
return data
except httpx.RequestError as error:
logger.error("Error communicating with CloudPayments API: %s", error)
raise CloudPaymentsAPIError(
"Failed to communicate with CloudPayments API"
) from error
@staticmethod
def _amount_from_kopeks(amount_kopeks: int) -> float:
"""Convert kopeks to rubles."""
return amount_kopeks / 100
@staticmethod
def _amount_to_kopeks(amount: float) -> int:
"""Convert rubles to kopeks."""
return int(amount * 100)
async def generate_payment_link(
self,
telegram_id: int,
amount_kopeks: int,
invoice_id: str,
description: Optional[str] = None,
email: Optional[str] = None,
success_redirect_url: Optional[str] = None,
fail_redirect_url: Optional[str] = None,
) -> str:
"""
Create a payment order via CloudPayments API and return payment URL.
Args:
telegram_id: User's Telegram ID (will be used as AccountId)
amount_kopeks: Amount in kopeks
invoice_id: Unique invoice ID for this payment
description: Payment description
email: User's email (optional)
success_redirect_url: Redirect URL after successful payment
fail_redirect_url: Redirect URL after failed payment
Returns:
URL to CloudPayments payment page
"""
if not self.is_configured:
raise CloudPaymentsAPIError("CloudPayments is not configured")
amount = self._amount_from_kopeks(amount_kopeks)
# Формируем данные для создания заказа через API /orders/create
payload: Dict[str, Any] = {
"Amount": amount,
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
"AccountId": str(telegram_id),
"InvoiceId": invoice_id,
"JsonData": {
"telegram_id": telegram_id,
"invoice_id": invoice_id,
},
}
if email:
payload["Email"] = email
if settings.CLOUDPAYMENTS_REQUIRE_EMAIL:
payload["RequireConfirmation"] = False
# URL для редиректа после оплаты
if success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL:
payload["SuccessRedirectUrl"] = success_redirect_url or settings.CLOUDPAYMENTS_RETURN_URL
if fail_redirect_url:
payload["FailRedirectUrl"] = fail_redirect_url
# Создаём заказ через API
response = await self._request("POST", "/orders/create", json=payload)
if not response.get("Success"):
error_message = response.get("Message", "Unknown error")
logger.error("CloudPayments orders/create failed: %s", error_message)
raise CloudPaymentsAPIError(f"Failed to create order: {error_message}")
model = response.get("Model", {})
payment_url = model.get("Url")
if not payment_url:
logger.error("CloudPayments orders/create returned no URL: %s", response)
raise CloudPaymentsAPIError("CloudPayments API returned no payment URL")
logger.info(
"CloudPayments order created: id=%s, url=%s",
model.get("Id"),
payment_url,
)
return payment_url
def generate_invoice_id(self, telegram_id: int) -> str:
"""Generate unique invoice ID for a payment."""
return f"cp_{telegram_id}_{int(time.time())}"
async def charge_by_token(
self,
token: str,
amount_kopeks: int,
account_id: str,
invoice_id: str,
description: Optional[str] = None,
) -> Dict[str, Any]:
"""
Charge a payment using saved card token (recurrent payment).
Args:
token: Card token from previous payment
amount_kopeks: Amount in kopeks
account_id: User's account ID (telegram_id)
invoice_id: Unique invoice ID
description: Payment description
Returns:
CloudPayments API response
"""
amount = self._amount_from_kopeks(amount_kopeks)
payload = {
"Amount": amount,
"Currency": settings.CLOUDPAYMENTS_CURRENCY,
"AccountId": account_id,
"Token": token,
"InvoiceId": invoice_id,
"Description": description or settings.CLOUDPAYMENTS_DESCRIPTION,
}
return await self._request("POST", "/payments/tokens/charge", json=payload)
async def get_payment(self, transaction_id: int) -> Dict[str, Any]:
"""Get payment details by CloudPayments transaction ID."""
return await self._request(
"POST",
"/payments/get",
json={"TransactionId": transaction_id},
)
async def find_payment(self, invoice_id: str) -> Dict[str, Any]:
"""Find payment by invoice ID."""
return await self._request(
"POST",
"/payments/find",
json={"InvoiceId": invoice_id},
)
async def refund_payment(
self,
transaction_id: int,
amount_kopeks: Optional[int] = None,
) -> Dict[str, Any]:
"""
Refund a payment (full or partial).
Args:
transaction_id: CloudPayments transaction ID
amount_kopeks: Amount to refund in kopeks (None for full refund)
Returns:
CloudPayments API response
"""
payload: Dict[str, Any] = {"TransactionId": transaction_id}
if amount_kopeks is not None:
payload["Amount"] = self._amount_from_kopeks(amount_kopeks)
return await self._request("POST", "/payments/refund", json=payload)
async def void_payment(self, transaction_id: int) -> Dict[str, Any]:
"""Cancel an authorized but not captured payment."""
return await self._request(
"POST",
"/payments/void",
json={"TransactionId": transaction_id},
)
@staticmethod
def verify_webhook_signature(body: bytes, signature: str, api_secret: str) -> bool:
"""
Verify CloudPayments webhook signature.
Args:
body: Raw request body bytes
signature: Signature from X-Content-HMAC header
api_secret: CloudPayments API secret
Returns:
True if signature is valid
"""
if not signature or not api_secret:
return False
calculated = base64.b64encode(
hmac.new(
api_secret.encode(),
body,
hashlib.sha256,
).digest()
).decode()
return hmac.compare_digest(calculated, signature)
@staticmethod
def parse_webhook_data(form_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse webhook form data into structured format.
Args:
form_data: Form data from webhook request
Returns:
Parsed payment data
"""
return {
"transaction_id": int(form_data.get("TransactionId", 0)),
"amount": float(form_data.get("Amount", 0)),
"currency": form_data.get("Currency", "RUB"),
"invoice_id": form_data.get("InvoiceId", ""),
"account_id": form_data.get("AccountId", ""),
"token": form_data.get("Token"),
"card_first_six": form_data.get("CardFirstSix"),
"card_last_four": form_data.get("CardLastFour"),
"card_type": form_data.get("CardType"),
"card_exp_date": form_data.get("CardExpDate"),
"email": form_data.get("Email"),
"status": form_data.get("Status", ""),
"test_mode": form_data.get("TestMode") == "1" or form_data.get("TestMode") == "True",
"reason": form_data.get("Reason"),
"reason_code": int(form_data.get("ReasonCode", 0)) if form_data.get("ReasonCode") else None,
"card_holder_message": form_data.get("CardHolderMessage"),
"data": form_data.get("Data"), # JSON string with custom data
}
+51 -52
View File
@@ -1,6 +1,5 @@
import asyncio
import logging
import random
from datetime import datetime, timedelta, time, timezone
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo
@@ -18,17 +17,19 @@ from app.database.crud.contest import (
)
from app.database.database import AsyncSessionLocal
from app.database.models import ContestTemplate, SubscriptionStatus, User
from app.services.contests.enums import GameType, PrizeType, RoundStatus
from app.services.contests.games import get_game_strategy
logger = logging.getLogger(__name__)
# Slugs for games
GAME_QUEST = "quest_buttons"
GAME_LOCKS = "lock_hack"
GAME_CIPHER = "letter_cipher"
GAME_SERVER = "server_lottery"
GAME_BLITZ = "blitz_reaction"
GAME_EMOJI = "emoji_guess"
GAME_ANAGRAM = "anagram"
# Legacy aliases for backward compatibility
GAME_QUEST = GameType.QUEST_BUTTONS.value
GAME_LOCKS = GameType.LOCK_HACK.value
GAME_CIPHER = GameType.LETTER_CIPHER.value
GAME_SERVER = GameType.SERVER_LOTTERY.value
GAME_BLITZ = GameType.BLITZ_REACTION.value
GAME_EMOJI = GameType.EMOJI_GUESS.value
GAME_ANAGRAM = GameType.ANAGRAM.value
DEFAULT_TEMPLATES = [
@@ -36,7 +37,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_QUEST,
"name": "Квест-кнопки",
"description": "Найди секретную кнопку 3×3",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 3,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -48,7 +50,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_LOCKS,
"name": "Кнопочный взлом",
"description": "Найди взломанную кнопку среди 20 замков",
"prize_days": 5,
"prize_type": "days",
"prize_value": "5",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -60,7 +63,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_CIPHER,
"name": "Шифр букв",
"description": "Расшифруй слово по номерам",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -72,7 +76,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_SERVER,
"name": "Сервер-лотерея",
"description": "Угадай доступный сервер",
"prize_days": 7,
"prize_type": "days",
"prize_value": "7",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -84,7 +89,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_BLITZ,
"name": "Блиц-реакция",
"description": "Нажми кнопку за 10 секунд",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 2,
@@ -96,7 +102,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_EMOJI,
"name": "Угадай сервис по эмодзи",
"description": "Определи сервис по эмодзи",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -108,7 +115,8 @@ DEFAULT_TEMPLATES = [
"slug": GAME_ANAGRAM,
"name": "Анаграмма дня",
"description": "Собери слово из букв",
"prize_days": 1,
"prize_type": "days",
"prize_value": "1",
"max_winners": 1,
"attempts_per_user": 1,
"times_per_day": 1,
@@ -239,38 +247,12 @@ class ContestRotationService:
return ZoneInfo("UTC")
def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict:
payload = tpl.payload or {}
if tpl.slug == GAME_QUEST:
rows = payload.get("rows", 3)
cols = payload.get("cols", 3)
total = rows * cols
secret_idx = random.randint(0, total - 1)
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
if tpl.slug == GAME_LOCKS:
total = payload.get("buttons", 20)
secret_idx = random.randint(0, max(0, total - 1))
return {"total": total, "secret_idx": secret_idx}
if tpl.slug == GAME_CIPHER:
words = payload.get("words") or ["VPN"]
word = random.choice(words)
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
return {"question": "-".join(codes), "answer": word.upper()}
if tpl.slug == GAME_SERVER:
flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]
secret_idx = random.randint(0, len(flags) - 1)
return {"flags": flags, "secret_idx": secret_idx}
if tpl.slug == GAME_BLITZ:
return {"timeout_seconds": payload.get("timeout_seconds", 10)}
if tpl.slug == GAME_EMOJI:
pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
pair = random.choice(pairs)
return pair
if tpl.slug == GAME_ANAGRAM:
words = payload.get("words") or ["SERVER"]
word = random.choice(words).upper()
shuffled = "".join(random.sample(word, len(word)))
return {"letters": shuffled, "answer": word}
return payload
"""Build round-specific payload using game strategy."""
strategy = get_game_strategy(tpl.slug)
if strategy:
return strategy.build_payload(tpl.payload or {})
# Fallback for unknown game types
return tpl.payload or {}
async def _announce_round_start(
self,
@@ -281,11 +263,28 @@ class ContestRotationService:
if not self.bot:
return
from app.localization.texts import get_texts
texts = get_texts("ru") # Default to ru for announcements
# Format prize display based on prize_type
prize_type = tpl.prize_type or PrizeType.DAYS.value
prize_value = tpl.prize_value or "1"
if prize_type == PrizeType.DAYS.value:
prize_display = f"{prize_value} {texts.t('DAYS', 'дн. подписки')}"
elif prize_type == PrizeType.BALANCE.value:
prize_display = f"{prize_value} коп."
elif prize_type == PrizeType.CUSTOM.value:
prize_display = prize_value
else:
prize_display = prize_value
text = (
f"🎲 Стартует игра: <b>{tpl.name}</b>\n"
f"Приз: {tpl.prize_days} дн. подписки • Победителей: {tpl.max_winners}\n"
f"Попыток/польз: {tpl.attempts_per_user}\n\n"
"Участвовать могут только с активной или триальной подпиской."
f"🎲 {texts.t('CONTEST_START_ANNOUNCEMENT', 'Стартует игра')}: <b>{tpl.name}</b>\n"
f"{texts.t('CONTEST_PRIZE', 'Приз')}: {prize_display} {texts.t('CONTEST_WINNERS', 'Победителей')}: {tpl.max_winners}\n"
f"{texts.t('CONTEST_ATTEMPTS', 'Попыток/польз')}: {tpl.attempts_per_user}\n\n"
f"{texts.t('CONTEST_ELIGIBILITY', 'Участвовать могут только с активной или триальной подпиской')}.\n"
f"💡 <b>{texts.t('REMINDER', 'Напоминание')}:</b> {texts.t('CONTEST_REMINDER_TEXT', 'Не забудьте участвовать в конкурсах для получения бонусов')}!"
)
await asyncio.gather(
+14
View File
@@ -0,0 +1,14 @@
"""Contest services module."""
from app.services.contests.enums import GameType, RoundStatus, PrizeType
from app.services.contests.games import get_game_strategy, BaseGameStrategy
from app.services.contests.attempt_service import ContestAttemptService
__all__ = [
"GameType",
"RoundStatus",
"PrizeType",
"get_game_strategy",
"BaseGameStrategy",
"ContestAttemptService",
]
+316
View File
@@ -0,0 +1,316 @@
"""Service for atomic contest attempt operations."""
import logging
from dataclasses import dataclass
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.contest import create_attempt, get_attempt, update_attempt
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
from app.database.crud.user import get_user_by_id
from app.database.models import ContestAttempt, ContestRound, ContestTemplate
from app.services.contests.enums import PrizeType
from app.services.contests.games import get_game_strategy
logger = logging.getLogger(__name__)
@dataclass
class AttemptResult:
"""Result of processing a contest attempt."""
success: bool
is_winner: bool
message: str
already_played: bool = False
round_finished: bool = False
class ContestAttemptService:
"""Service for processing contest attempts with atomic operations."""
async def process_button_attempt(
self,
db: AsyncSession,
round_obj: ContestRound,
user_id: int,
pick: str,
language: str,
) -> AttemptResult:
"""
Process a button-based game attempt atomically.
Args:
db: Database session
round_obj: Contest round
user_id: User ID
pick: User's pick (button callback data)
language: User's language
Returns:
AttemptResult with outcome details
"""
tpl = round_obj.template
if not tpl:
return AttemptResult(
success=False,
is_winner=False,
message="Конкурс не найден",
)
# Check if user already played
existing_attempt = await get_attempt(db, round_obj.id, user_id)
if existing_attempt:
return AttemptResult(
success=False,
is_winner=False,
message="У вас уже была попытка",
already_played=True,
)
# Get game strategy and check answer
strategy = get_game_strategy(tpl.slug)
if not strategy:
return AttemptResult(
success=False,
is_winner=False,
message="Тип игры не поддерживается",
)
check_result = strategy.check_answer(pick, round_obj.payload or {}, language)
is_winner = check_result.is_correct
# Atomic winner check with row lock
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
# Create attempt record
await create_attempt(
db,
round_id=round_obj.id,
user_id=user_id,
answer=str(pick),
is_winner=is_winner,
)
logger.info(
"Contest attempt: user %s, round %s, pick '%s', winner %s",
user_id, round_obj.id, pick, is_winner
)
if is_winner:
prize_msg = await self._award_prize(db, user_id, tpl, language)
return AttemptResult(
success=True,
is_winner=True,
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
)
return AttemptResult(
success=True,
is_winner=False,
message=check_result.response_text or "Неудача",
)
async def process_text_attempt(
self,
db: AsyncSession,
round_obj: ContestRound,
user_id: int,
text_answer: str,
language: str,
) -> AttemptResult:
"""
Process a text-input game attempt atomically.
Args:
db: Database session
round_obj: Contest round
user_id: User ID
text_answer: User's text answer
language: User's language
Returns:
AttemptResult with outcome details
"""
tpl = round_obj.template
if not tpl:
return AttemptResult(
success=False,
is_winner=False,
message="Конкурс не найден",
)
# For text games, attempt should already exist (created in render phase)
attempt = await get_attempt(db, round_obj.id, user_id)
if not attempt:
return AttemptResult(
success=False,
is_winner=False,
message="Сначала начните игру",
)
# Check if already answered
if attempt.answer is not None:
return AttemptResult(
success=False,
is_winner=False,
message="У вас уже была попытка",
already_played=True,
)
# Get game strategy and check answer
strategy = get_game_strategy(tpl.slug)
if not strategy:
return AttemptResult(
success=False,
is_winner=False,
message="Тип игры не поддерживается",
)
check_result = strategy.check_answer(text_answer, round_obj.payload or {}, language)
is_winner = check_result.is_correct
# Atomic winner check with row lock
is_winner = await self._atomic_winner_check(db, round_obj.id, is_winner)
# Update attempt with answer
await update_attempt(db, attempt, answer=text_answer.strip().upper(), is_winner=is_winner)
logger.info(
"Contest text attempt: user %s, round %s, answer '%s', winner %s",
user_id, round_obj.id, text_answer, is_winner
)
if is_winner:
prize_msg = await self._award_prize(db, user_id, tpl, language)
return AttemptResult(
success=True,
is_winner=True,
message=f"🎉 Победа! {prize_msg}" if prize_msg else "🎉 Победа!",
)
return AttemptResult(
success=True,
is_winner=False,
message=check_result.response_text or "Неверно, попробуй в следующем раунде",
)
async def create_pending_attempt(
self,
db: AsyncSession,
round_id: int,
user_id: int,
) -> Optional[ContestAttempt]:
"""
Create a pending attempt for text-input games.
This blocks re-entry while user is answering.
Args:
db: Database session
round_id: Round ID
user_id: User ID
Returns:
Created attempt or None if already exists
"""
existing = await get_attempt(db, round_id, user_id)
if existing:
return None
return await create_attempt(
db,
round_id=round_id,
user_id=user_id,
answer=None,
is_winner=False,
)
async def _atomic_winner_check(
self,
db: AsyncSession,
round_id: int,
is_winner: bool,
) -> bool:
"""
Atomically check and increment winner count.
Uses SELECT FOR UPDATE to prevent race conditions.
Args:
db: Database session
round_id: Round ID
is_winner: Whether user answered correctly
Returns:
True if user is a winner, False if max winners reached
"""
if not is_winner:
return False
stmt = select(ContestRound).where(ContestRound.id == round_id).with_for_update()
result = await db.execute(stmt)
round_obj = result.scalar_one()
if round_obj.winners_count >= round_obj.max_winners:
return False
round_obj.winners_count += 1
await db.commit()
return True
async def _award_prize(
self,
db: AsyncSession,
user_id: int,
template: ContestTemplate,
language: str,
) -> str:
"""
Award prize to winner.
Args:
db: Database session
user_id: Winner user ID
template: Contest template with prize info
language: User's language
Returns:
Prize notification message
"""
from app.localization.texts import get_texts
texts = get_texts(language)
prize_type = template.prize_type or PrizeType.DAYS.value
prize_value = template.prize_value or "1"
if prize_type == PrizeType.DAYS.value:
subscription = await get_subscription_by_user_id(db, user_id)
if not subscription:
return ""
days = int(prize_value) if prize_value.isdigit() else 1
await extend_subscription(db, subscription, days)
return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=days)
elif prize_type == PrizeType.BALANCE.value:
user = await get_user_by_id(db, user_id)
if not user:
return ""
kopeks = int(prize_value) if prize_value.isdigit() else 0
if kopeks > 0:
user.balance_kopeks += kopeks
await db.commit()
return texts.t(
"CONTEST_BALANCE_GRANTED",
"Бонус {amount} зачислен!"
).format(amount=settings.format_price(kopeks))
elif prize_type == PrizeType.CUSTOM.value:
return f"🎁 {prize_value}"
return ""
# Singleton instance
contest_attempt_service = ContestAttemptService()
+45
View File
@@ -0,0 +1,45 @@
"""Enum classes for contest system."""
from enum import Enum
class GameType(str, Enum):
"""Types of daily contest games."""
QUEST_BUTTONS = "quest_buttons"
LOCK_HACK = "lock_hack"
LETTER_CIPHER = "letter_cipher"
SERVER_LOTTERY = "server_lottery"
BLITZ_REACTION = "blitz_reaction"
EMOJI_GUESS = "emoji_guess"
ANAGRAM = "anagram"
@classmethod
def is_text_input(cls, game_type: "GameType") -> bool:
"""Check if game requires text input from user."""
return game_type in {cls.LETTER_CIPHER, cls.EMOJI_GUESS, cls.ANAGRAM}
@classmethod
def is_button_pick(cls, game_type: "GameType") -> bool:
"""Check if game uses button selection."""
return game_type in {
cls.QUEST_BUTTONS,
cls.LOCK_HACK,
cls.SERVER_LOTTERY,
cls.BLITZ_REACTION,
}
class RoundStatus(str, Enum):
"""Contest round status."""
ACTIVE = "active"
FINISHED = "finished"
class PrizeType(str, Enum):
"""Types of prizes for contests."""
DAYS = "days"
BALANCE = "balance"
CUSTOM = "custom"
+473
View File
@@ -0,0 +1,473 @@
"""Game strategies for different contest types."""
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from aiogram import types
from app.services.contests.enums import GameType
@dataclass
class GameRenderResult:
"""Result of rendering a game."""
text: str
keyboard: types.InlineKeyboardMarkup
requires_text_input: bool = False
@dataclass
class AnswerCheckResult:
"""Result of checking user's answer."""
is_correct: bool
response_text: str
class BaseGameStrategy(ABC):
"""Base class for game strategies."""
game_type: GameType
@abstractmethod
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
"""Build round-specific payload from template config."""
pass
@abstractmethod
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
"""Render game UI for user."""
pass
@abstractmethod
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
"""Check if user's answer is correct."""
pass
def _get_back_button(self, language: str, callback: str) -> types.InlineKeyboardButton:
from app.localization.texts import get_texts
texts = get_texts(language)
return types.InlineKeyboardButton(text=texts.BACK, callback_data=callback)
def _get_texts(self, language: str):
from app.localization.texts import get_texts
return get_texts(language)
class QuestButtonsStrategy(BaseGameStrategy):
"""3x3 grid game - find the secret button."""
game_type = GameType.QUEST_BUTTONS
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
rows = template_payload.get("rows", 3)
cols = template_payload.get("cols", 3)
total = rows * cols
secret_idx = random.randint(0, total - 1)
return {"rows": rows, "cols": cols, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
rows = payload.get("rows", 3)
cols = payload.get("cols", 3)
keyboard_rows = []
for r in range(rows):
row_buttons = []
for c in range(cols):
idx = r * cols + c
row_buttons.append(
types.InlineKeyboardButton(
text="🎛",
callback_data=f"contest_pick_{round_id}_quest_{idx}",
)
)
keyboard_rows.append(row_buttons)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
try:
if user_answer.startswith("quest_"):
idx = int(user_answer.split("_")[1])
is_correct = secret_idx is not None and idx == secret_idx
else:
is_correct = False
except (ValueError, IndexError):
is_correct = False
responses = ["Пусто", "Ложный сервер", "Найди другой узел"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class LockHackStrategy(BaseGameStrategy):
"""20 locks game - find the hacked one."""
game_type = GameType.LOCK_HACK
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
total = template_payload.get("buttons", 20)
secret_idx = random.randint(0, max(0, total - 1))
return {"total": total, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
total = payload.get("total", 20)
keyboard_rows = []
row = []
for i in range(total):
row.append(
types.InlineKeyboardButton(
text="🔒",
callback_data=f"contest_pick_{round_id}_locks_{i}",
)
)
if len(row) == 5:
keyboard_rows.append(row)
row = []
if row:
keyboard_rows.append(row)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
try:
if user_answer.startswith("locks_"):
idx = int(user_answer.split("_")[1])
is_correct = secret_idx is not None and idx == secret_idx
else:
is_correct = False
except (ValueError, IndexError):
is_correct = False
responses = ["Заблокировано", "Попробуй ещё", "Нет доступа"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class ServerLotteryStrategy(BaseGameStrategy):
"""Flag lottery game - pick the correct server flag."""
game_type = GameType.SERVER_LOTTERY
DEFAULT_FLAGS = ["🇸🇪", "🇸🇬", "🇺🇸", "🇷🇺", "🇩🇪", "🇯🇵", "🇧🇷", "🇦🇺", "🇨🇦", "🇫🇷"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
flags = template_payload.get("flags") or self.DEFAULT_FLAGS
secret_idx = random.randint(0, len(flags) - 1)
return {"flags": flags, "secret_idx": secret_idx}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
flags = payload.get("flags") or []
shuffled_flags = flags.copy()
random.shuffle(shuffled_flags)
keyboard_rows = []
row = []
for flag in shuffled_flags:
row.append(
types.InlineKeyboardButton(
text=flag,
callback_data=f"contest_pick_{round_id}_{flag}",
)
)
if len(row) == 5:
keyboard_rows.append(row)
row = []
if row:
keyboard_rows.append(row)
keyboard_rows.append([self._get_back_button(language, back_callback)])
return GameRenderResult(
text=texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"),
keyboard=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
secret_idx = payload.get("secret_idx")
flags = payload.get("flags") or []
correct_flag = flags[secret_idx] if secret_idx is not None and secret_idx < len(flags) else ""
is_correct = user_answer == correct_flag
responses = ["Сервер перегружен", "Нет ответа", "Попробуй завтра"]
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else random.choice(responses),
)
class BlitzReactionStrategy(BaseGameStrategy):
"""Blitz reaction game - press button quickly."""
game_type = GameType.BLITZ_REACTION
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
return {"timeout_seconds": template_payload.get("timeout_seconds", 10)}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"),
callback_data=f"contest_pick_{round_id}_blitz",
)
],
[self._get_back_button(language, back_callback)],
]
)
return GameRenderResult(
text=texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"),
keyboard=keyboard,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
is_correct = user_answer == "blitz"
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Время вышло",
)
class LetterCipherStrategy(BaseGameStrategy):
"""Letter cipher game - decode word from letter codes."""
game_type = GameType.LETTER_CIPHER
DEFAULT_WORDS = ["VPN", "SERVER", "PROXY", "XRAY"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
words = template_payload.get("words") or self.DEFAULT_WORDS
word = random.choice(words)
codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()]
return {"question": "-".join(codes), "answer": word.upper()}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
question = payload.get("question", "")
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
class EmojiGuessStrategy(BaseGameStrategy):
"""Emoji guess game - guess service by emoji."""
game_type = GameType.EMOJI_GUESS
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
pairs = template_payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}]
pair = random.choice(pairs)
return pair
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
question = payload.get("question", "🤔")
emoji_list = question.split()
random.shuffle(emoji_list)
shuffled_question = " ".join(emoji_list)
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(
q=shuffled_question
),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
class AnagramStrategy(BaseGameStrategy):
"""Anagram game - unscramble letters to form a word."""
game_type = GameType.ANAGRAM
DEFAULT_WORDS = ["SERVER", "XRAY", "VPN"]
def build_payload(self, template_payload: Dict[str, Any]) -> Dict[str, Any]:
words = template_payload.get("words") or self.DEFAULT_WORDS
word = random.choice(words).upper()
shuffled = "".join(random.sample(word, len(word)))
return {"letters": shuffled, "answer": word}
def render(
self,
round_id: int,
payload: Dict[str, Any],
language: str,
back_callback: str = "contests_menu",
) -> GameRenderResult:
texts = self._get_texts(language)
letters = payload.get("letters", "")
from app.keyboards.inline import get_back_keyboard
return GameRenderResult(
text=texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(
letters=letters
),
keyboard=get_back_keyboard(language),
requires_text_input=True,
)
def check_answer(
self,
user_answer: str,
payload: Dict[str, Any],
language: str,
) -> AnswerCheckResult:
correct = (payload.get("answer") or "").upper()
is_correct = correct and user_answer.strip().upper() == correct
return AnswerCheckResult(
is_correct=is_correct,
response_text="" if is_correct else "Неверно, попробуй в следующем раунде",
)
# Registry of game strategies
_GAME_STRATEGIES: Dict[GameType, BaseGameStrategy] = {
GameType.QUEST_BUTTONS: QuestButtonsStrategy(),
GameType.LOCK_HACK: LockHackStrategy(),
GameType.SERVER_LOTTERY: ServerLotteryStrategy(),
GameType.BLITZ_REACTION: BlitzReactionStrategy(),
GameType.LETTER_CIPHER: LetterCipherStrategy(),
GameType.EMOJI_GUESS: EmojiGuessStrategy(),
GameType.ANAGRAM: AnagramStrategy(),
}
def get_game_strategy(game_type: GameType | str) -> Optional[BaseGameStrategy]:
"""Get game strategy by type."""
if isinstance(game_type, str):
try:
game_type = GameType(game_type)
except ValueError:
return None
return _GAME_STRATEGIES.get(game_type)
def get_all_game_types() -> List[GameType]:
"""Get list of all supported game types."""
return list(_GAME_STRATEGIES.keys())
+351
View File
@@ -0,0 +1,351 @@
"""Сервис ротации логов с отправкой в Telegram.
Функционал:
- Ежедневная ротация в настроенное время (по умолчанию 00:00)
- Разделение по уровням: info.log, warning.log, error.log
- Отдельный лог платежей: payments.log
- Архивирование всех логов за день в один tar.gz
- Отправка архива в Telegram-канал
- Очистка архивов старше N дней
"""
from __future__ import annotations
import asyncio
import logging
import tarfile
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from aiogram import Bot
from aiogram.types import FSInputFile
from app.config import settings
from app.utils.timezone import get_local_timezone
logger = logging.getLogger(__name__)
@dataclass
class LogRotationStatus:
"""Статус сервиса ротации логов."""
enabled: bool
running: bool
rotation_time: str
keep_days: int
send_to_telegram: bool
next_rotation: Optional[str]
log_dir: str
archive_count: int
class LogRotationService:
"""Сервис ежедневной ротации и архивации логов."""
def __init__(self, bot: Optional[Bot] = None):
self.bot = bot
self._rotation_task: Optional[asyncio.Task] = None
self._running = False
self._handlers: List[logging.Handler] = []
# Пути
self.log_dir = Path(settings.LOG_DIR).resolve()
self.current_dir = self.log_dir / "current"
self.archive_dir = self.log_dir / "archive"
@property
def log_files(self) -> Dict[str, Path]:
"""Пути к текущим лог-файлам."""
return {
"bot": self.current_dir / "bot.log",
"info": self.current_dir / settings.LOG_INFO_FILE,
"warning": self.current_dir / settings.LOG_WARNING_FILE,
"error": self.current_dir / settings.LOG_ERROR_FILE,
"payments": self.current_dir / settings.LOG_PAYMENTS_FILE,
}
def set_bot(self, bot: Bot) -> None:
"""Установить экземпляр бота для отправки логов."""
self.bot = bot
def register_handlers(self, handlers: List[logging.Handler]) -> None:
"""Зарегистрировать хэндлеры для управления при ротации."""
self._handlers = handlers
async def initialize(self) -> None:
"""Создать необходимые директории."""
self.current_dir.mkdir(parents=True, exist_ok=True)
self.archive_dir.mkdir(parents=True, exist_ok=True)
async def start(self) -> None:
"""Запустить сервис ротации."""
if self._running:
return
self._running = True
self._rotation_task = asyncio.create_task(self._rotation_loop())
logger.info("Сервис ротации логов запущен")
async def stop(self) -> None:
"""Остановить сервис ротации."""
self._running = False
if self._rotation_task and not self._rotation_task.done():
self._rotation_task.cancel()
try:
await self._rotation_task
except asyncio.CancelledError:
pass
logger.info("Сервис ротации логов остановлен")
def is_running(self) -> bool:
"""Проверить, запущен ли сервис."""
return self._running
async def _rotation_loop(self) -> None:
"""Основной цикл ожидания времени ротации."""
while self._running:
next_rotation = self._calculate_next_rotation_time()
now = datetime.now(get_local_timezone())
wait_seconds = (next_rotation - now).total_seconds()
if wait_seconds > 0:
logger.info(
"Следующая ротация логов: %s (через %.1f часов)",
next_rotation.strftime("%Y-%m-%d %H:%M"),
wait_seconds / 3600,
)
try:
await asyncio.sleep(wait_seconds)
except asyncio.CancelledError:
break
if self._running:
await self.rotate_logs()
def _calculate_next_rotation_time(self) -> datetime:
"""Вычислить время следующей ротации."""
now = datetime.now(get_local_timezone())
# Парсим время ротации
time_str = settings.LOG_ROTATION_TIME
try:
hours, minutes = map(int, time_str.split(":"))
except ValueError:
hours, minutes = 0, 0
logger.warning(
"Некорректное LOG_ROTATION_TIME='%s', используем 00:00", time_str
)
next_rotation = now.replace(hour=hours, minute=minutes, second=0, microsecond=0)
# Если время уже прошло сегодня, ротация завтра
if next_rotation <= now:
next_rotation += timedelta(days=1)
return next_rotation
async def rotate_logs(self) -> Tuple[bool, str]:
"""Выполнить ротацию логов.
Создаёт один архив logs_YYYY-MM-DD.tar.gz со всеми лог-файлами за день.
Returns:
Tuple[bool, str]: (успех, сообщение)
"""
try:
logger.info("Начинаем ротацию логов...")
# Дата для архива (вчера, т.к. логи были за предыдущие сутки)
yesterday = (
datetime.now(get_local_timezone()) - timedelta(days=1)
).strftime("%Y-%m-%d")
# Сбрасываем буферы хэндлеров перед архивацией
for handler in self._handlers:
try:
handler.flush()
except Exception:
pass
# Собираем файлы для архивации
files_to_archive: List[Tuple[Path, str]] = []
for name, log_path in self.log_files.items():
if log_path.exists() and log_path.stat().st_size > 0:
files_to_archive.append((log_path, f"{name}.log"))
if not files_to_archive:
message = "Нет логов для архивации"
logger.info(message)
return True, message
# Создаём один архив со всеми логами
archive_path = await self._create_archive(files_to_archive, yesterday)
if archive_path:
# Очищаем текущие лог-файлы
for log_path, _ in files_to_archive:
log_path.write_text("")
# Очистка старых архивов
await self._cleanup_old_archives()
# Отправка в Telegram
if settings.LOG_ROTATION_SEND_TO_TELEGRAM and self.bot:
await self._send_logs_to_telegram(archive_path, yesterday)
message = f"Ротация логов завершена. Архив: {archive_path.name}"
logger.info(message)
return True, message
else:
message = "Ошибка создания архива логов"
logger.error(message)
return False, message
except Exception as error:
message = f"Ошибка ротации логов: {error}"
logger.error(message, exc_info=True)
return False, message
async def _create_archive(
self,
files: List[Tuple[Path, str]],
date_str: str,
) -> Optional[Path]:
"""Создать архив со всеми логами за день.
Args:
files: список (путь к файлу, имя в архиве)
date_str: дата в формате YYYY-MM-DD
Returns:
Путь к созданному архиву или None при ошибке
"""
try:
if settings.LOG_ROTATION_COMPRESS:
archive_name = f"logs_{date_str}.tar.gz"
mode = "w:gz"
else:
archive_name = f"logs_{date_str}.tar"
mode = "w"
archive_path = self.archive_dir / archive_name
def _create_tar():
with tarfile.open(archive_path, mode) as tar:
for file_path, arcname in files:
tar.add(file_path, arcname=arcname)
await asyncio.to_thread(_create_tar)
logger.debug("Создан архив: %s", archive_path)
return archive_path
except Exception as error:
logger.error("Ошибка создания архива: %s", error)
return None
async def _cleanup_old_archives(self) -> None:
"""Удалить архивы старше LOG_ROTATION_KEEP_DAYS."""
keep_days = settings.LOG_ROTATION_KEEP_DAYS
cutoff_date = datetime.now(get_local_timezone()) - timedelta(days=keep_days)
if not self.archive_dir.exists():
return
# Ищем файлы вида logs_YYYY-MM-DD.tar.gz или logs_YYYY-MM-DD.tar
for archive_file in self.archive_dir.iterdir():
if not archive_file.is_file():
continue
# Извлекаем дату из имени файла logs_YYYY-MM-DD.tar.gz
name = archive_file.name
if not name.startswith("logs_"):
continue
try:
# logs_2025-01-26.tar.gz -> 2025-01-26
date_part = name.replace("logs_", "").replace(".tar.gz", "").replace(".tar", "")
file_date = datetime.strptime(date_part, "%Y-%m-%d")
file_date = file_date.replace(tzinfo=get_local_timezone())
if file_date < cutoff_date:
archive_file.unlink()
logger.info("Удален старый архив логов: %s", archive_file.name)
except ValueError:
# Пропускаем файлы с некорректным форматом имени
pass
async def _send_logs_to_telegram(
self,
archive_path: Path,
date_str: str,
) -> None:
"""Отправить архив логов в Telegram."""
chat_id = settings.get_log_rotation_chat_id()
if not chat_id:
logger.warning("LOG_ROTATION_CHAT_ID не задан, пропускаем отправку")
return
topic_id = settings.get_log_rotation_topic_id()
try:
file_size_kb = archive_path.stat().st_size / 1024
caption = (
f"<b>Логи бота</b>\n"
f"Дата: {date_str}\n"
f"Файл: <code>{archive_path.name}</code>\n"
f"Размер: {file_size_kb:.1f} KB"
)
send_kwargs = {
"chat_id": chat_id,
"document": FSInputFile(archive_path),
"caption": caption,
"parse_mode": "HTML",
}
if topic_id:
send_kwargs["message_thread_id"] = topic_id
await self.bot.send_document(**send_kwargs)
logger.info("Архив логов отправлен: %s", archive_path.name)
except Exception as error:
logger.error("Ошибка отправки архива %s: %s", archive_path.name, error)
# === Ручные операции ===
async def force_rotate(self) -> Tuple[bool, str]:
"""Принудительная ротация (для админ-команды)."""
return await self.rotate_logs()
def get_status(self) -> LogRotationStatus:
"""Получить статус сервиса."""
archive_count = 0
if self.archive_dir.exists():
# Считаем файлы logs_*.tar.gz или logs_*.tar
archive_count = len(
[f for f in self.archive_dir.iterdir()
if f.is_file() and f.name.startswith("logs_")]
)
next_rotation = None
if self._running:
next_rotation = self._calculate_next_rotation_time().isoformat()
return LogRotationStatus(
enabled=settings.is_log_rotation_enabled(),
running=self._running,
rotation_time=settings.LOG_ROTATION_TIME,
keep_days=settings.LOG_ROTATION_KEEP_DAYS,
send_to_telegram=settings.LOG_ROTATION_SEND_TO_TELEGRAM,
next_rotation=next_rotation,
log_dir=str(self.log_dir),
archive_count=archive_count,
)
# Глобальный экземпляр сервиса
log_rotation_service = LogRotationService()
+5 -1
View File
@@ -293,6 +293,8 @@ class MaintenanceService:
secret_key = (auth_params.get("secret_key") or "").strip() or None
username = (auth_params.get("username") or "").strip() or None
password = (auth_params.get("password") or "").strip() or None
caddy_token = (auth_params.get("caddy_token") or "").strip() or None
auth_type = (auth_params.get("auth_type") or "api_key").strip()
if not base_url:
logger.error("REMNAWAVE_API_URL не настроен, пропускаем проверку API")
@@ -311,7 +313,9 @@ class MaintenanceService:
api_key=api_key,
secret_key=secret_key,
username=username,
password=password
password=password,
caddy_token=caddy_token,
auth_type=auth_type,
)
attempts = settings.get_maintenance_retry_attempts()
+1 -1
View File
@@ -128,7 +128,7 @@ DEFAULT_MENU_CONFIG: Dict[str, Any] = {
"action": "buy_traffic",
"enabled": True,
"visibility": "subscribers",
"conditions": {"has_traffic_limit": True},
"conditions": {"has_traffic_limit": True, "traffic_topup_enabled": True},
"dynamic_text": False,
},
"balance": {
+5
View File
@@ -715,6 +715,11 @@ class MenuLayoutService:
if is_trial or traffic_limit <= 0:
return False
# traffic_topup_enabled - функция докупки трафика включена
if conditions.get("traffic_topup_enabled") is True:
if not settings.is_traffic_topup_enabled():
return False
# is_admin
if conditions.get("is_admin") is True:
if not context.is_admin:
+392
View File
@@ -0,0 +1,392 @@
"""
Сервис для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import Subscription, User, TransactionType
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import get_remaining_months, calculate_prorated_price
logger = logging.getLogger(__name__)
class ModemError(Enum):
"""Типы ошибок при работе с модемом."""
NO_SUBSCRIPTION = "no_subscription"
TRIAL_SUBSCRIPTION = "trial_subscription"
MODEM_DISABLED = "modem_disabled"
ALREADY_ENABLED = "already_enabled"
NOT_ENABLED = "not_enabled"
INSUFFICIENT_FUNDS = "insufficient_funds"
CHARGE_ERROR = "charge_error"
UPDATE_ERROR = "update_error"
@dataclass
class ModemAvailabilityResult:
"""Результат проверки доступности модема."""
available: bool
error: Optional[ModemError] = None
modem_enabled: bool = False
@dataclass
class ModemPriceResult:
"""Результат расчёта цены модема."""
base_price: int
final_price: int
discount_percent: int
discount_amount: int
charged_months: int
remaining_days: int
end_date: datetime
@property
def has_discount(self) -> bool:
return self.discount_percent > 0
@dataclass
class ModemEnableResult:
"""Результат подключения модема."""
success: bool
error: Optional[ModemError] = None
charged_amount: int = 0
new_device_limit: int = 0
@dataclass
class ModemDisableResult:
"""Результат отключения модема."""
success: bool
error: Optional[ModemError] = None
new_device_limit: int = 0
# Константы для предупреждений о сроке действия
MODEM_WARNING_DAYS_CRITICAL = 7
MODEM_WARNING_DAYS_INFO = 30
class ModemService:
"""
Сервис для управления модемом в подписке.
Инкапсулирует всю бизнес-логику:
- Проверки доступности
- Расчёт цен и скидок
- Подключение/отключение модема
- Синхронизация с RemnaWave
"""
def __init__(self):
self._subscription_service = SubscriptionService()
@staticmethod
def is_modem_feature_enabled() -> bool:
"""Проверяет, включена ли функция модема в настройках."""
return settings.is_modem_enabled()
@staticmethod
def get_modem_enabled(subscription: Optional[Subscription]) -> bool:
"""Безопасно получает статус модема из подписки."""
if subscription is None:
return False
return getattr(subscription, 'modem_enabled', False) or False
def check_availability(
self,
user: User,
for_enable: bool = False,
for_disable: bool = False
) -> ModemAvailabilityResult:
"""
Проверяет доступность модема для пользователя.
Args:
user: Пользователь
for_enable: Проверка для подключения (модем должен быть отключен)
for_disable: Проверка для отключения (модем должен быть включен)
Returns:
ModemAvailabilityResult с результатом проверки
"""
subscription = user.subscription
modem_enabled = self.get_modem_enabled(subscription)
if not subscription:
return ModemAvailabilityResult(
available=False,
error=ModemError.NO_SUBSCRIPTION,
modem_enabled=modem_enabled
)
if subscription.is_trial:
return ModemAvailabilityResult(
available=False,
error=ModemError.TRIAL_SUBSCRIPTION,
modem_enabled=modem_enabled
)
if not self.is_modem_feature_enabled():
return ModemAvailabilityResult(
available=False,
error=ModemError.MODEM_DISABLED,
modem_enabled=modem_enabled
)
if for_enable and modem_enabled:
return ModemAvailabilityResult(
available=False,
error=ModemError.ALREADY_ENABLED,
modem_enabled=modem_enabled
)
if for_disable and not modem_enabled:
return ModemAvailabilityResult(
available=False,
error=ModemError.NOT_ENABLED,
modem_enabled=modem_enabled
)
return ModemAvailabilityResult(
available=True,
modem_enabled=modem_enabled
)
def calculate_price(self, subscription: Subscription) -> ModemPriceResult:
"""
Рассчитывает стоимость подключения модема.
Использует пропорциональную цену на основе оставшегося времени подписки
и применяет скидки в зависимости от периода.
Args:
subscription: Подписка пользователя
Returns:
ModemPriceResult с детализацией цены
"""
modem_price_per_month = settings.get_modem_price_per_month()
base_price, charged_months = calculate_prorated_price(
modem_price_per_month,
subscription.end_date,
)
now = datetime.utcnow()
remaining_days = max(0, (subscription.end_date - now).days)
discount_percent = settings.get_modem_period_discount(charged_months)
if discount_percent > 0:
discount_amount = base_price * discount_percent // 100
final_price = base_price - discount_amount
else:
discount_amount = 0
final_price = base_price
return ModemPriceResult(
base_price=base_price,
final_price=final_price,
discount_percent=discount_percent,
discount_amount=discount_amount,
charged_months=charged_months,
remaining_days=remaining_days,
end_date=subscription.end_date
)
def check_balance(self, user: User, price: int) -> Tuple[bool, int]:
"""
Проверяет достаточность баланса.
Args:
user: Пользователь
price: Требуемая сумма
Returns:
Tuple[достаточно ли средств, недостающая сумма]
"""
if price <= 0:
return True, 0
if user.balance_kopeks >= price:
return True, 0
missing = price - user.balance_kopeks
return False, missing
async def enable_modem(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> ModemEnableResult:
"""
Подключает модем к подписке.
Выполняет:
1. Расчёт цены
2. Проверку баланса
3. Списание средств
4. Создание транзакции
5. Обновление подписки
6. Синхронизацию с RemnaWave
Args:
db: Сессия базы данных
user: Пользователь
subscription: Подписка
Returns:
ModemEnableResult с результатом операции
"""
price_info = self.calculate_price(subscription)
price = price_info.final_price
has_funds, _ = self.check_balance(user, price)
if not has_funds:
return ModemEnableResult(
success=False,
error=ModemError.INSUFFICIENT_FUNDS
)
try:
if price > 0:
success = await subtract_user_balance(
db, user, price,
"Подключение модема"
)
if not success:
return ModemEnableResult(
success=False,
error=ModemError.CHARGE_ERROR
)
await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Подключение модема на {price_info.charged_months} мес"
)
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
subscription.updated_at = datetime.utcnow()
await db.commit()
await self._subscription_service.update_remnawave_user(db, subscription)
await db.refresh(user)
await db.refresh(subscription)
logger.info(
f"Пользователь {user.telegram_id} подключил модем, списано: {price / 100}"
)
return ModemEnableResult(
success=True,
charged_amount=price,
new_device_limit=subscription.device_limit
)
except Exception as e:
logger.error(f"Ошибка подключения модема для пользователя {user.telegram_id}: {e}")
await db.rollback()
return ModemEnableResult(
success=False,
error=ModemError.UPDATE_ERROR
)
async def disable_modem(
self,
db: AsyncSession,
user: User,
subscription: Subscription
) -> ModemDisableResult:
"""
Отключает модем от подписки.
Возврат средств не производится.
Args:
db: Сессия базы данных
user: Пользователь
subscription: Подписка
Returns:
ModemDisableResult с результатом операции
"""
try:
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
subscription.updated_at = datetime.utcnow()
await db.commit()
await self._subscription_service.update_remnawave_user(db, subscription)
await db.refresh(user)
await db.refresh(subscription)
logger.info(f"Пользователь {user.telegram_id} отключил модем")
return ModemDisableResult(
success=True,
new_device_limit=subscription.device_limit
)
except Exception as e:
logger.error(f"Ошибка отключения модема для пользователя {user.telegram_id}: {e}")
await db.rollback()
return ModemDisableResult(
success=False,
error=ModemError.UPDATE_ERROR
)
@staticmethod
def get_period_warning_level(remaining_days: int) -> Optional[str]:
"""
Определяет уровень предупреждения о сроке действия.
Args:
remaining_days: Оставшиеся дни подписки
Returns:
"critical" если <= 7 дней
"info" если <= 30 дней
None если больше 30 дней
"""
if remaining_days <= MODEM_WARNING_DAYS_CRITICAL:
return "critical"
if remaining_days <= MODEM_WARNING_DAYS_INFO:
return "info"
return None
# Singleton instance для использования в хендлерах
_modem_service: Optional[ModemService] = None
def get_modem_service() -> ModemService:
"""Возвращает singleton экземпляр ModemService."""
global _modem_service
if _modem_service is None:
_modem_service = ModemService()
return _modem_service
+321
View File
@@ -0,0 +1,321 @@
"""Фоновый сервис для обработки очереди чеков NaloGO.
При временной недоступности сервиса nalog.ru (503), чеки сохраняются в Redis
и отправляются позже этим сервисом.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional
from dateutil.parser import isoparse
from aiogram import Bot
from app.config import settings
from app.services.nalogo_service import NaloGoService
from app.utils.cache import cache
logger = logging.getLogger(__name__)
class NalogoQueueService:
"""Сервис фоновой обработки очереди чеков NaloGO."""
def __init__(self, nalogo_service: Optional[NaloGoService] = None):
self._nalogo_service = nalogo_service
self._bot: Optional[Bot] = None
self._task: Optional[asyncio.Task] = None
self._running = False
self._last_notification_time: Optional[datetime] = None
self._notification_cooldown = timedelta(hours=1) # Не чаще раза в час
self._had_pending_receipts = False # Флаг для отслеживания успешной разгрузки
def set_nalogo_service(self, service: NaloGoService) -> None:
"""Установить сервис NaloGO."""
self._nalogo_service = service
def set_bot(self, bot: Bot) -> None:
"""Установить бота для отправки уведомлений."""
self._bot = bot
def is_running(self) -> bool:
"""Проверка, запущен ли сервис."""
return self._running and self._task is not None and not self._task.done()
@property
def _check_interval(self) -> int:
"""Интервал проверки очереди в секундах."""
return getattr(settings, "NALOGO_QUEUE_CHECK_INTERVAL", 300)
@property
def _receipt_delay(self) -> int:
"""Задержка между отправкой чеков в секундах."""
return getattr(settings, "NALOGO_QUEUE_RECEIPT_DELAY", 3)
@property
def _max_attempts(self) -> int:
"""Максимальное количество попыток отправки чека."""
return getattr(settings, "NALOGO_QUEUE_MAX_ATTEMPTS", 10)
async def start(self) -> None:
"""Запустить фоновую обработку очереди."""
if not self._nalogo_service or not self._nalogo_service.configured:
logger.info("NaloGO не настроен, сервис очереди чеков не запущен")
return
if self.is_running():
logger.warning("Сервис очереди чеков уже запущен")
return
self._running = True
self._task = asyncio.create_task(self._process_queue_loop())
logger.info(
f"Сервис очереди чеков NaloGO запущен "
f"(интервал: {self._check_interval}с, задержка между чеками: {self._receipt_delay}с)"
)
async def stop(self) -> None:
"""Остановить фоновую обработку."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info("Сервис очереди чеков NaloGO остановлен")
async def _send_admin_notification(self, message: str, skip_cooldown: bool = False) -> None:
"""Отправить уведомление админам о чеках."""
if not self._bot:
return
chat_id = settings.get_admin_notifications_chat_id()
if not chat_id:
return
topic_id = settings.ADMIN_NOTIFICATIONS_NALOG_TOPIC_ID
# Проверяем cooldown (можно пропустить для важных уведомлений)
if not skip_cooldown:
now = datetime.now()
if self._last_notification_time:
if now - self._last_notification_time < self._notification_cooldown:
logger.debug("Уведомление о чеках пропущено (cooldown)")
return
try:
await self._bot.send_message(
chat_id=chat_id,
message_thread_id=topic_id,
text=message,
parse_mode="HTML",
)
self._last_notification_time = datetime.now()
logger.info("Отправлено уведомление о чеках NaloGO")
except Exception as error:
logger.error(f"Ошибка отправки уведомления о чеках: {error}")
async def _process_queue_loop(self) -> None:
"""Основной цикл обработки очереди."""
while self._running:
try:
await self._process_pending_receipts()
except Exception as error:
logger.error(f"Ошибка в цикле обработки очереди чеков: {error}")
await asyncio.sleep(self._check_interval)
async def _process_pending_receipts(self) -> None:
"""Обработать все ожидающие чеки в очереди."""
if not self._nalogo_service:
return
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return
logger.info(f"Начинаем обработку очереди чеков: {queue_length} шт.")
self._had_pending_receipts = True
processed = 0
failed = 0
skipped = 0
total_processed_amount = 0.0
service_unavailable = False
while True:
receipt_data = await self._nalogo_service.pop_receipt_from_queue()
if not receipt_data:
break
attempts = receipt_data.get("attempts", 0)
payment_id = receipt_data.get("payment_id", "unknown")
amount = receipt_data.get("amount", 0)
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning(
f"Чек {payment_id} уже {attempts} попыток, продолжаем пытаться..."
)
# Пытаемся отправить чек
try:
# Восстанавливаем описание из сохранённых данных
telegram_user_id = receipt_data.get("telegram_user_id")
amount_kopeks = receipt_data.get("amount_kopeks")
# Извлекаем время оплаты из очереди (чтобы чек был с правильным временем)
operation_time = None
created_at_str = receipt_data.get("created_at")
if created_at_str:
try:
operation_time = isoparse(created_at_str)
except (ValueError, TypeError) as parse_error:
logger.warning(
f"Не удалось распарсить created_at '{created_at_str}': {parse_error}"
)
# Формируем описание заново из настроек (если есть данные)
if amount_kopeks is not None:
receipt_name = settings.get_balance_payment_description(
amount_kopeks, telegram_user_id
)
else:
# Fallback на сохранённое имя
receipt_name = receipt_data.get(
"name",
settings.get_balance_payment_description(int(amount * 100), telegram_user_id)
)
receipt_uuid = await self._nalogo_service.create_receipt(
name=receipt_name,
amount=amount,
quantity=receipt_data.get("quantity", 1),
client_info=receipt_data.get("client_info"),
payment_id=payment_id,
queue_on_failure=False, # Не добавлять в очередь повторно автоматически
telegram_user_id=telegram_user_id,
amount_kopeks=amount_kopeks,
operation_time=operation_time, # Время оплаты, а не отправки
)
if receipt_uuid:
processed += 1
total_processed_amount += amount
# Удаляем метку "в очереди" (чек создан успешно)
if payment_id:
queued_key = f"nalogo:queued:{payment_id}"
await cache.delete(queued_key)
logger.info(
f"Чек из очереди успешно создан: {receipt_uuid} "
f"(payment_id={payment_id}, попытка {attempts + 1})"
)
else:
# Вернуть в очередь с увеличенным счетчиком попыток
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
service_unavailable = True
logger.warning(
f"Не удалось создать чек из очереди (payment_id={payment_id}), "
f"возвращен в очередь (попытка {attempts + 1}/{self._max_attempts})"
)
# Если сервис недоступен, прекращаем попытки до следующего цикла
break
except Exception as error:
await self._nalogo_service.requeue_receipt(receipt_data)
failed += 1
logger.error(
f"Ошибка при создании чека из очереди (payment_id={payment_id}): {error}"
)
# Прекращаем попытки при ошибке
break
# Задержка между чеками чтобы не долбить API
await asyncio.sleep(self._receipt_delay)
if processed > 0 or failed > 0 or skipped > 0:
logger.info(
f"Обработка очереди завершена: "
f"успешно={processed}, неудачно={failed}, пропущено={skipped}"
)
# Проверяем остаток в очереди
remaining = await self._nalogo_service.get_queue_length()
# Отправляем уведомление если есть проблемы
if service_unavailable or failed > 0:
if remaining > 0:
queued = await self._nalogo_service.get_queued_receipts()
total_queued_amount = sum(r.get("amount", 0) for r in queued)
message = (
f"<b>⚠️ Проблема с отправкой чеков NaloGO</b>\n\n"
f"Сервис nalog.ru временно недоступен.\n\n"
f"📋 <b>В очереди:</b> {remaining} чек(ов)\n"
f"💰 <b>На сумму:</b> {total_queued_amount:,.2f}\n\n"
f"Чеки будут отправлены автоматически когда сервис восстановится."
)
await self._send_admin_notification(message)
# Уведомление об успешной разгрузке очереди
elif remaining == 0 and self._had_pending_receipts and processed > 0:
self._had_pending_receipts = False
message = (
f"<b>✅ Очередь чеков NaloGO разгружена</b>\n\n"
f"Все отложенные чеки успешно отправлены!\n\n"
f"📋 <b>Отправлено:</b> {processed} чек(ов)\n"
f"💰 <b>На сумму:</b> {total_processed_amount:,.2f}"
)
await self._send_admin_notification(message, skip_cooldown=True)
async def force_process(self) -> dict:
"""Принудительно обработать очередь (для ручного запуска)."""
if not self._nalogo_service:
return {"error": "NaloGO сервис не настроен"}
queue_length = await self._nalogo_service.get_queue_length()
if queue_length == 0:
return {"message": "Очередь пуста", "processed": 0}
await self._process_pending_receipts()
new_length = await self._nalogo_service.get_queue_length()
return {
"message": "Обработка завершена",
"was_in_queue": queue_length,
"remaining": new_length,
"processed": queue_length - new_length,
}
async def get_status(self) -> dict:
"""Получить статус сервиса и очереди."""
queue_length = 0
total_amount = 0.0
queued_receipts = []
if self._nalogo_service:
queue_length = await self._nalogo_service.get_queue_length()
if queue_length > 0:
queued_receipts = await self._nalogo_service.get_queued_receipts()
total_amount = sum(r.get("amount", 0) for r in queued_receipts)
return {
"running": self.is_running(),
"check_interval_seconds": self._check_interval,
"receipt_delay_seconds": self._receipt_delay,
"queue_length": queue_length,
"total_amount": total_amount,
"max_attempts": self._max_attempts,
"queued_receipts": queued_receipts[:10], # Показываем только первые 10
}
# Глобальный экземпляр сервиса
nalogo_queue_service = NalogoQueueService()
+212 -7
View File
@@ -1,14 +1,19 @@
import logging
from typing import Optional, Dict, Any
from datetime import datetime, timezone, timedelta, date
from typing import Optional, Dict, Any, List
from decimal import Decimal
from nalogo import Client
from nalogo.dto.income import IncomeClient, IncomeType
# Используем локальную исправленную версию библиотеки
from app.lib.nalogo import Client
from app.lib.nalogo.dto.income import IncomeClient, IncomeType, MOSCOW_TZ
from app.config import settings
from app.utils.cache import cache
logger = logging.getLogger(__name__)
NALOGO_QUEUE_KEY = "nalogo:receipt_queue"
class NaloGoService:
"""Сервис для работы с API NaloGO (налоговая служба самозанятых)."""
@@ -49,6 +54,85 @@ class NaloGoService:
)
self.configured = False
@staticmethod
def _is_service_unavailable(error: Exception) -> bool:
"""Проверяет, является ли ошибка временной недоступностью сервиса."""
error_str = str(error).lower()
error_type = type(error).__name__.lower()
return (
"503" in error_str
or "500" in error_str
or "internal server error" in error_str
or "внутренняя ошибка" in error_str
or "service temporarily unavailable" in error_str
or "service unavailable" in error_str
or "ведутся работы" in error_str
or ("health" in error_str and "false" in error_str)
# Таймауты и сетевые ошибки — временные проблемы
or "timeout" in error_type
or "timeout" in error_str
or "readtimeout" in error_type
or "connecttimeout" in error_type
or "connectionerror" in error_type
or "connecterror" in error_type
)
async def _queue_receipt(
self,
name: str,
amount: float,
quantity: int,
client_info: Optional[Dict[str, Any]],
payment_id: Optional[str] = None,
telegram_user_id: Optional[int] = None,
amount_kopeks: Optional[int] = None,
) -> bool:
"""Добавить чек в очередь для отложенной отправки."""
if payment_id:
# Защита от дубликатов: проверяем не был ли чек уже создан
created_key = f"nalogo:created:{payment_id}"
already_created = await cache.get(created_key)
if already_created:
logger.info(
f"Чек для payment_id={payment_id} уже создан ({already_created}), "
"не добавляем в очередь"
)
return False
# Проверяем не в очереди ли уже
queued_key = f"nalogo:queued:{payment_id}"
already_queued = await cache.get(queued_key)
if already_queued:
logger.info(
f"Чек для payment_id={payment_id} уже в очереди, пропускаем дубликат"
)
return False
receipt_data = {
"name": name,
"amount": amount,
"quantity": quantity,
"client_info": client_info,
"payment_id": payment_id,
"telegram_user_id": telegram_user_id,
"amount_kopeks": amount_kopeks,
"created_at": datetime.now().isoformat(),
"attempts": 0,
}
success = await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
if success:
# Помечаем что чек в очереди (TTL 7 дней)
if payment_id:
queued_key = f"nalogo:queued:{payment_id}"
await cache.set(queued_key, "queued", expire=7 * 24 * 3600)
queue_len = await cache.llen(NALOGO_QUEUE_KEY)
logger.info(
f"Чек добавлен в очередь (payment_id={payment_id}, "
f"сумма={amount}₽, в очереди: {queue_len})"
)
return success
async def authenticate(self) -> bool:
"""Аутентификация в сервисе NaloGO."""
if not self.configured:
@@ -60,10 +144,27 @@ class NaloGoService:
logger.info("Успешная аутентификация в NaloGO")
return True
except Exception as error:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
if self._is_service_unavailable(error):
logger.warning(
"NaloGO временно недоступен (техработы): %s",
str(error)[:200]
)
else:
logger.error("Ошибка аутентификации в NaloGO: %s", error, exc_info=True)
return False
async def create_receipt(self, name: str, amount: float, quantity: int = 1, client_info: Optional[Dict[str, Any]] = None) -> Optional[str]:
async def create_receipt(
self,
name: str,
amount: float,
quantity: int = 1,
client_info: Optional[Dict[str, Any]] = None,
payment_id: Optional[str] = None,
queue_on_failure: bool = True,
telegram_user_id: Optional[int] = None,
amount_kopeks: Optional[int] = None,
operation_time: Optional[datetime] = None,
) -> Optional[str]:
"""Создание чека о доходе.
Args:
@@ -71,6 +172,11 @@ class NaloGoService:
amount: Сумма в рублях
quantity: Количество
client_info: Информация о клиенте (опционально)
payment_id: ID платежа для логирования
queue_on_failure: Добавить в очередь при временной недоступности
telegram_user_id: Telegram ID пользователя для формирования описания
amount_kopeks: Сумма в копейках для формирования описания
operation_time: Время операции (по умолчанию текущее)
Returns:
UUID чека или None при ошибке
@@ -79,11 +185,28 @@ class NaloGoService:
logger.warning("NaloGO не настроен, чек не создан")
return None
# Защита от дублей: проверяем не был ли уже создан чек для этого payment_id
if payment_id:
created_key = f"nalogo:created:{payment_id}"
already_created = await cache.get(created_key)
if already_created:
logger.info(
f"Чек для payment_id={payment_id} уже был создан ({already_created}), "
"пропускаем повторное создание"
)
return already_created # Возвращаем ранее созданный uuid
try:
# Аутентифицируемся, если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
# Если сервис недоступен — добавляем в очередь
if queue_on_failure:
await self._queue_receipt(
name, amount, quantity, client_info, payment_id,
telegram_user_id, amount_kopeks
)
return None
income_api = self.client.income()
@@ -98,21 +221,103 @@ class NaloGoService:
inn=client_info.get("inn")
)
# Используем переданное время операции или текущее
result = await income_api.create(
name=name,
amount=Decimal(str(amount)),
quantity=quantity,
client=income_client
operation_time=operation_time,
client=income_client,
)
receipt_uuid = result.get("approvedReceiptUuid")
if receipt_uuid:
logger.info(f"Чек создан успешно: {receipt_uuid} на сумму {amount}")
# Сохраняем в Redis чтобы предотвратить дубли (TTL 30 дней)
if payment_id:
created_key = f"nalogo:created:{payment_id}"
await cache.set(created_key, receipt_uuid, expire=30 * 24 * 3600)
return receipt_uuid
else:
logger.error(f"Ошибка создания чека: {result}")
return None
except Exception as error:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
if self._is_service_unavailable(error):
logger.warning(
"NaloGO временно недоступен, чек будет отправлен позже "
f"(payment_id={payment_id}, сумма={amount}₽)"
)
if queue_on_failure:
await self._queue_receipt(
name, amount, quantity, client_info, payment_id,
telegram_user_id, amount_kopeks
)
else:
logger.error("Ошибка создания чека в NaloGO: %s", error, exc_info=True)
return None
async def get_queue_length(self) -> int:
"""Получить количество чеков в очереди."""
return await cache.llen(NALOGO_QUEUE_KEY)
async def get_queued_receipts(self) -> list:
"""Получить список чеков в очереди (без удаления)."""
return await cache.lrange(NALOGO_QUEUE_KEY)
async def pop_receipt_from_queue(self) -> Optional[Dict[str, Any]]:
"""Извлечь следующий чек из очереди."""
return await cache.rpop(NALOGO_QUEUE_KEY)
async def requeue_receipt(self, receipt_data: Dict[str, Any]) -> bool:
"""Вернуть чек обратно в очередь (при неудачной отправке)."""
receipt_data["attempts"] = receipt_data.get("attempts", 0) + 1
return await cache.lpush(NALOGO_QUEUE_KEY, receipt_data)
async def get_incomes(
self,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
limit: int = 100,
) -> Optional[List[Dict[str, Any]]]:
"""Получить список доходов (чеков) за период.
Args:
from_date: Начало периода (по умолчанию 30 дней назад)
to_date: Конец периода (по умолчанию сегодня)
limit: Максимальное количество записей
Returns:
Список чеков с информацией, или None при ошибке
"""
if not self.configured:
logger.warning("NaloGO не настроен, невозможно получить список доходов")
return None
try:
# Аутентифицируемся если нужно
if not hasattr(self.client, '_access_token') or not self.client._access_token:
auth_success = await self.authenticate()
if not auth_success:
return []
income_api = self.client.income()
result = await income_api.get_list(
from_date=from_date,
to_date=to_date,
limit=limit,
)
# API возвращает структуру с полем content или items
incomes = result.get("content", result.get("items", []))
logger.info(f"Получено {len(incomes)} доходов из NaloGO")
return incomes
except Exception as error:
if self._is_service_unavailable(error):
logger.warning(f"NaloGO временно недоступен: {error}")
else:
logger.error(f"Ошибка получения списка доходов: {error}", exc_info=True)
return None # None = ошибка, [] = нет чеков
+479
View File
@@ -0,0 +1,479 @@
"""Mixin for integrating CloudPayments into the payment service."""
from __future__ import annotations
import json
from datetime import datetime
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
class CloudPaymentsPaymentMixin:
"""Encapsulates creation and webhook handling for CloudPayments."""
async def create_cloudpayments_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
*,
telegram_id: int,
language: Optional[str] = None,
email: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
Create a CloudPayments payment and return payment link info.
Args:
db: Database session
user_id: Internal user ID
amount_kopeks: Payment amount in kopeks
description: Payment description
telegram_id: User's Telegram ID
language: User's language
email: User's email (optional)
Returns:
Dict with payment_url and invoice_id, or None on error
"""
if not getattr(self, "cloudpayments_service", None):
logger.error("CloudPayments service is not initialised")
return None
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
logger.warning(
"Сумма CloudPayments меньше минимальной: %s < %s",
amount_kopeks,
settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
logger.warning(
"Сумма CloudPayments больше максимальной: %s > %s",
amount_kopeks,
settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module("app.services.payment_service")
# Generate unique invoice ID
invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id)
try:
# Create payment order via CloudPayments API
payment_url = await self.cloudpayments_service.generate_payment_link(
telegram_id=telegram_id,
amount_kopeks=amount_kopeks,
invoice_id=invoice_id,
description=description,
email=email,
)
except CloudPaymentsAPIError as error:
logger.error("Ошибка создания CloudPayments платежа: %s", error)
return None
except Exception as error:
logger.exception("Непредвиденная ошибка при создании CloudPayments платежа: %s", error)
return None
metadata = {
"language": language or settings.DEFAULT_LANGUAGE,
"telegram_id": telegram_id,
}
# Create local payment record
local_payment = await payment_module.create_cloudpayments_payment(
db=db,
user_id=user_id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
description=description,
payment_url=payment_url,
metadata=metadata,
test_mode=settings.CLOUDPAYMENTS_TEST_MODE,
)
if not local_payment:
logger.error("Не удалось создать локальную запись CloudPayments платежа")
return None
logger.info(
"Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s",
invoice_id,
amount_kopeks / 100,
user_id,
)
return {
"payment_url": payment_url,
"invoice_id": invoice_id,
"payment_id": local_payment.id,
}
async def process_cloudpayments_pay_webhook(
self,
db: AsyncSession,
webhook_data: Dict[str, Any],
) -> bool:
"""
Process CloudPayments Pay webhook (successful payment).
Args:
db: Database session
webhook_data: Parsed webhook data
Returns:
True if payment was processed successfully
"""
invoice_id = webhook_data.get("invoice_id")
transaction_id_cp = webhook_data.get("transaction_id")
amount = webhook_data.get("amount", 0)
amount_kopeks = int(amount * 100)
account_id = webhook_data.get("account_id", "")
token = webhook_data.get("token")
test_mode = webhook_data.get("test_mode", False)
if not invoice_id:
logger.error("CloudPayments webhook без invoice_id")
return False
payment_module = import_module("app.services.payment_service")
# Find existing payment record
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
if not payment:
logger.warning(
"CloudPayments платёж не найден: invoice=%s, создаём новый",
invoice_id,
)
# Try to extract telegram_id from account_id
try:
telegram_id = int(account_id) if account_id else None
except ValueError:
telegram_id = None
if not telegram_id:
logger.error("Не удалось определить telegram_id из account_id: %s", account_id)
return False
# Get user by telegram_id
from app.database.crud.user import get_user_by_telegram_id
user = await get_user_by_telegram_id(db, telegram_id)
if not user:
logger.error("Пользователь не найден: telegram_id=%s", telegram_id)
return False
# Create payment record
payment = await payment_module.create_cloudpayments_payment(
db=db,
user_id=user.id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
test_mode=test_mode,
)
if not payment:
logger.error("Не удалось создать запись платежа")
return False
# Check if already processed
if payment.is_paid:
logger.info("CloudPayments платёж уже обработан: invoice=%s", invoice_id)
return True
# Update payment record
payment.transaction_id_cp = transaction_id_cp
payment.status = "completed"
payment.is_paid = True
payment.paid_at = datetime.utcnow()
payment.token = token
payment.card_first_six = webhook_data.get("card_first_six")
payment.card_last_four = webhook_data.get("card_last_four")
payment.card_type = webhook_data.get("card_type")
payment.card_exp_date = webhook_data.get("card_exp_date")
payment.email = webhook_data.get("email")
payment.test_mode = test_mode
payment.callback_payload = webhook_data
await db.flush()
# Get user
from app.database.crud.user import get_user_by_id, add_user_balance
user = await get_user_by_id(db, payment.user_id)
if not user:
logger.error("Пользователь не найден: id=%s", payment.user_id)
return False
# Add balance
await add_user_balance(db, user.id, amount_kopeks)
# Create transaction record
from app.database.crud.transaction import create_transaction
transaction = await create_transaction(
db=db,
user_id=user.id,
type_=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
payment_method=PaymentMethod.CLOUDPAYMENTS,
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
is_completed=True,
)
payment.transaction_id = transaction.id
await db.commit()
logger.info(
"CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s",
invoice_id,
amount_kopeks / 100,
user.telegram_id,
)
# Send notification to user
try:
await self._send_cloudpayments_success_notification(
user=user,
amount_kopeks=amount_kopeks,
transaction=transaction,
)
except Exception as error:
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)
except Exception as error:
logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as error:
logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
return True
async def process_cloudpayments_fail_webhook(
self,
db: AsyncSession,
webhook_data: Dict[str, Any],
) -> bool:
"""
Process CloudPayments Fail webhook (failed payment).
Args:
db: Database session
webhook_data: Parsed webhook data
Returns:
True if processed successfully
"""
invoice_id = webhook_data.get("invoice_id")
reason = webhook_data.get("reason", "Unknown")
reason_code = webhook_data.get("reason_code")
card_holder_message = webhook_data.get("card_holder_message", reason)
account_id = webhook_data.get("account_id", "")
if not invoice_id:
logger.warning("CloudPayments fail webhook без invoice_id")
return True
payment_module = import_module("app.services.payment_service")
# Find payment record
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
if payment:
payment.status = "failed"
payment.callback_payload = webhook_data
await db.commit()
logger.info(
"CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)",
invoice_id,
reason,
reason_code,
)
# Notify user about failed payment
try:
telegram_id = int(account_id) if account_id else None
if telegram_id:
await self._send_cloudpayments_fail_notification(
telegram_id=telegram_id,
message=card_holder_message,
)
except Exception as error:
logger.exception("Ошибка отправки уведомления о неуспешном платеже: %s", error)
return True
async def _send_cloudpayments_success_notification(
self,
user: Any,
amount_kopeks: int,
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from app.bot import bot
from app.localization.texts import get_texts
if not bot:
return
texts = get_texts(user.language)
keyboard = await self.build_topup_success_keyboard(user)
referrer_info = format_referrer_info(user)
amount_rub = amount_kopeks / 100
new_balance = user.balance_kopeks / 100
message = texts.t(
"PAYMENT_SUCCESS_CLOUDPAYMENTS",
"✅ <b>Оплата получена!</b>\n\n"
"💰 Сумма: {amount}\n"
"💳 Способ: CloudPayments\n"
"💵 Баланс: {balance}\n\n"
"Спасибо за пополнение!",
).format(
amount=f"{amount_rub:.2f}",
balance=f"{new_balance:.2f}",
)
if referrer_info:
message += f"\n\n{referrer_info}"
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.warning("Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, error)
async def _send_cloudpayments_fail_notification(
self,
telegram_id: int,
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from app.bot import bot
if not bot:
return
text = f"❌ <b>Оплата не прошла</b>\n\n{message}"
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode="HTML",
)
except Exception as error:
logger.warning("Не удалось отправить уведомление пользователю %s: %s", telegram_id, error)
async def get_cloudpayments_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> Optional[Dict[str, Any]]:
"""
Check CloudPayments payment status via API.
Args:
db: Database session
local_payment_id: Internal payment ID
Returns:
Dict with payment info or None if not found
"""
payment_module = import_module("app.services.payment_service")
# Get local payment record
payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id)
if not payment:
logger.warning("CloudPayments payment not found: id=%s", local_payment_id)
return None
# If already paid, return current state
if payment.is_paid:
return {"payment": payment, "status": "completed"}
# Check with CloudPayments API
if not getattr(self, "cloudpayments_service", None):
logger.warning("CloudPayments service not initialized")
return {"payment": payment, "status": payment.status}
try:
# Try to find payment by invoice_id
api_response = await self.cloudpayments_service.find_payment(payment.invoice_id)
if not api_response.get("Success"):
logger.debug(
"CloudPayments API: payment not found or error for invoice=%s",
payment.invoice_id,
)
return {"payment": payment, "status": payment.status}
model = api_response.get("Model", {})
api_status = model.get("Status", "")
transaction_id_cp = model.get("TransactionId")
# Update local record if status changed
if api_status == "Completed" and not payment.is_paid:
# Payment completed - process it
webhook_data = {
"invoice_id": payment.invoice_id,
"transaction_id": transaction_id_cp,
"amount": model.get("Amount", 0),
"account_id": model.get("AccountId", ""),
"token": model.get("Token"),
"card_first_six": model.get("CardFirstSix"),
"card_last_four": model.get("CardLastFour"),
"card_type": model.get("CardType"),
"card_exp_date": model.get("CardExpDate"),
"email": model.get("Email"),
"test_mode": model.get("TestMode", False),
"status": api_status,
}
await self.process_cloudpayments_pay_webhook(db, webhook_data)
await db.refresh(payment)
elif api_status in ("Declined", "Cancelled") and payment.status not in ("failed", "cancelled"):
payment.status = "failed"
await db.flush()
await db.refresh(payment)
return {"payment": payment, "status": payment.status}
except Exception as error:
logger.error(
"Error checking CloudPayments payment status: id=%s, error=%s",
local_payment_id,
error,
)
return {"payment": payment, "status": payment.status}
+1 -3
View File
@@ -7,7 +7,6 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from typing import Any
@@ -25,8 +24,7 @@ from app.services.subscription_checkout_service import (
)
from app.services.user_cart_service import user_cart_service
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class PaymentCommonMixin:
+19 -3
View File
@@ -1,7 +1,7 @@
"""Mixin с логикой обработки платежей CryptoBot."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import datetime
@@ -14,6 +14,7 @@ from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.services.subscription_renewal_service import (
@@ -27,8 +28,7 @@ from app.services.subscription_renewal_service import (
)
from app.utils.currency_converter import currency_converter
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
renewal_service = SubscriptionRenewalService()
@@ -376,6 +376,22 @@ class CryptoBotPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(
db,
user,
bot=bot_instance,
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and bot_instance:
from app.localization.texts import get_texts
+1 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import secrets
import time
from datetime import datetime, timezone, timedelta
@@ -14,8 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class HeleketPaymentMixin:
+15 -4
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import uuid
from importlib import import_module
from typing import Any, Dict, Optional
@@ -12,11 +11,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class MulenPayPaymentMixin:
@@ -396,11 +395,23 @@ class MulenPayPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
# Если у пользователя есть сохраненная корзина,
# отправляем ему уведомление с кнопкой вернуться к оформлению
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER_DETAILED",
+14 -3
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
from datetime import datetime
from importlib import import_module
import uuid
@@ -14,11 +13,11 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.pal24_service import Pal24APIError
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class Pal24PaymentMixin:
@@ -499,6 +498,18 @@ class Pal24PaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts
+17 -5
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime
from importlib import import_module
@@ -14,11 +13,11 @@ from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.platega_service import PlategaService
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
logger = logging.getLogger(__name__)
from app.utils.payment_logger import payment_logger as logger
class PlategaPaymentMixin:
@@ -360,11 +359,12 @@ class PlategaPaymentMixin:
PaymentMethod.PLATEGA,
)
platega_name = settings.get_platega_display_name()
method_display = settings.get_platega_method_display_name(payment.payment_method_code)
description = (
f"Пополнение через Platega ({method_display})"
f"Пополнение через {platega_name} ({method_display})"
if method_display
else "Пополнение через Platega"
else f"Пополнение через {platega_name}"
)
transaction = existing_transaction
@@ -484,6 +484,18 @@ class PlategaPaymentMixin:
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
await auto_activate_subscription_after_topup(db, user)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
if has_saved_cart and getattr(self, "bot", None):
from app.localization.texts import get_texts

Some files were not shown because too many files have changed in this diff Show More