Compare commits

...

59 Commits

Author SHA1 Message Date
dependabot[bot] 7bc29c3421 deps(deps): bump the python-dependencies group across 1 directory with 10 updates
---
updated-dependencies:
- dependency-name: aiogram
  dependency-version: 3.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: qrcode[pil]
  dependency-version: '8.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: aiohttp
  dependency-version: 3.13.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic
  dependency-version: 2.12.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: pydantic-settings
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-dotenv
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: uvicorn
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
- dependency-name: python-multipart
  dependency-version: 0.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: apscheduler
  dependency-version: 3.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: python-dependencies
- dependency-name: email-validator
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: python-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-16 04:34:17 +00:00
Egor 448be6e512 Merge pull request #2606 from BEDOLAGA-DEV/dev
Dev
2026-02-16 07:31:48 +03:00
Fringg 871ceb866c fix: replace deprecated Query(regex=) with pattern= 2026-02-16 07:11:58 +03:00
Fringg 8e61fe4774 fix: handle TelegramBadRequest in ticket edit_message_text calls
Wrap all edit_message_text calls in ticket handlers with try/except
TelegramBadRequest fallback to message.answer(). Fixes crash when
the prompt message was deleted or has no text (e.g. photo message).
2026-02-16 07:09:06 +03:00
Fringg d4dfa235e5 chore: update all dependencies to latest stable versions
Security: cryptography 41.0→44.0+ (4 CVEs patched)
Major: redis 5.0→7.1, fastapi 0.115→0.129, bcrypt 4.2→5.0
Minor: sqlalchemy 2.0.46, alembic 1.18.4, asyncpg 0.31,
  aiosqlite 0.22, qrcode 8.0, packaging 26.0, pyjwt 2.11,
  yookassa 3.10, pyyaml 6.0.3
2026-02-16 06:59:48 +03:00
Fringg 97ec39aa80 fix: add promo code anti-abuse protections
- Rate-limit on brute-force: 5 failed attempts per 5 min blocks user
- Daily stacking limit: max 5 promo activations per 24h (in-memory + DB)
- Format validation: only alphanumeric/hyphen/underscore, 3-50 chars
2026-02-16 06:52:45 +03:00
Fringg 61a97220d3 fix: add /start burst rate-limit to prevent spam abuse
Sliding window limiter: max 3 /start calls per 60 seconds per user.
Runs before the general 0.5s throttle. Shows cooldown timer on block.
Lazy cleanup of start_buckets when size exceeds 500 entries.
2026-02-16 06:41:14 +03:00
Egor 2d04f2aa28 Merge pull request #2605 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.12.0
2026-02-16 02:20:45 +03:00
github-actions[bot] d6e79161e7 chore(main): release 3.12.0 2026-02-15 23:20:25 +00:00
Egor 45fd543206 Merge pull request #2604 from BEDOLAGA-DEV/dev
Dev
2026-02-16 02:20:02 +03:00
Fringg ba0a5e9abd fix: handle tariff_extend callback without period (back button crash)
The 'Back' button on tariff extend confirmation sends
tariff_extend:{id} without a period segment, which crashed
select_tariff_extend_period with IndexError on parts[2].
Now redirects to show_tariff_extend when period is missing.
2026-02-16 01:38:04 +03:00
Fringg d712ab8301 fix: remove redundant trial inactivity monitoring checks
Remnawave already sends user.not_connected webhooks, making the
monitoring service's 1h/24h trial inactivity checks redundant.
The monitoring checks caused false positives because they relied on
traffic_used_gb which may not be synced in real-time.

Removed:
- _check_trial_inactivity_notifications from monitoring cycle
- _send_trial_inactive_notification method
- trial_inactive_1h / trial_inactive_24h notification settings
- Admin UI toggles and preview buttons for these notifications
2026-02-16 00:58:24 +03:00
Fringg 1e2a7e3096 fix: webhook notification 'My Subscription' button uses unregistered callback_data
Changed callback_data from 'subscription' (no handler) to 'menu_subscription'
(registered handler) in _get_subscription_keyboard and _get_traffic_keyboard.
In cabinet mode the button opens a WebApp URL so the bug was invisible,
but in default MAIN_MENU_MODE the callback went unhandled.
2026-02-16 00:30:17 +03:00
Fringg 64a684cd2f fix: filter out traffic packages with zero price from purchase options 2026-02-15 23:32:15 +03:00
Fringg e4c207ecff chore: format files with ruff 2026-02-15 23:18:44 +03:00
Fringg 80914c1af7 fix: daily tariff subscriptions stuck in expired/disabled with no resume path
- Keyboard now shows "Возобновить" for disabled/expired daily tariffs
  instead of useless "Приостановить"
- resume_daily_subscription handles EXPIRED→ACTIVE (not only DISABLED)
- Pause handler detects inactive status and calls resume directly
- subscription_extend redirects daily tariffs to subscription info
  (daily tariffs have no period_prices, so extend page was empty)
2026-02-15 23:17:45 +03:00
Fringg e1822800ab fix: handle photo message in ticket creation flow
Ticket creation crashed with "there is no text in the message to edit"
when initiated from the tickets list (rendered as photo with logo).
2026-02-15 22:58:31 +03:00
Fringg 68773b7e77 feat: add per-button enable/disable toggle and custom labels per locale
- Add enabled flag to hide/show each button section in main menu
- Add per-locale custom labels (ru, en, ua, zh, fa) for button text
- Deep-copy nested labels dict in cache to prevent reference leaks
- Validate label entries from DB (type + locale key checks)
- Use selective merge in PATCH handler instead of blind .update()
2026-02-12 23:42:55 +03:00
Fringg 10538e7351 feat: add 'default' (no color) option for button styles
Allow admins to set buttons to Telegram's default style with no color
override. Refactors style resolution from or-chain to explicit if/elif/else
so that 'default' does not fall through to global config or hardcoded defaults.
2026-02-12 23:25:42 +03:00
Fringg a9687912df feat: add per-section button style and emoji customization via admin API
Add cabinet admin API for configuring button colors (primary/success/danger)
and custom emoji IDs per menu section (home, subscription, balance, referral,
support, info, admin). Styles are stored as JSON in system_settings and cached
in-process for fast resolution.

Style resolution chain: explicit param > per-section DB > global config > defaults.
2026-02-12 23:15:58 +03:00
Fringg 46c1a69456 fix: pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults 2026-02-12 22:43:30 +03:00
Fringg bf2b2f1c56 feat: add button style and emoji support for cabinet mode (Bot API 9.4)
- Upgrade aiogram to 3.25.0 for style/icon_custom_emoji_id support
- Add CABINET_BUTTON_STYLE config for global color override
- Per-section default styles: subscription (green), balance (blue),
  referral (green), admin (red), home (blue)
- Style priority: explicit > CABINET_BUTTON_STYLE > per-section default
- Add icon_custom_emoji_id pass-through for Premium bot owners
- Admin panel setting for button style with color picker
2026-02-12 22:34:38 +03:00
Fringg 9ac6da490d feat: add web admin button for admins in cabinet mode 2026-02-12 22:22:28 +03:00
Fringg ad87c5fb5e feat: rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections
- Rename mode from 'text' to 'cabinet' (text/text_only/minimal kept as aliases)
- Add build_cabinet_url() for joining MINIAPP_CUSTOM_URL with section paths
- Cabinet main menu now has section-specific buttons: subscription, balance,
  referral, support, info — each opens the corresponding cabinet page
- Add CALLBACK_TO_CABINET_PATH mapping for automatic deep-linking from
  callback_data to cabinet routes (/subscription, /balance, /referral, etc.)
- Unmapped callback_data gracefully falls back to regular Telegram callbacks
- Add startup validation warning when cabinet mode is active without MINIAPP_CUSTOM_URL
- Update admin broadcast buttons with section-specific routing
- Backward compatible: is_text_main_menu_mode() kept as alias for is_cabinet_mode()
2026-02-12 22:21:08 +03:00
Egor 7ac73e5745 Merge pull request #2600 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.11.0
2026-02-12 21:12:59 +03:00
github-actions[bot] 61be89743d chore(main): release 3.11.0 2026-02-12 18:12:13 +00:00
Egor d174d9a927 Merge pull request #2599 from BEDOLAGA-DEV/dev
Dev
2026-02-12 21:11:44 +03:00
Fringg 4048aebb9f chore: format models.py 2026-02-12 21:08:05 +03:00
Fringg bfd66c42c1 fix: add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete 2026-02-12 20:59:28 +03:00
Fringg 351c95bac1 chore: change SALES_MODE default to tariffs 2026-02-12 20:55:52 +03:00
Fringg 1d43ae5e25 fix: add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode 2026-02-12 20:43:12 +03:00
Fringg 476b89fe8e feat: add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL 2026-02-12 20:38:33 +03:00
Fringg 14e13177b5 chore: change CONNECT_BUTTON_MODE default to miniapp_subscription 2026-02-12 20:35:34 +03:00
Fringg 760c833b74 fix: ticket creation crash and webhook PendingRollbackError
- tickets.py: remove ENABLE_LOGO_MODE branches that used edit_message_caption
  on text messages (prompt is always text, not photo with caption)
- webhook_service: add db.rollback() before retrying DB ops in _handle_user_deleted
  when subscription was cascade-deleted, catch PendingRollbackError alongside StaleDataError
2026-02-12 20:32:52 +03:00
Fringg 1a476c49c1 feat: add cabinet admin API for pinned messages management
- Full CRUD + broadcast/unpin/activate/deactivate endpoints
- Admin auth required on all endpoints (get_current_admin_user)
- Broadcast cooldown (60s) on all mass operation endpoints
- Cached Bot singleton to prevent aiohttp session leaks
- Guard against deleting active pinned messages (409 Conflict)
- Route ordering: /active/* before /{message_id}/* to prevent path conflicts
- Pydantic schemas with proper validation (file_id max_length=255)
2026-02-12 19:13:51 +03:00
Fringg 454b83138e fix: flood control handling in pinned messages and XSS hardening in HTML sanitizer
- Add retry loop with backoff to _unpin_message_for_user (max 3 attempts)
- Add TelegramRetryAfter handling in _send_and_pin_message (unpin + send phases)
- Fix missing failed_count increment when all broadcast retries exhaust (for/else)
- Remove dead code in unpin_active_pinned_message (unreachable TelegramRetryAfter catch)
- Harden sanitize_html: allowlist URI schemes (http/https/tg/mailto/tel), whitelist
  tag attributes, strip all attrs from tags without explicit whitelist, full HTML
  entity decoding via html.unescape
2026-02-12 19:13:40 +03:00
Fringg 2de438426a fix: suppress expired callback query error in AuthMiddleware
Catch TelegramBadRequest with "query is too old" before generic Exception handler
to prevent it from being logged as error and triggering error reports.
2026-02-12 18:43:16 +03:00
Egor 6039db997c Merge pull request #2597 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.3
2026-02-12 07:10:05 +03:00
github-actions[bot] 940959c951 chore(main): release 3.10.3 2026-02-12 04:06:02 +00:00
Egor e688110129 Merge pull request #2596 from BEDOLAGA-DEV/dev
Dev
2026-02-12 07:05:38 +03:00
Fringg 57dc1ff47f fix: resolve deadlock on server_squads counter updates and add webhook notification toggles
- Fix deadlock: enforce sorted lock ordering in add_user_to_servers/remove_user_from_servers
- Fix cross-call deadlock: add update_server_user_counts() for atomic add+remove in one sorted pass
- Fix deadlock in squad migration: use sorted dict iteration for counter updates
- Fix broken "Buy traffic" button: subscription_add_traffic → buy_traffic callback_data
- Add 12 webhook notification toggle settings (WEBHOOK_NOTIFY_*) with master toggle
- Add admin UI category "Уведомления от вебхуков" with hints in BotConfigurationService
- Add toggle check in _notify_user() respecting master and per-event settings
2026-02-12 06:47:26 +03:00
Fringg fc42916b10 fix: harden backup create/restore against serialization and constraint errors
- Backup creation: handle Decimal, float NaN/Inf, fallback for JSON column dumps
- Restore users: savepoint per INSERT to survive duplicate telegram_id/email/referral_code
- Restore associations: savepoint per INSERT to survive FK or duplicate constraint violations
- Restore table records: savepoint already added in prior commit
2026-02-12 03:41:24 +03:00
Fringg 5893874776 fix: handle unique constraint conflicts during backup restore without clear_existing 2026-02-12 03:37:36 +03:00
Egor 60305d8d5b Merge pull request #2595 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.2
2026-02-12 03:07:43 +03:00
github-actions[bot] 07ef3b46d9 chore(main): release 3.10.2 2026-02-12 00:07:06 +00:00
Egor f9d58e964c Merge pull request #2594 from BEDOLAGA-DEV/dev
Dev
2026-02-12 03:06:34 +03:00
Fringg d3c14ac303 fix: UnboundLocalError for get_logo_media in required_sub_channel_check 2026-02-12 02:56:14 +03:00
Fringg fda9f3beec fix: suppress bot-blocked-by-user error in AuthMiddleware 2026-02-12 02:53:05 +03:00
Fringg 27365b3c75 fix: handle time/date types in backup JSON serialization 2026-02-12 02:51:20 +03:00
Fringg 3dac332a9f chore: ruff format 7 files 2026-02-11 21:50:49 +03:00
Fringg c5124b97b6 fix: payment race conditions, balance atomicity, renewal rollback safety
- YooKassa: SELECT FOR UPDATE on payment row to prevent concurrent double-processing
- subtract_user_balance: row locking to prevent concurrent balance race conditions
- subtract_user_balance: transaction creation before commit for atomicity
- subscription renewal: compensating refund if extend_subscription fails after charge
- StaleDataError: use savepoint instead of full rollback to protect parent transaction
2026-02-11 21:49:37 +03:00
Fringg ee2e79db31 refactor: remove modem functionality from classic subscriptions
Remove all modem purchase/management code:
- Delete modem handler, service, and tests
- Remove modem button from keyboards and admin panel
- Remove modem pricing from calculations
- Remove modem REST API endpoint and schemas
- Remove modem decorator, config settings, and notification formatting
- Keep DB column and migration for backwards compatibility
2026-02-11 21:14:08 +03:00
Fringg d05ff678ab fix: HTML parse fallback, email change race condition, username length limit
- start.py: retry welcome message with parse_mode=None on TelegramBadRequest HTML parse error
- auth.py: handle IntegrityError race condition on email change, wrap email sending in try-except
- config.py: truncate RemnaWave username to 36 chars (API limit) instead of 64
2026-02-11 20:51:50 +03:00
Fringg fcaa9dfb27 fix: clean stale squad UUIDs from tariffs during server sync
When squads are deleted from the RemnaWave panel and servers are synced,
the bot cleaned subscription connected_squads but left stale UUIDs in
tariff.allowed_squads. This caused errors when users tried to purchase
or extend subscriptions with tariffs referencing deleted squads.

Now sync_with_remnawave also removes stale UUIDs from all tariffs.
2026-02-11 18:37:19 +03:00
Fringg c30c2feee1 fix: handle StaleDataError in webhook user.deleted server counter decrement
When a user is deleted from the panel, the subscription may already be
cascade-deleted by the time the webhook handler tries to decrement
server counters. This caused StaleDataError followed by
PendingRollbackError when accessing subscription.id in the error handler.

- Save subscription.id before DB operations to avoid lazy load after rollback
- Catch StaleDataError explicitly and rollback the session
- Re-fetch subscription/user after potential rollback in _handle_user_deleted
- Skip subscription cleanup if it was already cascade-deleted
2026-02-11 18:35:36 +03:00
Fringg 640da34736 fix: remove DisplayNameRestrictionMiddleware
Blocking users based on display name patterns caused false positives
for legitimate users. Removed middleware registration from dispatcher.
2026-02-11 18:31:50 +03:00
Fringg 93bb8e0eb4 fix: allow email change for unverified emails
Unverified email users could not change their email (e.g. to fix a typo)
because the endpoint required email_verified=True. Now unverified emails
are replaced directly without code verification, and a new verification
email is sent to the updated address.
2026-02-11 18:28:52 +03:00
Fringg 7d9ced8f4f fix: delete subscription_servers before subscription to prevent FK violation
reset_user_subscription and reset_trial endpoints did not clean up
subscription_servers rows before deleting the subscription, causing
ForeignKeyViolationError on subscription_servers.subscription_id_fkey.

Also fixed the same missing cleanup in user_service.hard_delete_user.
2026-02-11 18:25:42 +03:00
Fringg b5998ea9d2 fix: use traffic topup config and add WATA 429 retry
- Cabinet API: use get_traffic_topup_packages() instead of
  get_traffic_packages() in classic mode endpoints (lines 622, 727, 2410)
  to prevent infinite free traffic exploit via initial-purchase packages
- WATA service: add retry logic for 429 rate limit responses with
  Retry-After parsing from header and response body, up to 2 retries,
  downgrade 429 from error to warning log level
2026-02-11 18:20:30 +03:00
64 changed files with 2402 additions and 2143 deletions
+39 -2
View File
@@ -197,6 +197,32 @@ REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
WEBHOOK_NOTIFY_USER_ENABLED=true
# Отключение/активация подписки администратором
WEBHOOK_NOTIFY_SUB_STATUS=true
# Истечение подписки
WEBHOOK_NOTIFY_SUB_EXPIRED=true
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
WEBHOOK_NOTIFY_SUB_EXPIRING=true
# Достижение лимита трафика
WEBHOOK_NOTIFY_SUB_LIMITED=true
# Сброс счётчика трафика
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
# Удаление пользователя из панели
WEBHOOK_NOTIFY_SUB_DELETED=true
# Обновление ключей подписки (revoke)
WEBHOOK_NOTIFY_SUB_REVOKED=true
# Первое подключение к VPN
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
# Напоминание о неподключении
WEBHOOK_NOTIFY_NOT_CONNECTED=true
# Предупреждение о приближении к лимиту трафика
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
# Подключение и отключение устройств
WEBHOOK_NOTIFY_DEVICES=true
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
@@ -671,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
@@ -685,7 +722,7 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.10.1"
".": "3.12.0"
}
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
### New Features
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
### Bug Fixes
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
### New Features
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
### Bug Fixes
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
### Bug Fixes
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
### Bug Fixes
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
### Refactoring
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.10.1" # x-release-please-version
ARG VERSION="v3.12.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+37 -5
View File
@@ -63,7 +63,6 @@ from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
from app.middlewares.logging import LoggingMiddleware
from app.middlewares.maintenance import MaintenanceMiddleware
@@ -124,10 +123,6 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
dp.pre_checkout_query.middleware(display_name_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
@@ -215,6 +210,43 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info('Мониторинг техработ отключен настройками')
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
# Validate CONNECT_BUTTON_MODE dependencies
if not settings.get_happ_cryptolink_redirect_template():
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" не будет отображаться.'
)
elif settings.CONNECT_BUTTON_MODE == 'guide':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
)
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
'Кнопка "Подключиться" не будет работать.'
)
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
'Кнопки кабинета не смогут открывать разделы MiniApp. '
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.info('Бот успешно настроен')
return bot, dp
+4
View File
@@ -5,10 +5,12 @@ from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
@@ -89,6 +91,8 @@ router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)
+253
View File
@@ -0,0 +1,253 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+397
View File
@@ -0,0 +1,397 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
deactivate_active_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
PinnedMessageListResponse,
PinnedMessageResponse,
PinnedMessageSettingsRequest,
PinnedMessageUnpinResponse,
PinnedMessageUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
# Broadcast cooldown: min 60 seconds between mass operations
_BROADCAST_COOLDOWN_SECONDS = 60
_last_broadcast_time: float = 0.0
def _check_broadcast_cooldown() -> None:
global _last_broadcast_time
now = time.monotonic()
elapsed = now - _last_broadcast_time
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f'Broadcast cooldown active. Try again in {remaining} seconds.',
)
_last_broadcast_time = now
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
return PinnedMessageResponse(
id=msg.id,
content=msg.content,
media_type=msg.media_type,
media_file_id=msg.media_file_id,
send_before_menu=msg.send_before_menu,
send_on_every_start=msg.send_on_every_start,
is_active=msg.is_active,
created_by=msg.created_by,
created_at=msg.created_at,
updated_at=msg.updated_at,
)
_cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return _cached_bot
# ============ List / Get Endpoints ============
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
active_only: bool = Query(False),
) -> PinnedMessageListResponse:
"""Get list of pinned messages with pagination."""
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
count_query = select(func.count(PinnedMessage.id))
if active_only:
query = query.where(PinnedMessage.is_active.is_(True))
count_query = count_query.where(PinnedMessage.is_active.is_(True))
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
items = result.scalars().all()
return PinnedMessageListResponse(
items=[_serialize_pinned_message(msg) for msg in items],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
msg = await get_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
return _serialize_pinned_message(msg)
# ============ Create / Update Endpoints ============
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Create a new pinned message.
Automatically deactivates previous active message.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if payload.broadcast:
_check_broadcast_cooldown()
content = payload.content.strip()
if not content and not payload.media:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
media_type = payload.media.type if payload.media else None
media_file_id = payload.media.file_id if payload.media else None
try:
msg = await set_active_pinned_message(
db=db,
content=content,
created_by=admin.id,
media_type=media_type,
media_file_id=media_file_id,
send_before_menu=payload.send_before_menu,
send_on_every_start=payload.send_on_every_start,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
sent_count = 0
failed_count = 0
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.content is not None:
sanitized = sanitize_html(payload.content)
is_valid, error = validate_html_tags(sanitized)
if not is_valid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
msg.content = sanitized
if payload.media is not None:
msg.media_type = payload.media.type
msg.media_file_id = payload.media.file_id
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
return _serialize_pinned_message(msg)
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
# ============ Active Message Actions (before /{message_id} POST routes) ============
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
msg = await deactivate_active_pinned_message(db)
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
return _serialize_pinned_message(msg)
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
_check_broadcast_cooldown()
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
failed_count=failed_count,
was_active=was_active,
)
# ============ Per-Message Actions ============
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Activate a pinned message.
Deactivates the current active message and activates the specified one.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if broadcast:
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if msg.is_active:
raise HTTPException(
status.HTTP_409_CONFLICT,
'Cannot delete active pinned message. Deactivate it first.',
)
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
+5
View File
@@ -27,6 +27,7 @@ from app.database.crud.user import (
from app.database.models import (
PromoGroup,
Subscription,
SubscriptionServer,
SubscriptionStatus,
TrafficPurchase,
Transaction,
@@ -1806,6 +1807,8 @@ async def reset_user_trial(
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
@@ -1876,6 +1879,8 @@ async def reset_user_subscription(
# Delete subscription from database
from sqlalchemy import delete
subscription_id = user.subscription.id
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription_id))
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
+67 -5
View File
@@ -7,6 +7,7 @@ from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -969,14 +970,13 @@ async def request_email_change(
"""
Request email change.
Sends a 6-digit verification code to the new email address.
User must have a verified email to change it.
For verified emails: sends a 6-digit verification code to the new email.
For unverified emails: replaces the email directly and sends verification to the new address.
"""
# Check if user has a verified email
if not user.email or not user.email_verified:
if not user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You must have a verified email to change it',
detail='No email address to change',
)
# Check if new email is the same as current
@@ -1000,6 +1000,68 @@ async def request_email_change(
detail='This email is already registered',
)
# Unverified email: replace directly and send verification to new address
if not user.email_verified:
old_email = user.email
user.email = request.new_email.lower()
user.email_verified = False
verification_token = generate_verification_token()
verification_expires = get_verification_expires_at()
user.email_verification_token = verification_token
user.email_verification_expires = verification_expires
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This email is already registered',
)
if settings.is_cabinet_email_verification_enabled() and email_service.is_configured():
cabinet_url = settings.CABINET_URL
verification_url = f'{cabinet_url}/verify-email'
lang = user.language or 'ru'
full_url = f'{verification_url}?token={verification_token}'
expire_hours = settings.get_cabinet_email_verification_expire_hours()
override = await get_rendered_override(
'email_verification',
lang,
context={
'username': user.first_name or '',
'verification_url': full_url,
'expire_hours': str(expire_hours),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
try:
await asyncio.to_thread(
email_service.send_verification_email,
to_email=request.new_email,
verification_token=verification_token,
verification_url=verification_url,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
except Exception as e:
logger.error(f'Failed to send verification email to {request.new_email} for user {user.id}: {e}')
logger.info(f'Unverified email replaced for user {user.id}: {old_email} -> {request.new_email}')
return EmailChangeResponse(
message='Email replaced, verification sent to new address',
new_email=request.new_email,
expires_in_minutes=0,
)
# Verified email: send code to new address for confirmation
# Generate verification code
code = generate_email_change_code()
expires_at = get_email_change_expires_at()
+17 -3
View File
@@ -598,6 +598,8 @@ async def get_traffic_packages(
result = []
for gb, price in packages.items():
if price <= 0:
continue
result.append(
TrafficPackageResponse(
gb=gb,
@@ -619,12 +621,14 @@ async def get_traffic_packages(
if tariff and not tariff.allow_traffic_topup:
return []
packages = settings.get_traffic_packages()
packages = settings.get_traffic_topup_packages()
result = []
for pkg in packages:
if not pkg.get('enabled', True):
continue
if pkg['price'] <= 0:
continue
result.append(
TrafficPackageResponse(
@@ -705,6 +709,11 @@ async def purchase_traffic(
detail=f'Traffic package {request.gb}GB is not available',
)
base_price_kopeks = packages[request.gb]
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Traffic package {request.gb}GB has no price configured',
)
else:
# Classic режим
@@ -724,7 +733,7 @@ async def purchase_traffic(
)
# Получаем цену из глобальных настроек
packages = settings.get_traffic_packages()
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
@@ -732,6 +741,11 @@ async def purchase_traffic(
detail='Invalid traffic package',
)
base_price_kopeks = matching_pkg['price']
if base_price_kopeks <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Traffic package has no price configured',
)
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
@@ -2407,7 +2421,7 @@ async def save_traffic_cart(
detail='Докупка трафика отключена',
)
packages = settings.get_traffic_packages()
packages = settings.get_traffic_topup_packages()
matching_pkg = next((pkg for pkg in packages if pkg['gb'] == request.gb and pkg.get('enabled', True)), None)
if not matching_pkg:
raise HTTPException(
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for cabinet pinned messages."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PinnedMessageMedia(BaseModel):
type: str = Field(pattern=r'^(photo|video)$')
file_id: str = Field(..., min_length=1, max_length=255)
class PinnedMessageCreateRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=4000)
media: PinnedMessageMedia | None = None
send_before_menu: bool = True
send_on_every_start: bool = True
broadcast: bool = False
class PinnedMessageUpdateRequest(BaseModel):
content: str | None = Field(None, max_length=4000)
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
media: PinnedMessageMedia | None = None
class PinnedMessageSettingsRequest(BaseModel):
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
class PinnedMessageResponse(BaseModel):
id: int
content: str | None
media_type: str | None = None
media_file_id: str | None = None
send_before_menu: bool
send_on_every_start: bool
is_active: bool
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
class PinnedMessageBroadcastResponse(BaseModel):
message: PinnedMessageResponse
sent_count: int
failed_count: int
class PinnedMessageUnpinResponse(BaseModel):
unpinned_count: int
failed_count: int
was_active: bool
class PinnedMessageListResponse(BaseModel):
items: list[PinnedMessageResponse]
total: int
limit: int
offset: int
+33 -76
View File
@@ -110,6 +110,20 @@ class Settings(BaseSettings):
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
WEBHOOK_NOTIFY_SUB_STATUS: bool = True
WEBHOOK_NOTIFY_SUB_EXPIRED: bool = True
WEBHOOK_NOTIFY_SUB_EXPIRING: bool = True
WEBHOOK_NOTIFY_SUB_LIMITED: bool = True
WEBHOOK_NOTIFY_TRAFFIC_RESET: bool = True
WEBHOOK_NOTIFY_SUB_DELETED: bool = True
WEBHOOK_NOTIFY_SUB_REVOKED: bool = True
WEBHOOK_NOTIFY_FIRST_CONNECTED: bool = True
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
WEBHOOK_NOTIFY_DEVICES: bool = True
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
@@ -167,11 +181,6 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = 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 = ''
@@ -186,7 +195,7 @@ class Settings(BaseSettings):
# Режим продаж подписок:
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = 'classic'
SALES_MODE: str = 'tariffs'
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
@@ -507,8 +516,10 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
CONNECT_BUTTON_MODE: str = 'guide'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
MINIAPP_PURCHASE_URL: str = ''
@@ -741,15 +752,16 @@ class Settings(BaseSettings):
'default': 'default',
'full': 'default',
'standard': 'default',
'text': 'text',
'text_only': 'text',
'textual': 'text',
'minimal': 'text',
'cabinet': 'cabinet',
'text': 'cabinet',
'text_only': 'cabinet',
'textual': 'cabinet',
'minimal': 'cabinet',
}
mode = aliases.get(normalized, normalized)
if mode not in {'default', 'text'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
if mode not in {'default', 'cabinet'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
return mode
@field_validator('SERVER_STATUS_MODE', mode='before')
@@ -1057,7 +1069,7 @@ class Settings(BaseSettings):
if not sanitized_username:
sanitized_username = f'user_{identifier}'
return sanitized_username[:64]
return sanitized_username[:36]
@staticmethod
def parse_daily_time_list(raw_value: str | None) -> list[time]:
@@ -1356,8 +1368,12 @@ class Settings(BaseSettings):
def get_main_menu_mode(self) -> str:
return getattr(self, 'MAIN_MENU_MODE', 'default')
def is_cabinet_mode(self) -> bool:
return self.get_main_menu_mode() == 'cabinet'
def is_text_main_menu_mode(self) -> bool:
return self.get_main_menu_mode() == 'text'
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
return self.is_cabinet_mode()
def get_main_menu_miniapp_url(self) -> str | None:
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
@@ -1529,9 +1545,6 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == 'tariffs'
@@ -1542,68 +1555,12 @@ class Settings(BaseSettings):
def get_sales_mode(self) -> str:
"""Возвращает текущий режим продаж."""
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
def get_trial_tariff_id(self) -> int:
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
return max(0, self.TRIAL_TARIFF_ID)
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:
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
+11
View File
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
return result.scalar_one_or_none()
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(hours=hours)
result = await db.execute(
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
)
return result.scalar() or 0
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
+80 -2
View File
@@ -23,6 +23,7 @@ from app.database.models import (
Subscription,
SubscriptionServer,
SubscriptionStatus,
Tariff,
User,
)
@@ -362,6 +363,25 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
# Clean up stale UUIDs from tariff allowed_squads
cleaned_tariffs = 0
tariffs_result = await db.execute(select(Tariff))
for tariff in tariffs_result.scalars().all():
current = list(tariff.allowed_squads or [])
if not current:
continue
filtered = [u for u in current if u not in removed_uuids]
if len(filtered) != len(current):
tariff.allowed_squads = filtered
tariff.updated_at = datetime.utcnow()
cleaned_tariffs += 1
logger.info(
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
tariff.name,
tariff.id,
[u for u in current if u in removed_uuids],
)
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
@@ -371,6 +391,12 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
cleaned_subscriptions,
)
if cleaned_tariffs:
logger.info(
'🧹 Обновлены тарифы после удаления серверов: %s',
cleaned_tariffs,
)
await db.commit()
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
@@ -733,7 +759,7 @@ async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int
async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
try:
for server_id in server_squad_ids:
for server_id in sorted(server_squad_ids):
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
@@ -751,7 +777,7 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
try:
for server_id in server_squad_ids:
for server_id in sorted(server_squad_ids):
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
@@ -767,6 +793,58 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
raise
async def update_server_user_counts(
db: AsyncSession,
add_ids: list[int] | None = None,
remove_ids: list[int] | None = None,
) -> None:
"""Increment and decrement server user counters in a single sorted pass.
Prevents deadlocks by acquiring row locks in consistent ID order
across both add and remove operations within one transaction.
"""
try:
add_set = set(add_ids) if add_ids else set()
remove_set = set(remove_ids) if remove_ids else set()
if not add_set and not remove_set:
return
# IDs in both sets cancel out — skip them
overlap = add_set & remove_set
if overlap:
add_set -= overlap
remove_set -= overlap
all_ids = sorted(add_set | remove_set)
if not all_ids:
return
for server_id in all_ids:
if server_id in add_set:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=ServerSquad.current_users + 1)
)
if server_id in remove_set:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.flush()
if add_set:
logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set))
if remove_set:
logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set))
except Exception as e:
logger.error('Ошибка обновления счетчиков серверов: %s', e)
raise
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
result = await db.execute(select(ServerSquad.id).where(ServerSquad.squad_uuid.in_(squad_uuids)))
return [row[0] for row in result.fetchall()]
+30 -18
View File
@@ -6,6 +6,7 @@ from typing import Optional
from sqlalchemy import and_, delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy.orm.exc import StaleDataError
from app.config import settings
from app.database.crud.notification import clear_notifications
@@ -294,23 +295,22 @@ async def replace_subscription(
if update_server_counters:
try:
from app.database.crud.server_squad import (
add_user_to_servers,
get_server_ids_by_uuids,
remove_user_from_servers,
update_server_user_counts,
)
squads_to_remove = old_squads - new_squads
squads_to_add = new_squads - old_squads
if squads_to_remove:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
if server_ids:
await remove_user_from_servers(db, sorted(server_ids))
remove_ids = await get_server_ids_by_uuids(db, list(squads_to_remove)) if squads_to_remove else []
add_ids = await get_server_ids_by_uuids(db, list(squads_to_add)) if squads_to_add else []
if squads_to_add:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
if server_ids:
await add_user_to_servers(db, sorted(server_ids))
if remove_ids or add_ids:
await update_server_user_counts(
db,
add_ids=add_ids or None,
remove_ids=remove_ids or None,
)
logger.info(
'♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s',
@@ -625,6 +625,9 @@ async def decrement_subscription_server_counts(
if not subscription:
return
# Save ID before any DB operations that might invalidate the ORM object
sub_id = subscription.id
server_ids: set[int] = set()
if subscription_servers is not None:
@@ -633,12 +636,12 @@ async def decrement_subscription_server_counts(
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
ids_from_links = await get_subscription_server_ids(db, sub_id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
subscription.id,
sub_id,
error,
)
@@ -652,7 +655,7 @@ async def decrement_subscription_server_counts(
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
subscription.id,
sub_id,
error,
)
@@ -662,12 +665,20 @@ async def decrement_subscription_server_counts(
try:
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
async with db.begin_nested():
await remove_user_from_servers(db, list(server_ids))
except StaleDataError:
logger.warning(
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
sub_id,
list(server_ids),
)
except Exception as error:
logger.error(
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
list(server_ids),
subscription.id,
sub_id,
error,
)
@@ -1971,13 +1982,14 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE')
await db.commit()
await db.refresh(subscription)
+10 -3
View File
@@ -503,6 +503,10 @@ async def subtract_user_balance(
logger.info(f' 💸 Сумма к списанию: {amount_kopeks} копеек')
logger.info(f' 📝 Описание: {description}')
# Lock the user row to prevent concurrent balance race conditions
locked_result = await db.execute(select(User).where(User.id == user.id).with_for_update())
user = locked_result.scalar_one()
log_context: dict[str, object] | None = None
if consume_promo_offer:
try:
@@ -554,14 +558,13 @@ async def subtract_user_balance(
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
if create_transaction:
from app.database.crud.transaction import (
create_transaction as create_trans,
)
# create_trans commits the session, atomically persisting
# both the balance change and the transaction record
await create_trans(
db=db,
user_id=user.id,
@@ -570,6 +573,10 @@ async def subtract_user_balance(
description=description,
payment_method=payment_method,
)
else:
await db.commit()
await db.refresh(user)
if consume_promo_offer and log_context:
try:
+9 -5
View File
@@ -19,7 +19,7 @@ from sqlalchemy import (
UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
from sqlalchemy.sql import func
@@ -1153,8 +1153,12 @@ class Subscription(Base):
user = relationship('User', back_populates='subscription')
tariff = relationship('Tariff', back_populates='subscriptions')
discount_offers = relationship('DiscountOffer', back_populates='subscription')
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
temporary_accesses = relationship(
'SubscriptionTemporaryAccess', back_populates='subscription', passive_deletes=True
)
traffic_purchases = relationship(
'TrafficPurchase', back_populates='subscription', passive_deletes=True, cascade='all, delete-orphan'
)
@property
def is_active(self) -> bool:
@@ -1779,7 +1783,7 @@ class SentNotification(Base):
created_at = Column(DateTime, default=func.now())
user = relationship('User', backref='sent_notifications')
subscription = relationship('Subscription', backref='sent_notifications')
subscription = relationship('Subscription', backref=backref('sent_notifications', passive_deletes=True))
class SubscriptionEvent(Base):
@@ -2069,7 +2073,7 @@ class SubscriptionServer(Base):
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship('Subscription', backref='subscription_servers')
subscription = relationship('Subscription', backref=backref('subscription_servers', passive_deletes=True))
server_squad = relationship('ServerSquad', backref='subscription_servers')
+6 -3
View File
@@ -45,7 +45,7 @@ from app.services.pinned_message_service import (
)
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -76,12 +76,14 @@ async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
CABINET_MINIAPP_BUTTON_KEYS = {
'balance',
'referrals',
'promocode',
'connect',
'subscription',
'support',
'home',
}
@@ -106,11 +108,12 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
if settings.is_cabinet_mode() and button_key in CABINET_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config['text'],
callback_data=button_config['callback'],
cabinet_path=BUTTON_KEY_TO_CABINET_PATH.get(button_key, ''),
)
)
else:
+2 -139
View File
@@ -41,17 +41,13 @@ def _build_notification_settings_view(language: str):
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True))
trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True))
trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True))
trial_channel_status = _format_toggle(config.get('trial_channel_unsubscribed', {}).get('enabled', True))
expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True))
second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True))
third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True))
summary_text = (
'🔔 <b>Уведомления пользователям</b>\n\n'
f'• 1 час после триала: {trial_1h_status}\n'
f'• 24 часа после триала: {trial_24h_status}\n'
f'• Отписка от канала: {trial_channel_status}\n'
f'• 1 день после истечения: {expired_1d_status}\n'
f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n'
@@ -62,26 +58,6 @@ def _build_notification_settings_view(language: str):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h'
)
],
[
InlineKeyboardButton(
text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h'
)
],
[
InlineKeyboardButton(
text=f'{trial_channel_status} • Отписка от канала',
@@ -170,76 +146,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_inactive_1h':
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_inactive_24h':
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_channel_unsubscribed':
if notification_type == 'trial_channel_unsubscribed':
template = texts.get(
'TRIAL_CHANNEL_UNSUBSCRIBED',
(
@@ -535,48 +442,6 @@ async def admin_notify_settings(callback: CallbackQuery):
await callback.answer('❌ Не удалось загрузить настройки', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h')
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h')
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 1h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h')
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h')
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 24h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel')
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
@@ -668,8 +533,6 @@ async def preview_all_notifications(callback: CallbackQuery):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
'trial_inactive_1h',
'trial_inactive_24h',
'trial_channel_unsubscribed',
'expired_1d',
'expired_2d',
-71
View File
@@ -901,16 +901,6 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
],
]
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 settings.is_tariffs_mode():
keyboard.append(
@@ -3638,65 +3628,6 @@ async def set_user_devices_button(callback: types.CallbackQuery, db_user: User,
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Статус модема: {modem_status}\nЛимит устройств: {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()
@@ -5578,8 +5509,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(set_user_devices_button, F.data.startswith('admin_user_devices_set_'))
dp.callback_query.register(toggle_user_modem, F.data.startswith('admin_user_modem_'))
# Смена тарифа пользователя
dp.callback_query.register(show_admin_tariff_change, F.data.startswith('admin_sub_change_tariff_'))
+42
View File
@@ -84,14 +84,52 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
)
return
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
# Валидация формата
if not validate_promo_format(code):
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
return
# Rate-limit на перебор
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
# Лимит на стакинг (макс активаций в день)
if not promo_limiter.can_activate(message.from_user.id):
await message.answer(
texts.t(
'PROMO_DAILY_LIMIT',
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result['success']:
promo_limiter.record_activation(message.from_user.id)
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result['description']),
reply_markup=get_back_keyboard(db_user.language),
)
else:
# Записываем неудачную попытку только для not_found (перебор)
if result['error'] == 'not_found':
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
error_messages = {
'not_found': texts.PROMOCODE_INVALID,
'expired': texts.PROMOCODE_EXPIRED,
@@ -104,6 +142,10 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
),
'daily_limit': texts.t(
'PROMO_DAILY_LIMIT',
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
),
'server_error': texts.ERROR,
}
+1 -11
View File
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
# (независимо от того, включён ли выбор устройств)
if current_subscription:
current_device_limit = current_subscription.device_limit or device_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно
if getattr(current_subscription, 'modem_enabled', False):
current_device_limit = max(1, current_device_limit - 1)
# Используем максимум из текущего и дефолтного
device_limit = max(device_limit, current_device_limit)
# Проверяем, включён ли модем у текущей подписки
modem_enabled = False
if current_subscription:
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
# Подготовим параметры простой подписки
subscription_params = {
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
'device_limit': device_limit,
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
'modem_enabled': modem_enabled,
}
# Сохраняем параметры в состояние
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
db_user.id,
period_days,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
price_kopeks,
+74 -9
View File
@@ -159,10 +159,27 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
texts = get_texts(language)
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
potential_code = message.text.strip()
if len(potential_code) < 4 or len(potential_code) > 20:
if len(potential_code) < 3 or len(potential_code) > 50:
return False
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
if not validate_promo_format(potential_code):
return False
# Rate-limit на перебор промокодов
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return True
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if referrer:
@@ -217,7 +234,10 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
return True
# Ни реферальный код, ни промокод не найдены
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(
texts.t(
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
@@ -969,8 +989,26 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
code = message.text.strip()
# Валидация формата
if not validate_promo_format(code):
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
# Rate-limit на перебор
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
if referrer:
@@ -1000,7 +1038,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены
# Ни реферальный код, ни промокод не найдены — записываем неудачу
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}')
return
@@ -1225,6 +1266,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
)
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
await _send_pinned_message(callback.bot, db, user)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode=None,
)
await _send_pinned_message(callback.bot, db, user)
except Exception as fallback_err:
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
else:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
except Exception as e:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
else:
@@ -1504,6 +1559,20 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
)
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
await _send_pinned_message(message.bot, db, user)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
try:
await message.answer(
offer_text,
reply_markup=keyboard,
parse_mode=None,
)
await _send_pinned_message(message.bot, db, user)
except Exception as fallback_err:
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
else:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
except Exception as e:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
else:
@@ -1732,6 +1801,8 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
async def required_sub_channel_check(
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
):
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
language = DEFAULT_LANGUAGE
texts = get_texts(language)
@@ -1877,8 +1948,6 @@ async def required_sub_channel_check(
menu_text = await get_main_menu_text(user, texts, db)
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -1971,8 +2040,6 @@ async def required_sub_channel_check(
menu_text = await get_main_menu_text(user, texts, db)
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -2025,8 +2092,6 @@ async def required_sub_channel_check(
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
else:
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE:
-12
View File
@@ -64,13 +64,6 @@ from .links import (
handle_connect_subscription,
handle_open_subscription_link,
)
from .modem import (
handle_modem_confirm,
handle_modem_disable,
handle_modem_enable,
handle_modem_menu,
register_modem_handlers,
)
from .notifications import (
send_extension_notification,
send_purchase_notification,
@@ -172,10 +165,6 @@ __all__ = [
'handle_happ_download_platform_choice',
'handle_happ_download_request',
'handle_manage_country',
'handle_modem_confirm',
'handle_modem_disable',
'handle_modem_enable',
'handle_modem_menu',
'handle_no_traffic_packages',
'handle_open_subscription_link',
'handle_promo_offer_close',
@@ -190,7 +179,6 @@ __all__ = [
'load_app_config',
'refresh_traffic_config',
'register_handlers',
'register_modem_handlers',
'resume_subscription_checkout',
'return_to_saved_cart',
'save_cart_and_redirect_to_topup',
-323
View File
@@ -1,323 +0,0 @@
"""
Хендлеры для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from aiogram import Dispatcher, F, types
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 (
ModemError,
get_modem_service,
)
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)} (за {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')
+24 -48
View File
@@ -426,14 +426,7 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
'',
)
# Формируем отображение лимита устройств с учётом модема
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled and settings.is_modem_enabled():
# Показываем лимит без модема + модем
visible_device_limit = (subscription.device_limit or 1) - 1
device_limit_display = f'{visible_device_limit} + модем'
else:
device_limit_display = str(subscription.device_limit)
device_limit_display = str(subscription.device_limit)
message = message_template.format(
full_name=db_user.full_name,
@@ -1544,6 +1537,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
@@ -1603,11 +1605,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_base = devices_price_per_month * months_in_period
@@ -1814,11 +1811,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -3065,13 +3057,7 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
'',
)
# Формируем отображение лимита устройств с учётом модема
modem_enabled = getattr(subscription, 'modem_enabled', False) or False
if modem_enabled and settings.is_modem_enabled():
visible_device_limit = (subscription.device_limit or 1) - 1
devices_limit_display = f'{visible_device_limit} + модем'
else:
devices_limit_display = str(subscription.device_limit)
devices_limit_display = str(subscription.device_limit)
settings_text = settings_template.format(
countries_count=len(subscription.connected_squads),
@@ -3134,11 +3120,15 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
# Определяем, нужно ли возобновление: пауза пользователя ИЛИ остановка системой (disabled/expired)
from app.database.models import SubscriptionStatus
was_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
needs_resume = was_paused or is_inactive
# При возобновлении проверяем баланс
if was_paused:
if needs_resume:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
@@ -3150,10 +3140,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
return
subscription = await toggle_daily_subscription_pause(db, subscription)
if needs_resume:
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
if was_paused:
# Была пауза, теперь возобновили
subscription = await resume_daily_subscription(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
# Синхронизируем с Remnawave - активируем пользователя
try:
@@ -3170,10 +3161,9 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
except Exception as e:
logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}')
else:
# Была активна, теперь на паузе
# Подписка активна, ставим на паузу
subscription = await toggle_daily_subscription_pause(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!')
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)
@@ -4118,11 +4108,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
# Регистрируем обработчики модема
from .modem import register_modem_handlers
register_modem_handlers(dp)
# Регистрируем обработчики покупки по тарифам
from .tariff_purchase import register_tariff_purchase_handlers
@@ -4157,10 +4142,6 @@ async def handle_simple_subscription_purchase(
if current_subscription and current_subscription.is_active:
# При продлении используем текущие устройства подписки, а не дефолтные
extend_device_limit = current_subscription.device_limit or simple_device_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
if modem_enabled:
extend_device_limit = max(1, extend_device_limit - 1)
# Используем максимум из текущего и дефолтного
extend_device_limit = max(simple_device_limit, extend_device_limit)
@@ -4174,7 +4155,6 @@ async def handle_simple_subscription_purchase(
device_limit=extend_device_limit,
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
modem_enabled=modem_enabled,
)
return
@@ -4294,7 +4274,6 @@ async def _extend_existing_subscription(
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
modem_enabled: bool = False,
):
"""Продлевает существующую подписку."""
from datetime import datetime, timedelta
@@ -4312,7 +4291,6 @@ async def _extend_existing_subscription(
'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'modem_enabled': modem_enabled,
}
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
@@ -4321,17 +4299,15 @@ async def _extend_existing_subscription(
resolved_squad_uuid=squad_uuid,
)
logger.warning(
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
db_user.id,
price_kopeks,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
device_limit,
modem_enabled,
)
# Проверяем баланс пользователя
@@ -1552,6 +1552,12 @@ async def select_tariff_extend_period(
texts = get_texts(db_user.language)
parts = callback.data.split(':')
tariff_id = int(parts[1])
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
if len(parts) < 3:
await show_tariff_extend(callback, db_user, db)
return
period = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
+67 -113
View File
@@ -3,12 +3,12 @@ import logging
import time
from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InaccessibleMessage
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.user import get_user_by_id
from app.database.models import Ticket, TicketStatus, User
@@ -63,12 +63,20 @@ async def show_ticket_priority_selection(
)
return
await callback.message.edit_text(
texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:'),
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
prompt_text = texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:')
cancel_kb = get_ticket_cancel_keyboard(db_user.language)
prompt_msg = callback.message
try:
await callback.message.edit_text(prompt_text, reply_markup=cancel_kb)
except TelegramBadRequest:
# Предыдущее сообщение — фото (нет текста для edit_text), удаляем и шлём новое
try:
await callback.message.delete()
except Exception:
pass
prompt_msg = await callback.message.answer(prompt_text, reply_markup=cancel_kb)
# Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые
await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.message_id)
await state.update_data(prompt_chat_id=prompt_msg.chat.id, prompt_message_id=prompt_msg.message_id)
await state.set_state(TicketStates.waiting_for_title)
await callback.answer()
@@ -92,58 +100,18 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
if len(title) < 5:
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t(
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:')
)
text_val = texts.t(
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
return
if len(title) > 255:
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
)
text_val = texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
return
# Глобальный блок
@@ -166,29 +134,8 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
await state.update_data(title=title)
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'),
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
await state.set_state(TicketStates.waiting_for_message)
@@ -263,12 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
)
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None
)
else:
try:
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
except TelegramBadRequest:
await message.answer(text_msg)
else:
await message.answer(text_msg)
await state.clear()
@@ -285,24 +230,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
err_text = texts.t(
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=err_text,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=err_text,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(err_text)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
return
data = await state.get_data()
@@ -356,15 +284,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
]
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=creation_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
try:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
@@ -372,6 +292,8 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
reply_markup=keyboard,
parse_mode='HTML',
)
except TelegramBadRequest:
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
@@ -765,6 +687,28 @@ async def user_delete_message(callback: types.CallbackQuery):
await callback.answer('')
async def _edit_or_send(
message: types.Message,
chat_id: int | None,
message_id: int | None,
text: str,
language: str,
) -> None:
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
if chat_id and message_id:
try:
await message.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=get_ticket_cancel_keyboard(language),
)
return
except TelegramBadRequest:
pass
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
try:
await asyncio.sleep(delay_seconds)
@@ -782,10 +726,20 @@ async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_u
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
try:
await callback.message.edit_text(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
except TelegramBadRequest:
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
await state.set_state(TicketStates.waiting_for_reply)
await callback.answer()
+123 -30
View File
@@ -362,32 +362,101 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_text_main_menu_keyboard(
def _build_cabinet_main_menu_keyboard(
language: str,
texts,
*,
is_admin: bool,
is_moderator: bool,
balance_kopeks: int = 0,
) -> InlineKeyboardMarkup:
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
miniapp_url = settings.get_main_menu_miniapp_url()
"""Build the main-menu keyboard for Cabinet mode.
if miniapp_url:
profile_button = InlineKeyboardButton(
text=profile_text,
web_app=types.WebAppInfo(url=miniapp_url),
)
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
build_cabinet_url,
)
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
def _cabinet_button(
text: str,
path: str,
callback_fallback: str,
*,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
url = build_cabinet_url(path)
if url:
section = CALLBACK_TO_SECTION.get(callback_fallback)
section_cfg = cached_styles.get(section or '', {}) if section else {}
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved = _resolve_style(style)
elif section_cfg.get('style'):
resolved = _resolve_style(section_cfg['style'])
else:
resolved = global_style or _resolve_style(CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
home_cfg = cached_styles.get('home', {})
if home_cfg.get('enabled', True):
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
else:
profile_button = InlineKeyboardButton(
text=profile_text,
callback_data='menu_profile_unavailable',
)
keyboard_rows: list[list[InlineKeyboardButton]] = []
keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]]
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
if settings.is_language_selection_enabled():
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')])
# Subscription (green — main action)
sub_cfg = cached_styles.get('subscription', {})
if sub_cfg.get('enabled', True):
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
# Balance
bal_cfg = cached_styles.get('balance', {})
if bal_cfg.get('enabled', True):
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
balance_text = custom_bal
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Referrals (if enabled)
ref_cfg = cached_styles.get('referral', {})
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# Support
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
@@ -396,11 +465,33 @@ def _build_text_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')])
sup_cfg = cached_styles.get('support', {})
if support_enabled and sup_cfg.get('enabled', True):
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
# Info
info_cfg = cached_styles.get('info', {})
if info_cfg.get('enabled', True):
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
# Language selection (stays as callback — not a cabinet section)
if settings.is_language_selection_enabled():
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
# Lay out in pairs
for i in range(0, len(paired), 2):
keyboard_rows.append(paired[i : i + 2])
# Admin / Moderator
admin_cfg = cached_styles.get('admin', {})
if is_admin:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')])
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if admin_cfg.get('enabled', True):
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_buttons)
elif is_moderator:
keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')])
@@ -423,12 +514,13 @@ def get_main_menu_keyboard(
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.is_text_main_menu_mode():
return _build_text_main_menu_keyboard(
if settings.is_cabinet_mode():
return _build_cabinet_main_menu_keyboard(
language,
texts,
is_admin=is_admin,
is_moderator=is_moderator,
balance_kopeks=balance_kopeks,
)
if settings.DEBUG:
@@ -1000,9 +1092,15 @@ def get_subscription_keyboard(
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточного тарифа показываем кнопку паузы/возобновления
# Для суточного тарифа: проверяем статус подписки
from app.database.models import SubscriptionStatus
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку')
else:
pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку')
@@ -1800,7 +1898,7 @@ def get_add_traffic_keyboard(
period_text = f' (за {months_multiplier} мес)'
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
if not enabled_packages:
return InlineKeyboardMarkup(
@@ -1884,8 +1982,8 @@ def get_add_traffic_keyboard_from_tariff(
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
# Сортируем пакеты по размеру, исключаем пакеты с нулевой ценой
sorted_packages = sorted(((gb, p) for gb, p in packages.items() if p > 0), key=lambda x: x[0])
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
@@ -2620,11 +2718,6 @@ def get_updated_subscription_settings_keyboard(
]
)
if settings.is_modem_enabled() and not has_tariff:
keyboard.append(
[InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
)
keyboard.append(
[
InlineKeyboardButton(
+10
View File
@@ -5,6 +5,7 @@ from datetime import datetime
from typing import Any
from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
from sqlalchemy.exc import InterfaceError, OperationalError
@@ -219,6 +220,15 @@ class AuthMiddleware(BaseMiddleware):
if hasattr(event, 'data'):
logger.error(f'Callback data: {event.data}')
raise
except TelegramForbiddenError:
# User blocked the bot — normal, not an error
logger.debug('AuthMiddleware: bot blocked by user, skipping')
return None
except TelegramBadRequest as e:
if 'query is too old' in str(e):
logger.debug('AuthMiddleware: callback query expired, skipping')
return None
raise
except Exception as e:
logger.error(f'Ошибка в AuthMiddleware: {e}')
logger.error(f'Event type: {type(event)}')
+53 -2
View File
@@ -12,10 +12,26 @@ logger = logging.getLogger(__name__)
class ThrottlingMiddleware(BaseMiddleware):
def __init__(self, rate_limit: float = 0.5):
"""
Двухуровневый rate-limiter:
1. Общий троттлинг 0.5 сек между любыми сообщениями (UX)
2. /start burst-лимит макс N вызовов за окно (anti-spam)
"""
def __init__(
self,
rate_limit: float = 0.5,
start_max_calls: int = 3,
start_window: float = 60.0,
):
self.rate_limit = rate_limit
self.user_buckets: dict[int, float] = {}
# /start anti-spam: sliding window per user
self.start_max_calls = start_max_calls
self.start_window = start_window
self.start_buckets: dict[int, list[float]] = {}
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
@@ -30,10 +46,37 @@ class ThrottlingMiddleware(BaseMiddleware):
return await handler(event, data)
now = time.time()
# --- /start burst rate-limit ---
if isinstance(event, Message) and event.text and event.text.startswith('/start'):
timestamps = self.start_buckets.get(user_id, [])
# Оставляем только вызовы внутри окна
timestamps = [ts for ts in timestamps if now - ts < self.start_window]
if len(timestamps) >= self.start_max_calls:
cooldown = int(self.start_window - (now - timestamps[0])) + 1
logger.warning(
'Rate-limit /start для %s: %d вызовов за %ds (лимит %d)',
user_id,
len(timestamps),
int(self.start_window),
self.start_max_calls,
)
try:
await event.answer(f'⏳ Слишком много запросов. Попробуйте через {cooldown} сек.')
except Exception:
pass
self.start_buckets[user_id] = timestamps
return None
timestamps.append(now)
self.start_buckets[user_id] = timestamps
# --- Общий троттлинг (0.5 сек) ---
last_call = self.user_buckets.get(user_id, 0)
if now - last_call < self.rate_limit:
logger.warning(f'🚫 Throttling для пользователя {user_id}')
logger.warning(f'Throttling для пользователя {user_id}')
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
if isinstance(event, Message):
@@ -61,9 +104,17 @@ class ThrottlingMiddleware(BaseMiddleware):
self.user_buckets[user_id] = now
# Периодическая очистка старых записей
cleanup_threshold = now - 60
self.user_buckets = {
uid: timestamp for uid, timestamp in self.user_buckets.items() if timestamp > cleanup_threshold
}
# Очистка /start бакетов (раз в ~60 сек, лениво)
if len(self.start_buckets) > 500:
self.start_buckets = {
uid: [ts for ts in tss if now - ts < self.start_window]
for uid, tss in self.start_buckets.items()
if any(now - ts < self.start_window for ts in tss)
}
return await handler(event, data)
@@ -1534,7 +1534,6 @@ class AdminNotificationService:
'traffic': '📊 ДОКУПКА ТРАФИКА',
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
'servers': '🌐 СМЕНА СЕРВЕРОВ',
'modem': '📡 МОДЕМ',
}
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
@@ -1570,10 +1569,6 @@ class AdminNotificationService:
message_lines.append(f'🔄 {old_formatted}{new_formatted}')
elif update_type == 'devices':
message_lines.append(f'🔄 {old_value}{new_value} устр.')
elif update_type == 'modem':
old_state = '✅ Вкл' if old_value else '❌ Выкл'
new_state = '✅ Вкл' if new_value else '❌ Выкл'
message_lines.append(f'🔄 {old_state}{new_state}')
else:
message_lines.append(f'🔄 {old_value}{new_value}')
@@ -1638,8 +1633,6 @@ class AdminNotificationService:
if isinstance(value, list):
return f'{len(value)} серверов'
return str(value)
if update_type == 'modem':
return '✅ Включён' if value else '❌ Выключен'
return str(value)
async def send_bulk_ban_notification(
+57 -8
View File
@@ -2,12 +2,14 @@ import asyncio
import gzip
import json as json_lib
import logging
import math
import os
import shutil
import tarfile
import tempfile
from dataclasses import asdict, dataclass
from datetime import date as dt_date, datetime, time as dt_time, timedelta
from decimal import Decimal
from pathlib import Path
from typing import Any
@@ -15,6 +17,7 @@ import aiofiles
import pyzipper
from aiogram.types import FSInputFile
from sqlalchemy import inspect, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -599,10 +602,17 @@ class BackupService:
if value is None:
record_dict[column.name] = None
elif isinstance(value, datetime):
elif isinstance(value, (datetime, dt_date, dt_time)):
record_dict[column.name] = value.isoformat()
elif isinstance(value, Decimal):
record_dict[column.name] = float(value)
elif isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
record_dict[column.name] = 0.0
elif isinstance(value, (list, dict)):
record_dict[column.name] = json_lib.dumps(value) if value else None
try:
record_dict[column.name] = json_lib.dumps(value) if value else None
except TypeError:
record_dict[column.name] = str(value)
elif hasattr(value, '__dict__'):
record_dict[column.name] = str(value)
else:
@@ -1088,16 +1098,39 @@ class BackupService:
setattr(existing, key, value)
else:
instance = User(**processed_data)
db.add(instance)
try:
async with db.begin_nested():
db.add(instance)
await db.flush()
except IntegrityError:
logger.warning(
'Дубликат пользователя (id=%s, telegram_id=%s), пропускаем',
processed_data.get('id'),
processed_data.get('telegram_id'),
)
continue
else:
instance = User(**processed_data)
db.add(instance)
try:
async with db.begin_nested():
db.add(instance)
await db.flush()
except IntegrityError:
logger.warning(
'Дубликат пользователя (telegram_id=%s), пропускаем',
processed_data.get('telegram_id'),
)
continue
except Exception as e:
logger.error(f'Ошибка при восстановлении пользователя: {e}')
raise
await db.flush()
try:
await db.flush()
except IntegrityError as e:
logger.warning('IntegrityError при flush пользователей, откатываем: %s', e)
await db.rollback()
logger.info('✅ Пользователи без реферальных связей восстановлены')
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
@@ -1277,8 +1310,13 @@ class BackupService:
logger.debug('Запись %s %s уже существует', table_name, values)
continue
await db.execute(table_obj.insert().values(**values))
restored += 1
try:
async with db.begin_nested():
await db.execute(table_obj.insert().values(**values))
restored += 1
except IntegrityError:
logger.warning('Пропускаем связь %s %s (FK или дубликат)', table_name, values)
continue
except Exception as e:
logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e)
raise
@@ -1324,7 +1362,18 @@ class BackupService:
setattr(existing, key, value)
else:
instance = model(**processed_data)
db.add(instance)
try:
async with db.begin_nested():
db.add(instance)
await db.flush()
except IntegrityError:
# Unique constraint conflict — record exists with different PK
logger.warning(
'Дубликат по уникальному ключу в %s (PK=%s), пропускаем',
table_name,
{col: processed_data.get(col) for col in pk_cols},
)
continue
else:
instance = model(**processed_data)
db.add(instance)
-349
View File
@@ -1,349 +0,0 @@
"""
Сервис для управления модемом в подписке.
Модем - это дополнительное устройство, которое можно подключить к подписке
за отдельную плату. При подключении увеличивается лимит устройств.
"""
import logging
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import Subscription, TransactionType, User
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import 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: ModemError | None = 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: ModemError | None = None
charged_amount: int = 0
new_device_limit: int = 0
@dataclass
class ModemDisableResult:
"""Результат отключения модема."""
success: bool
error: ModemError | None = 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: Subscription | None) -> 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)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(f'Пользователь {user_id_display} подключил модем, списано: {price / 100}')
return ModemEnableResult(success=True, charged_amount=price, new_device_limit=subscription.device_limit)
except Exception as e:
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.error(f'Ошибка подключения модема для пользователя {user_id_display}: {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)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(f'Пользователь {user_id_display} отключил модем')
return ModemDisableResult(success=True, new_device_limit=subscription.device_limit)
except Exception as e:
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.error(f'Ошибка отключения модема для пользователя {user_id_display}: {e}')
await db.rollback()
return ModemDisableResult(success=False, error=ModemError.UPDATE_ERROR)
@staticmethod
def get_period_warning_level(remaining_days: int) -> str | None:
"""
Определяет уровень предупреждения о сроке действия.
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: ModemService | None = None
def get_modem_service() -> ModemService:
"""Возвращает singleton экземпляр ModemService."""
global _modem_service
if _modem_service is None:
_modem_service = ModemService()
return _modem_service
-154
View File
@@ -221,7 +221,6 @@ class MonitoringService:
await self._check_expired_subscriptions(db)
await self._check_expiring_subscriptions(db)
await self._check_trial_expiring_soon(db)
await self._check_trial_inactivity_notifications(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
if settings.ENABLE_AUTOPAY:
@@ -504,77 +503,6 @@ class MonitoringService:
except Exception as e:
logger.error(f'Ошибка проверки истекающих тестовых подписок: {e}')
async def _check_trial_inactivity_notifications(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
return
if not self.bot:
return
try:
now = datetime.utcnow()
one_hour_ago = now - timedelta(hours=1)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
Subscription.is_trial == True,
Subscription.start_date.isnot(None),
Subscription.start_date <= one_hour_ago,
Subscription.end_date > now,
)
)
)
subscriptions = result.scalars().all()
sent_1h = 0
sent_24h = 0
for subscription in subscriptions:
user = subscription.user
if not user:
continue
if (subscription.traffic_used_gb or 0) > 0:
continue
start_date = subscription.start_date
if not start_date:
continue
time_since_start = now - start_date
if NotificationSettingsService.is_trial_inactive_1h_enabled() and timedelta(
hours=1
) <= time_since_start < timedelta(hours=24):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_1h'):
success = await self._send_trial_inactive_notification(user, subscription, 1)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_1h')
sent_1h += 1
if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(
hours=24
):
if not await notification_sent(db, user.id, subscription.id, 'trial_inactive_24h'):
success = await self._send_trial_inactive_notification(user, subscription, 24)
if success:
await record_notification(db, user.id, subscription.id, 'trial_inactive_24h')
sent_24h += 1
if sent_1h or sent_24h:
await self._log_monitoring_event(
db,
'trial_inactivity_notifications',
f'Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа',
{'sent_1h': sent_1h, 'sent_24h': sent_24h},
)
except Exception as e:
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
@@ -1357,88 +1285,6 @@ class MonitoringService:
)
return False
async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool:
try:
texts = get_texts(user.language)
if hours >= 24:
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
else:
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=settings.format_price(settings.PRICE_30_DAYS),
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
build_miniapp_or_callback_button(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
build_miniapp_or_callback_button(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'), callback_data='menu_support'
)
],
]
)
await self._send_message_with_logo(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
return True
except (TelegramForbiddenError, TelegramBadRequest) as exc:
if self._handle_unreachable_user(user, exc, 'уведомление о бездействии на тесте'):
return True
logger.error(
'Ошибка Telegram API при отправке уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
exc,
)
return False
except TelegramNetworkError as e:
logger.warning(
'Таймаут отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
except Exception as e:
logger.error(
'Ошибка отправки уведомления об отсутствии подключения пользователю %s: %s',
user.telegram_id,
e,
)
return False
async def _send_trial_channel_unsubscribed_notification(self, user: User) -> bool:
try:
texts = get_texts(user.language)
@@ -18,8 +18,6 @@ class NotificationSettingsService:
_loaded: bool = False
_DEFAULTS: dict[str, dict[str, Any]] = {
'trial_inactive_1h': {'enabled': True},
'trial_inactive_24h': {'enabled': True},
'trial_channel_unsubscribed': {'enabled': True},
'expired_1d': {'enabled': True},
'expired_second_wave': {
@@ -122,23 +120,6 @@ class NotificationSettingsService:
def is_enabled(cls, key: str) -> bool:
return bool(cls._get(key).get('enabled', True))
# Trial inactivity helpers
@classmethod
def is_trial_inactive_1h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_1h')
@classmethod
def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_1h', enabled)
@classmethod
def is_trial_inactive_24h_enabled(cls) -> bool:
return cls.is_enabled('trial_inactive_24h')
@classmethod
def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool:
return cls.set_enabled('trial_inactive_24h', enabled)
@classmethod
def is_trial_channel_unsubscribed_enabled(cls) -> bool:
return cls.is_enabled('trial_channel_unsubscribed')
+15
View File
@@ -397,6 +397,21 @@ class YooKassaPaymentMixin:
try:
from sqlalchemy import select
from app.database.models import YooKassaPayment as YKPayment
# Lock the payment row to prevent concurrent double-processing
locked_result = await db.execute(select(YKPayment).where(YKPayment.id == payment.id).with_for_update())
payment = locked_result.scalar_one()
# Fast-path: already processed
if getattr(payment, 'transaction_id', None):
logger.info(
'Платеж YooKassa %s уже обработан (transaction_id=%s), пропускаем.',
payment.yookassa_payment_id,
payment.transaction_id,
)
return True
payment_module = import_module('app.services.payment_service')
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
+47 -32
View File
@@ -189,6 +189,9 @@ async def broadcast_pinned_message(
)
failed_count += 1
break
else:
# All retry attempts exhausted (TelegramRetryAfter on every attempt)
failed_count += 1
for i in range(0, len(recipient_telegram_ids), 30):
batch = recipient_telegram_ids[i : i + 30]
@@ -251,23 +254,6 @@ async def unpin_active_pinned_message(
unpinned_count += 1
else:
failed_count += 1
except TelegramRetryAfter as retry_error:
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter while unpinning for user %s, waiting %s seconds',
telegram_id,
delay,
)
await asyncio.sleep(delay)
# Повторная попытка после ожидания
try:
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
failed_count += 1
except Exception:
failed_count += 1
except Exception as error:
logger.error(
'Ошибка открепления сообщения у пользователя %s: %s',
@@ -311,6 +297,12 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
pass
except TelegramForbiddenError:
return False
except TelegramRetryAfter as e:
await asyncio.sleep(min(e.retry_after + 1, 30))
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
except (TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter):
pass
try:
if pinned_message.media_type == 'photo' and pinned_message.media_file_id:
@@ -345,6 +337,9 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
return True
except TelegramForbiddenError:
return False
except TelegramRetryAfter as e:
await asyncio.sleep(min(e.retry_after + 1, 30))
raise # Propagate to caller's retry loop
except TelegramBadRequest as error:
logger.warning(
'Некорректный запрос при отправке закрепленного сообщения в чат %s: %s',
@@ -361,18 +356,38 @@ async def _send_and_pin_message(bot: Bot, chat_id: int, pinned_message: PinnedMe
return False
async def _unpin_message_for_user(bot: Bot, chat_id: int) -> bool:
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
return True
except TelegramForbiddenError:
return False
except TelegramBadRequest:
return False
except Exception as error:
logger.error(
'Не удалось открепить сообщение у пользователя %s: %s',
chat_id,
error,
)
return False
async def _unpin_message_for_user(bot: Bot, chat_id: int, max_retries: int = 3) -> bool:
for attempt in range(max_retries):
try:
await bot.unpin_all_chat_messages(chat_id=chat_id)
return True
except TelegramRetryAfter as e:
if attempt < max_retries - 1:
delay = min(e.retry_after + 1, 30)
logger.warning(
'RetryAfter при откреплении для %s, ожидание %s сек (попытка %d/%d)',
chat_id,
delay,
attempt + 1,
max_retries,
)
await asyncio.sleep(delay)
else:
logger.warning(
'Не удалось открепить сообщение у %s после %d попыток (flood control)',
chat_id,
max_retries,
)
return False
except TelegramForbiddenError:
return False
except TelegramBadRequest:
return False
except Exception as error:
logger.error(
'Не удалось открепить сообщение у пользователя %s: %s',
chat_id,
error,
)
return False
return False
+12
View File
@@ -54,6 +54,18 @@ class PromoCodeService:
if existing_use:
return {'success': False, 'error': 'already_used_by_user'}
# Лимит на количество активаций за день (анти-стакинг)
from app.database.crud.promocode import count_user_recent_activations
recent_count = await count_user_recent_activations(db, user_id, hours=24)
if recent_count >= 5:
logger.warning(
'Promo stacking limit: user %s has %d activations in 24h',
self._format_user_log(user),
recent_count,
)
return {'success': False, 'error': 'daily_limit'}
# Проверка "только для первой покупки"
if getattr(promocode, 'first_purchase_only', False):
if getattr(user, 'has_had_paid_subscription', False):
+6 -13
View File
@@ -1070,22 +1070,15 @@ class RemnaWaveService:
)
if updated_subscriptions:
# Update in consistent ID order to prevent deadlocks
counter_updates = {}
if source_decrement:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == source_server.id)
.values(
current_users=func.greatest(
ServerSquad.current_users - source_decrement,
0,
)
)
)
counter_updates[source_server.id] = func.greatest(ServerSquad.current_users - source_decrement, 0)
if target_increment:
counter_updates[target_server.id] = ServerSquad.current_users + target_increment
for sid in sorted(counter_updates):
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == target_server.id)
.values(current_users=ServerSquad.current_users + target_increment)
update(ServerSquad).where(ServerSquad.id == sid).values(current_users=counter_updates[sid])
)
await db.commit()
+71 -8
View File
@@ -17,9 +17,11 @@ from typing import Any
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import delete
from sqlalchemy.exc import PendingRollbackError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
from app.config import settings
from app.database.crud.subscription import (
deactivate_subscription,
decrement_subscription_server_counts,
@@ -28,7 +30,7 @@ from app.database.crud.subscription import (
reactivate_subscription,
update_subscription_usage,
)
from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id
from app.database.crud.user import get_user_by_id, get_user_by_remnawave_uuid, get_user_by_telegram_id
from app.database.models import Subscription, SubscriptionServer, SubscriptionStatus, User
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
@@ -59,6 +61,26 @@ _TEXT_KEY_TO_NOTIFICATION_TYPE: dict[str, NotificationType] = {
'WEBHOOK_DEVICE_DELETED': NotificationType.WEBHOOK_DEVICE_DELETED,
}
# Mapping from locale text_key to the Settings toggle that controls it
_TEXT_KEY_TO_SETTING: dict[str, str] = {
'WEBHOOK_SUB_EXPIRED': 'WEBHOOK_NOTIFY_SUB_EXPIRED',
'WEBHOOK_SUB_DISABLED': 'WEBHOOK_NOTIFY_SUB_STATUS',
'WEBHOOK_SUB_ENABLED': 'WEBHOOK_NOTIFY_SUB_STATUS',
'WEBHOOK_SUB_LIMITED': 'WEBHOOK_NOTIFY_SUB_LIMITED',
'WEBHOOK_SUB_TRAFFIC_RESET': 'WEBHOOK_NOTIFY_TRAFFIC_RESET',
'WEBHOOK_SUB_DELETED': 'WEBHOOK_NOTIFY_SUB_DELETED',
'WEBHOOK_SUB_REVOKED': 'WEBHOOK_NOTIFY_SUB_REVOKED',
'WEBHOOK_SUB_EXPIRES_72H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
'WEBHOOK_SUB_EXPIRES_48H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
'WEBHOOK_SUB_EXPIRES_24H': 'WEBHOOK_NOTIFY_SUB_EXPIRING',
'WEBHOOK_SUB_EXPIRED_24H_AGO': 'WEBHOOK_NOTIFY_SUB_EXPIRED',
'WEBHOOK_SUB_FIRST_CONNECTED': 'WEBHOOK_NOTIFY_FIRST_CONNECTED',
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD': 'WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD',
'WEBHOOK_USER_NOT_CONNECTED': 'WEBHOOK_NOTIFY_NOT_CONNECTED',
'WEBHOOK_DEVICE_ADDED': 'WEBHOOK_NOTIFY_DEVICES',
'WEBHOOK_DEVICE_DELETED': 'WEBHOOK_NOTIFY_DEVICES',
}
# Admin event display names for notification messages
_ADMIN_NODE_EVENTS: dict[str, str] = {
'node.created': '🟢 Нода создана',
@@ -171,7 +193,7 @@ class RemnaWaveWebhookService:
try:
await handler(db, user, subscription, data)
return True
except StaleDataError:
except (StaleDataError, PendingRollbackError):
logger.warning(
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
event_name,
@@ -334,7 +356,7 @@ class RemnaWaveWebhookService:
button_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription')],
[build_miniapp_or_callback_button(text=button_text, callback_data='menu_subscription')],
]
)
@@ -353,8 +375,8 @@ class RemnaWaveWebhookService:
sub_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=buy_text, callback_data='subscription_add_traffic')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='subscription')],
[build_miniapp_or_callback_button(text=buy_text, callback_data='buy_traffic')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='menu_subscription')],
]
)
@@ -371,7 +393,19 @@ class RemnaWaveWebhookService:
Telegram users receive a bot message; email-only users receive
an email and/or WebSocket notification through the unified
notification delivery service.
Respects WEBHOOK_NOTIFY_USER_ENABLED master toggle and
per-event toggles from Settings.
"""
if not settings.WEBHOOK_NOTIFY_USER_ENABLED:
logger.debug('Webhook user notifications disabled globally, skipping %s', text_key)
return
setting_key = _TEXT_KEY_TO_SETTING.get(text_key)
if setting_key and not getattr(settings, setting_key, True):
logger.debug('Webhook notification %s disabled via %s', text_key, setting_key)
return
texts = get_texts(user.language)
message = texts.get(text_key)
if not message:
@@ -574,18 +608,47 @@ class RemnaWaveWebhookService:
async def _handle_user_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
user_id = user.id
sub_id = subscription.id if subscription else None
if subscription:
self._stamp_webhook_update(subscription)
# Decrement server counters BEFORE clearing connected_squads
await decrement_subscription_server_counts(db, subscription)
# Re-fetch after potential rollback inside decrement_subscription_server_counts
try:
await db.refresh(subscription)
except Exception:
# Subscription was cascade-deleted, re-fetch user and skip subscription updates
logger.warning(
'Webhook: subscription %s already deleted for user %s, skipping subscription cleanup',
sub_id,
user_id,
)
subscription = None
try:
await db.rollback()
except Exception:
pass
try:
user = await get_user_by_id(db, user_id)
except Exception:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if not user:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if subscription:
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
subscription.id,
user.id,
sub_id,
user_id,
)
# Clear subscription data — panel user no longer exists
@@ -596,7 +659,7 @@ class RemnaWaveWebhookService:
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
# Remove SubscriptionServer link rows
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == subscription.id))
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
# Clear remnawave linkage
if user.remnawave_uuid:
+24 -6
View File
@@ -336,11 +336,6 @@ class SubscriptionRenewalService:
if devices_limit is None:
devices_limit = settings.DEFAULT_DEVICE_LIMIT
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
devices_limit = max(1, devices_limit - 1)
total_cost, details = await calculate_subscription_total_cost(
db,
period_days,
@@ -454,7 +449,30 @@ class SubscriptionRenewalService:
subscription_before = subscription
old_end_date = subscription_before.end_date
subscription_after = await extend_subscription(db, subscription_before, period_days)
try:
subscription_after = await extend_subscription(db, subscription_before, period_days)
except Exception:
# Compensate: refund the charged balance since extension failed
if charge_from_balance > 0:
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
user,
charge_from_balance,
'Возврат: ошибка продления подписки',
create_transaction=True,
transaction_type=TransactionType.REFUND,
)
except Exception as refund_error:
logger.critical(
'CRITICAL: Failed to refund %s kopeks to user %s after extension failure: %s',
charge_from_balance,
user.id,
refund_error,
)
raise
server_ids = pricing.server_ids or []
server_prices_for_period = pricing.details.get('servers_individual_prices', [])
-5
View File
@@ -790,11 +790,6 @@ class SubscriptionService:
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
devices_price = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
devices_discount_percent = _resolve_discount_percent(
user,
+74 -1
View File
@@ -124,6 +124,7 @@ class BotConfigurationService:
'VERSION': '🔄 Проверка версий',
'WEB_API': '⚡ Web API',
'WEBHOOK': '🌐 Webhook',
'WEBHOOK_NOTIFICATIONS': '📢 Уведомления от вебхуков',
'LOG': '📝 Логирование',
'DEBUG': '🧪 Режим разработки',
'MODERATION': '🛡️ Модерация и фильтры',
@@ -183,6 +184,7 @@ class BotConfigurationService:
'VERSION': 'Отслеживание обновлений репозитория.',
'WEB_API': 'Web API, токены и права доступа.',
'WEBHOOK': 'Пути и секреты вебхуков.',
'WEBHOOK_NOTIFICATIONS': 'Управление уведомлениями, которые получают пользователи при событиях RemnaWave (отключение/активация подписки, устройства, трафик и т.д.).',
'LOG': 'Уровни логирования и ротация.',
'DEBUG': 'Отладочные функции и безопасный режим.',
'MODERATION': 'Настройки фильтров отображаемых имен и защиты от фишинга.',
@@ -292,6 +294,7 @@ class BotConfigurationService:
'LOGO_FILE': 'INTERFACE_BRANDING',
'HIDE_SUBSCRIPTION_LINK': 'INTERFACE_SUBSCRIPTION',
'MAIN_MENU_MODE': 'INTERFACE',
'CABINET_BUTTON_STYLE': 'INTERFACE',
'CONNECT_BUTTON_MODE': 'CONNECT_BUTTON',
'MINIAPP_CUSTOM_URL': 'CONNECT_BUTTON',
'APP_CONFIG_PATH': 'ADDITIONAL',
@@ -356,6 +359,7 @@ class BotConfigurationService:
'MAINTENANCE_': 'MAINTENANCE',
'VERSION_CHECK': 'VERSION',
'BACKUP_': 'BACKUP',
'WEBHOOK_NOTIFY_': 'WEBHOOK_NOTIFICATIONS',
'WEBHOOK_': 'WEBHOOK',
'LOG_': 'LOG',
'WEB_API_': 'WEB_API',
@@ -403,7 +407,13 @@ class BotConfigurationService:
],
'MAIN_MENU_MODE': [
ChoiceOption('default', '📋 Полное меню'),
ChoiceOption('text', '📝 Текстовое меню'),
ChoiceOption('cabinet', '🏠 Cabinet (МиниАпп)'),
],
'CABINET_BUTTON_STYLE': [
ChoiceOption('', '🎨 По секциям (авто)'),
ChoiceOption('primary', '🔵 Синий'),
ChoiceOption('success', '🟢 Зелёный'),
ChoiceOption('danger', '🔴 Красный'),
],
'SALES_MODE': [
ChoiceOption('classic', '📋 Классический (периоды из .env)'),
@@ -809,6 +819,69 @@ class BotConfigurationService:
'example': '60',
'warning': 'Защита от спама уведомлениями по одному и тому же пользователю.',
},
'WEBHOOK_NOTIFY_USER_ENABLED': {
'description': (
'Глобальный переключатель уведомлений пользователям от вебхуков RemnaWave. '
'При выключении ни одно уведомление не отправляется, независимо от остальных настроек.'
),
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_STATUS': {
'description': 'Уведомления об отключении и активации подписки администратором.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_EXPIRED': {
'description': 'Уведомления об истечении подписки.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_EXPIRING': {
'description': 'Предупреждения о скором истечении подписки (72ч, 48ч, 24ч до окончания).',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_LIMITED': {
'description': 'Уведомление при достижении лимита трафика.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_TRAFFIC_RESET': {
'description': 'Уведомление о сбросе счётчика трафика.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_DELETED': {
'description': 'Уведомление при удалении пользователя из панели.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_SUB_REVOKED': {
'description': 'Уведомление при обновлении ключей подписки (revoke).',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_FIRST_CONNECTED': {
'description': 'Уведомление при первом подключении к VPN.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_NOT_CONNECTED': {
'description': 'Напоминание, что пользователь ещё не подключился к VPN.',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD': {
'description': 'Предупреждение при приближении к лимиту трафика (порог в %).',
'format': 'Булево значение.',
'example': 'true',
},
'WEBHOOK_NOTIFY_DEVICES': {
'description': 'Уведомления о подключении и отключении устройств.',
'format': 'Булево значение.',
'example': 'true',
},
}
@classmethod
+3
View File
@@ -1105,6 +1105,9 @@ class UserService:
try:
if user.subscription:
logger.info(f'🔄 Удаляем подписку {user.subscription.id}')
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
await db.flush()
except Exception as e:
+68 -26
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import logging
import re
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -47,6 +49,24 @@ class WataService:
'Content-Type': 'application/json',
}
_MAX_RETRIES = 2
@staticmethod
def _parse_retry_after(response: aiohttp.ClientResponse, response_text: str) -> float:
"""Extract retry delay from Retry-After header or response body."""
retry_after = response.headers.get('Retry-After')
if retry_after:
try:
return float(retry_after)
except (ValueError, TypeError):
pass
match = re.search(r'[Rr]etry after (\d+)', response_text)
if match:
return float(match.group(1))
return 45.0
async def _request(
self,
method: str,
@@ -61,35 +81,57 @@ class WataService:
url = self._build_url(path)
timeout = aiohttp.ClientTimeout(total=self.request_timeout)
try:
async with (
aiohttp.ClientSession(timeout=timeout) as session,
session.request(
method,
url,
json=json,
params=params,
headers=self._build_headers(),
) as response,
):
response_text = await response.text()
if response.status >= 400:
logger.error('WATA API error %s: %s', response.status, response_text)
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
last_error: WataAPIError | None = None
for attempt in range(1 + self._MAX_RETRIES):
try:
async with (
aiohttp.ClientSession(timeout=timeout) as session,
session.request(
method,
url,
json=json,
params=params,
headers=self._build_headers(),
) as response,
):
response_text = await response.text()
if not response_text:
return {}
if response.status == 429:
retry_delay = self._parse_retry_after(response, response_text)
if attempt < self._MAX_RETRIES:
logger.warning(
'WATA API 429 on %s %s, retry %d/%d after %.0fs',
method,
path,
attempt + 1,
self._MAX_RETRIES,
retry_delay,
)
await asyncio.sleep(retry_delay)
continue
logger.warning('WATA API 429 on %s %s, retries exhausted', method, path)
last_error = WataAPIError(f'WATA API rate limited on {method} {path}')
break
try:
data = await response.json()
except aiohttp.ContentTypeError as error:
logger.error('WATA API returned non-JSON response: %s', error)
raise WataAPIError('WATA API returned invalid JSON') from error
if response.status >= 400:
logger.error('WATA API error %s: %s', response.status, response_text)
raise WataAPIError(f'WATA API returned status {response.status}: {response_text}')
return data
except aiohttp.ClientError as error:
logger.error('Error communicating with WATA API: %s', error)
raise WataAPIError('Failed to communicate with WATA API') from error
if not response_text:
return {}
try:
data = await response.json()
except aiohttp.ContentTypeError as error:
logger.error('WATA API returned non-JSON response: %s', error)
raise WataAPIError('WATA API returned invalid JSON') from error
return data
except aiohttp.ClientError as error:
logger.error('Error communicating with WATA API: %s', error)
raise WataAPIError('Failed to communicate with WATA API') from error
raise last_error or WataAPIError('WATA API request failed')
@staticmethod
def _amount_from_kopeks(amount_kopeks: int) -> float:
+120
View File
@@ -0,0 +1,120 @@
"""Lightweight in-process cache for per-section cabinet button styles.
Avoids circular imports between ``cabinet.routes`` and ``app.utils.miniapp_buttons``
by keeping the cache and its helpers in a dedicated module.
"""
import json
import logging
from app.database.database import AsyncSessionLocal
logger = logging.getLogger(__name__)
# ---- Defaults per section ------------------------------------------------
DEFAULT_BUTTON_STYLES: dict[str, dict] = {
'home': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'subscription': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'balance': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'referral': {'style': 'success', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'support': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'info': {'style': 'primary', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
'admin': {'style': 'danger', 'icon_custom_emoji_id': '', 'enabled': True, 'labels': {}},
}
BOT_LOCALES = ('ru', 'en', 'ua', 'zh', 'fa')
SECTIONS = list(DEFAULT_BUTTON_STYLES.keys())
# Map callback_data values to their logical section name.
CALLBACK_TO_SECTION: dict[str, str] = {
'menu_profile_unavailable': 'home',
'back_to_menu': 'home',
'menu_subscription': 'subscription',
'subscription': 'subscription',
'subscription_extend': 'subscription',
'subscription_upgrade': 'subscription',
'subscription_connect': 'subscription',
'subscription_resume_checkout': 'subscription',
'return_to_saved_cart': 'subscription',
'menu_buy': 'subscription',
'buy_traffic': 'subscription',
'menu_balance': 'balance',
'balance_topup': 'balance',
'menu_referrals': 'referral',
'menu_referral': 'referral',
'menu_support': 'support',
'menu_info': 'info',
'admin_panel': 'admin',
}
# DB key used for storage.
BUTTON_STYLES_KEY = 'CABINET_BUTTON_STYLES'
# Valid Telegram Bot API style values.
VALID_STYLES = frozenset({'primary', 'success', 'danger'})
# All style values accepted by the admin API ('default' = no color, Telegram default).
ALLOWED_STYLE_VALUES = VALID_STYLES | {'default'}
# ---- Module-level cache ---------------------------------------------------
_cached_styles: dict[str, dict] | None = None
def _deep_copy_styles(source: dict[str, dict]) -> dict[str, dict]:
"""Return a deep copy of styles dict (copies nested ``labels`` dicts)."""
return {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in source.items()}
def get_cached_button_styles() -> dict[str, dict]:
"""Return the current merged config (DB overrides + defaults).
If the cache has not been loaded yet, returns defaults.
"""
if _cached_styles is not None:
return _deep_copy_styles(_cached_styles)
return _deep_copy_styles(DEFAULT_BUTTON_STYLES)
async def load_button_styles_cache() -> dict[str, dict]:
"""Load button styles from DB and refresh the module cache.
Called at bot startup and after admin updates via the cabinet API.
"""
global _cached_styles
merged = _deep_copy_styles(DEFAULT_BUTTON_STYLES)
try:
from sqlalchemy import select
from app.database.models import SystemSetting
async with AsyncSessionLocal() as session:
result = await session.execute(select(SystemSetting).where(SystemSetting.key == BUTTON_STYLES_KEY))
setting = result.scalar_one_or_none()
if setting and setting.value:
db_data: dict = json.loads(setting.value)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except Exception:
logger.exception('Failed to load button styles from DB, using defaults')
_cached_styles = merged
logger.info('Button styles cache loaded: %s', list(merged.keys()))
return merged
-72
View File
@@ -173,75 +173,3 @@ def rate_limit(rate: float = 1.0, key: str = None):
return wrapper
return decorator
def modem_available(for_enable: bool = False, for_disable: bool = False):
"""
Декоратор для проверки доступности модема.
Проверяет:
- Наличие подписки
- Подписка не триальная
- Функция модема включена в настройках
- (опционально) Модем ещё не подключен (for_enable=True)
- (опционально) Модем уже подключен (for_disable=True)
Args:
for_enable: Проверять, что модем ещё не подключен
for_disable: Проверять, что модем подключен
Usage:
@modem_available()
async def handle_modem_menu(callback, db_user, db): ...
@modem_available(for_enable=True)
async def handle_modem_enable(callback, db_user, db): ...
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
async def wrapper(event: types.Update, *args, **kwargs) -> Any:
db_user = kwargs.get('db_user')
if not db_user:
logger.warning('modem_available: нет db_user в kwargs')
return None
from app.services.modem_service import ModemError, get_modem_service
service = get_modem_service()
result = service.check_availability(db_user, for_enable=for_enable, for_disable=for_disable)
if not result.available:
texts = get_texts(db_user.language if db_user else 'ru')
error_messages = {
ModemError.NO_SUBSCRIPTION: texts.t(
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
),
ModemError.TRIAL_SUBSCRIPTION: texts.t(
'MODEM_PAID_ONLY', 'Модем доступен только для платных подписок'
),
ModemError.MODEM_DISABLED: texts.t('MODEM_DISABLED', 'Функция модема отключена'),
ModemError.ALREADY_ENABLED: texts.t('MODEM_ALREADY_ENABLED', 'Модем уже подключен'),
ModemError.NOT_ENABLED: texts.t('MODEM_NOT_ENABLED', 'Модем не подключен'),
}
error_text = error_messages.get(result.error, texts.ERROR)
try:
if isinstance(event, types.CallbackQuery):
await event.answer(error_text, show_alert=True)
elif isinstance(event, types.Message):
await event.answer(error_text)
except TelegramBadRequest as e:
if 'query is too old' not in str(e).lower():
raise
return None
return await func(event, *args, **kwargs)
return wrapper
return decorator
+138 -11
View File
@@ -2,30 +2,157 @@ from aiogram import types
from aiogram.types import InlineKeyboardButton
from app.config import settings
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
# Mapping from callback_data to cabinet frontend paths.
# Used for automatic deep-linking when explicit ``cabinet_path`` is not provided.
# If callback_data is NOT in this mapping, the button falls back to a regular callback.
CALLBACK_TO_CABINET_PATH: dict[str, str] = {
'menu_balance': '/balance',
'balance_topup': '/balance/top-up',
'menu_subscription': '/subscription',
'subscription': '/subscription',
'subscription_extend': '/subscription',
'subscription_upgrade': '/subscription',
'subscription_connect': '/subscription',
'subscription_resume_checkout': '/subscription',
'return_to_saved_cart': '/subscription',
'menu_buy': '/subscription',
'buy_traffic': '/subscription',
'menu_referrals': '/referral',
'menu_referral': '/referral',
'menu_support': '/support',
'menu_info': '/info',
'menu_profile': '/profile',
'back_to_menu': '/',
}
# Default button styles per callback_data for cabinet mode.
# Values: 'primary' (blue), 'success' (green), 'danger' (red), None (default).
CALLBACK_TO_CABINET_STYLE: dict[str, str] = {
'menu_balance': 'primary',
'balance_topup': 'primary',
'menu_subscription': 'success',
'subscription': 'success',
'subscription_extend': 'success',
'subscription_upgrade': 'success',
'subscription_connect': 'success',
'subscription_resume_checkout': 'success',
'return_to_saved_cart': 'success',
'menu_buy': 'success',
'buy_traffic': 'success',
'menu_referrals': 'success',
'menu_referral': 'success',
'menu_support': 'primary',
'menu_info': 'primary',
'menu_profile': 'primary',
'back_to_menu': 'primary',
}
# Mapping from broadcast button keys to cabinet paths.
BUTTON_KEY_TO_CABINET_PATH: dict[str, str] = {
'balance': '/balance/top-up',
'referrals': '/referral',
'promocode': '/subscription',
'connect': '/subscription',
'subscription': '/subscription',
'support': '/support',
'home': '/',
}
# Valid style values accepted by the Telegram Bot API.
_VALID_STYLES = frozenset({'primary', 'success', 'danger'})
def _resolve_style(style: str | None) -> str | None:
"""Return a validated style or ``None``."""
if style and style in _VALID_STYLES:
return style
return None
def build_cabinet_url(path: str = '') -> str:
"""Join ``MINIAPP_CUSTOM_URL`` with an optional *path* segment.
Handles trailing-slash normalization so that both
``https://example.com`` and ``https://example.com/`` produce
correct URLs like ``https://example.com/balance``.
Returns an empty string when the base URL is not configured
or when *path* is empty (no known section).
"""
base = (settings.MINIAPP_CUSTOM_URL or '').strip().rstrip('/')
if not base:
return ''
if not path:
return ''
if path == '/':
return base
if not path.startswith('/'):
path = f'/{path}'
return f'{base}{path}'
def build_miniapp_or_callback_button(
text: str,
*,
callback_data: str,
cabinet_path: str | None = None,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
"""Create a button that opens the miniapp or falls back to a callback.
"""Create a button that opens the cabinet miniapp or falls back to a callback.
In text menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the full cabinet miniapp. Otherwise (or outside text menu mode)
the regular ``callback_data`` is used so the user stays in the bot.
In cabinet menu mode, if ``MINIAPP_CUSTOM_URL`` is configured the button
opens the relevant section of the cabinet. The target section is determined
by ``cabinet_path`` (explicit) or inferred from ``callback_data`` via
``CALLBACK_TO_CABINET_PATH``.
Button styling (Bot API 9.4):
- ``style`` overrides the button color: ``'primary'`` (blue),
``'success'`` (green), ``'danger'`` (red). When omitted the style is
resolved from ``CABINET_BUTTON_STYLE`` config or per-section defaults.
- ``icon_custom_emoji_id`` shows a custom emoji before the button text
(requires bot owner to have Telegram Premium).
When ``callback_data`` is not found in the mapping and no explicit
``cabinet_path`` is given, the button falls back to a regular Telegram
callback this keeps actions like ``claim_discount_*`` working correctly.
Only ``MINIAPP_CUSTOM_URL`` is considered here the purchase-only URL
(``MINIAPP_PURCHASE_URL``) is intentionally excluded because it cannot
display subscription details and would load indefinitely.
"""
if settings.is_text_main_menu_mode():
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
if miniapp_url:
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=miniapp_url),
)
if settings.is_cabinet_mode():
path = cabinet_path or CALLBACK_TO_CABINET_PATH.get(callback_data)
if path:
url = build_cabinet_url(path)
if url:
# Resolve per-section config from cache
section = CALLBACK_TO_SECTION.get(callback_data)
section_cfg = get_cached_button_styles().get(section or '', {}) if section else {}
# Style chain: explicit param > per-section DB > global config > hardcoded default
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved_style = _resolve_style(style)
elif section_cfg.get('style'):
resolved_style = _resolve_style(section_cfg['style'])
else:
resolved_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip()) or _resolve_style(
CALLBACK_TO_CABINET_STYLE.get(callback_data)
)
# Emoji chain: explicit param > per-section DB
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved_style,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_data)
+1 -15
View File
@@ -129,14 +129,6 @@ async def compute_simple_subscription_price(
additional_devices = max(0, device_limit - settings.DEFAULT_DEVICE_LIMIT)
devices_price_original = additional_devices * settings.PRICE_PER_DEVICE
# Расчёт цены модема (если включён)
modem_enabled = params.get('modem_enabled', False)
modem_price_original = 0
if modem_enabled and settings.is_modem_enabled():
modem_price_per_month = settings.get_modem_price_per_month()
months = calculate_months_from_days(period_days)
modem_price_original = modem_price_per_month * months
promo_group: PromoGroup | None = params.get('promo_group')
if promo_group is None:
@@ -256,11 +248,7 @@ async def compute_simple_subscription_price(
)
total_before_discount = (
base_price_original
+ traffic_price_original
+ devices_price_original
+ servers_price_original
+ modem_price_original
base_price_original + traffic_price_original + devices_price_original + servers_price_original
)
total_discount = base_discount + traffic_discount + devices_discount + servers_discount_total
@@ -274,8 +262,6 @@ async def compute_simple_subscription_price(
'traffic_discount': traffic_discount,
'devices_price': devices_price_original,
'devices_discount': devices_discount,
'modem_price': modem_price_original,
'modem_enabled': modem_enabled,
'servers_price': servers_price_original,
'servers_discount': servers_discount_total,
'servers_final': sum(item['final_price'] for item in server_breakdown),
+105
View File
@@ -0,0 +1,105 @@
import logging
import re
import time
logger = logging.getLogger(__name__)
# Только буквы, цифры, дефис, подчёркивание
PROMO_CODE_PATTERN = re.compile(r'^[A-Za-z0-9_-]+$')
# Лимиты
MAX_FAILED_ATTEMPTS = 5
FAILED_WINDOW_SECONDS = 300 # 5 минут
MAX_ACTIVATIONS_PER_DAY = 5
ACTIVATION_WINDOW_SECONDS = 86400 # 24 часа
class PromoRateLimiter:
"""
In-memory rate limiter для промокодов:
1. Лимит на неудачные попытки (перебор)
2. Лимит на количество активаций за день (стакинг)
"""
def __init__(self):
# user_id → list[timestamp] неудачных попыток
self._failed_attempts: dict[int, list[float]] = {}
# user_id → list[timestamp] успешных активаций
self._activations: dict[int, list[float]] = {}
def record_failed_attempt(self, user_id: int) -> None:
now = time.time()
attempts = self._failed_attempts.get(user_id, [])
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
attempts.append(now)
self._failed_attempts[user_id] = attempts
if len(attempts) >= MAX_FAILED_ATTEMPTS:
logger.warning(
'Promo brute-force: user %s%d failed attempts in %ds',
user_id,
len(attempts),
FAILED_WINDOW_SECONDS,
)
def is_blocked(self, user_id: int) -> bool:
now = time.time()
attempts = self._failed_attempts.get(user_id, [])
attempts = [ts for ts in attempts if now - ts < FAILED_WINDOW_SECONDS]
self._failed_attempts[user_id] = attempts
return len(attempts) >= MAX_FAILED_ATTEMPTS
def get_block_cooldown(self, user_id: int) -> int:
attempts = self._failed_attempts.get(user_id, [])
if not attempts:
return 0
oldest = attempts[0]
remaining = int(FAILED_WINDOW_SECONDS - (time.time() - oldest)) + 1
return max(remaining, 0)
def record_activation(self, user_id: int) -> None:
now = time.time()
activations = self._activations.get(user_id, [])
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
activations.append(now)
self._activations[user_id] = activations
def can_activate(self, user_id: int) -> bool:
now = time.time()
activations = self._activations.get(user_id, [])
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
self._activations[user_id] = activations
return len(activations) < MAX_ACTIVATIONS_PER_DAY
def get_activations_left(self, user_id: int) -> int:
now = time.time()
activations = self._activations.get(user_id, [])
activations = [ts for ts in activations if now - ts < ACTIVATION_WINDOW_SECONDS]
return max(0, MAX_ACTIVATIONS_PER_DAY - len(activations))
def cleanup(self) -> None:
now = time.time()
if len(self._failed_attempts) > 500:
self._failed_attempts = {
uid: [ts for ts in tss if now - ts < FAILED_WINDOW_SECONDS]
for uid, tss in self._failed_attempts.items()
if any(now - ts < FAILED_WINDOW_SECONDS for ts in tss)
}
if len(self._activations) > 500:
self._activations = {
uid: [ts for ts in tss if now - ts < ACTIVATION_WINDOW_SECONDS]
for uid, tss in self._activations.items()
if any(now - ts < ACTIVATION_WINDOW_SECONDS for ts in tss)
}
def validate_promo_format(code: str) -> bool:
"""Проверяет формат промокода: 3-50 символов, только буквы/цифры/дефис/подчёркивание."""
if not code or len(code) < 3 or len(code) > 50:
return False
return bool(PROMO_CODE_PATTERN.match(code))
# Глобальный синглтон
promo_limiter = PromoRateLimiter()
+42 -13
View File
@@ -1,3 +1,4 @@
import html as html_module
import re
from datetime import datetime
@@ -23,6 +24,16 @@ ALLOWED_HTML_TAGS = {
SELF_CLOSING_TAGS = {'br', 'hr', 'img'}
# Разрешённые атрибуты для HTML-тегов
ALLOWED_TAG_ATTRIBUTES = {
'a': {'href'},
'tg-emoji': {'emoji-id'},
'span': {'class'},
}
# Разрешённые URI-схемы в href (allowlist вместо blocklist)
SAFE_URI_SCHEMES = re.compile(r'^(https?://|tg://|mailto:|tel:)', re.IGNORECASE)
def validate_email(email: str) -> bool:
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
@@ -140,25 +151,43 @@ def sanitize_html(text: str) -> str:
# Обработка всех разрешенных тегов
for tag in allowed_tags:
# Паттерн: захватываем &lt;tag&gt;, &lt;/tag&gt;, или &lt;tag атрибуты&gt;
# Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего &gt;
# (?s) - позволяет . захватывать новую строку
# [^>]*? - ленивый захват до >
pattern = rf'(&lt;)(/?{tag}\b)([^>]*?)(&gt;)'
def replace_tag(match):
match.group(1) # &lt;
tag_lower = tag.lower()
def replace_tag(match, _tag=tag_lower):
full_tag_content = match.group(2) # /?tagname
attrs_part = match.group(3) # атрибуты (без >)
match.group(4) # &gt;
attrs_part = match.group(3).removeprefix(' ') # атрибуты (без >)
# Убираем начальный пробел, если есть
attrs_part = attrs_part.removeprefix(' ')
if not attrs_part:
return f'<{full_tag_content}>'
# Формируем результат
if attrs_part:
# Безопасно обрабатываем атрибуты, заменяя только безопасные сущности
# Не разворачиваем &lt; и &gt; внутри атрибутов, чтобы избежать XSS
processed_attrs = attrs_part.replace('&quot;', '"').replace('&#x27;', "'")
# Полное декодирование HTML-сущностей для корректной проверки атрибутов
processed_attrs = html_module.unescape(attrs_part)
# Проверяем whitelist атрибутов для данного тега
allowed_attrs = ALLOWED_TAG_ATTRIBUTES.get(_tag)
if allowed_attrs is None:
# Тег без whitelist — удаляем ВСЕ атрибуты
return f'<{full_tag_content}>'
filtered_parts = []
for attr_match in re.finditer(r'([a-zA-Z][\w-]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\')', processed_attrs):
attr_name = attr_match.group(1).lower()
attr_value = attr_match.group(2) if attr_match.group(2) is not None else attr_match.group(3)
if attr_name not in allowed_attrs:
continue
# href: allowlist безопасных URI-схем
if attr_name == 'href':
# Нормализуем: убираем control chars и пробелы из начала значения
normalized = re.sub(r'[\x00-\x1f\x7f\s]+', '', attr_value)
if not SAFE_URI_SCHEMES.match(normalized):
continue
filtered_parts.append(f'{attr_name}="{attr_value}"')
processed_attrs = ' '.join(filtered_parts)
if processed_attrs:
return f'<{full_tag_content} {processed_attrs}>'
return f'<{full_tag_content}>'
+1 -1
View File
@@ -335,7 +335,7 @@ async def start_round_now(
tags=['contests'],
)
async def list_rounds(
status_filter: str = Query('active', regex='^(active|finished|any)$'),
status_filter: str = Query('active', pattern='^(active|finished|any)$'),
template_id: int | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
+9 -8
View File
@@ -29,10 +29,9 @@ from app.database.crud.promo_group import get_auto_assign_promo_groups
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
from app.database.crud.rules import get_rules_by_language
from app.database.crud.server_squad import (
add_user_to_servers,
get_available_server_squads,
get_server_squad_by_uuid,
remove_user_from_servers,
update_server_user_counts,
)
from app.database.crud.subscription import (
add_subscription_servers,
@@ -5926,10 +5925,6 @@ async def update_subscription_servers_endpoint(
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов (add): {e}')
removed_server_ids = [
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
@@ -5937,10 +5932,16 @@ async def update_subscription_servers_endpoint(
if removed_server_ids:
await remove_subscription_servers(db, subscription.id, removed_server_ids)
if added_server_ids or removed_server_ids:
try:
await remove_user_from_servers(db, removed_server_ids)
await update_server_user_counts(
db,
add_ids=added_server_ids or None,
remove_ids=removed_server_ids or None,
)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов (remove): {e}')
logger.error('Ошибка обновления счётчика серверов: %s', e)
ordered_selection = []
seen_selection = set()
-40
View File
@@ -30,7 +30,6 @@ from ..schemas.subscriptions import (
SubscriptionCreateRequest,
SubscriptionDevicesRequest,
SubscriptionExtendRequest,
SubscriptionModemRequest,
SubscriptionResponse,
SubscriptionSquadRequest,
SubscriptionTrafficRequest,
@@ -54,7 +53,6 @@ def _serialize_subscription(subscription: Subscription) -> SubscriptionResponse:
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
@@ -323,41 +321,3 @@ async def delete_subscription(
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
@router.post('/{subscription_id}/modem', response_model=SubscriptionResponse)
async def set_subscription_modem(
subscription_id: int,
payload: SubscriptionModemRequest,
_: Any = Security(require_api_token),
db: AsyncSession = Depends(get_db_session),
) -> SubscriptionResponse:
"""Включить или выключить модем для подписки."""
subscription = await _get_subscription(db, subscription_id)
if subscription.is_trial:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem is not available for trial subscriptions')
if not settings.is_modem_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Modem feature is disabled')
current_modem = getattr(subscription, 'modem_enabled', False) or False
if payload.enabled == current_modem:
return _serialize_subscription(subscription)
if payload.enabled:
subscription.modem_enabled = True
subscription.device_limit = (subscription.device_limit or 1) + 1
else:
subscription.modem_enabled = False
if subscription.device_limit and subscription.device_limit > 1:
subscription.device_limit = subscription.device_limit - 1
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
subscription = await _get_subscription(db, subscription.id)
return _serialize_subscription(subscription)
-1
View File
@@ -70,7 +70,6 @@ def _serialize_subscription(subscription: Subscription | None) -> SubscriptionSu
traffic_limit_gb=subscription.traffic_limit_gb,
traffic_used_gb=subscription.traffic_used_gb,
device_limit=subscription.device_limit,
modem_enabled=getattr(subscription, 'modem_enabled', False) or False,
autopay_enabled=subscription.autopay_enabled,
autopay_days_before=subscription.autopay_days_before,
subscription_url=subscription.subscription_url,
-5
View File
@@ -16,7 +16,6 @@ class SubscriptionResponse(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: int | None = None
subscription_url: str | None = None
@@ -51,7 +50,3 @@ class SubscriptionDevicesRequest(BaseModel):
class SubscriptionSquadRequest(BaseModel):
squad_uuid: str
class SubscriptionModemRequest(BaseModel):
enabled: bool
-1
View File
@@ -24,7 +24,6 @@ class SubscriptionSummary(BaseModel):
traffic_limit_gb: int
traffic_used_gb: float
device_limit: int
modem_enabled: bool = False
autopay_enabled: bool
autopay_days_before: int | None = None
subscription_url: str | None = None
+2 -2
View File
@@ -329,7 +329,7 @@
Функции: `_t` — Helper for localized button labels with fallbacks., `get_admin_main_keyboard`, `get_admin_users_submenu_keyboard`, `get_admin_promo_submenu_keyboard`, `get_admin_communications_submenu_keyboard`, `get_admin_support_submenu_keyboard`, `get_admin_settings_submenu_keyboard`, `get_admin_system_submenu_keyboard`, `get_admin_reports_keyboard`, `get_admin_report_result_keyboard`, `get_admin_users_keyboard`, `get_admin_users_filters_keyboard`, `get_admin_subscriptions_keyboard`, `get_admin_promocodes_keyboard`, `get_admin_campaigns_keyboard`, `get_campaign_management_keyboard`, `get_campaign_edit_keyboard`, `get_campaign_bonus_type_keyboard`, `get_promocode_management_keyboard`, `get_admin_messages_keyboard`, `get_admin_monitoring_keyboard`, `get_admin_remnawave_keyboard`, `get_admin_statistics_keyboard`, `get_user_management_keyboard`, `get_user_promo_group_keyboard`, `get_confirmation_keyboard`, `get_promocode_type_keyboard`, `get_promocode_list_keyboard`, `get_broadcast_target_keyboard`, `get_custom_criteria_keyboard`, `get_broadcast_history_keyboard`, `get_sync_options_keyboard`, `get_sync_confirmation_keyboard`, `get_sync_result_keyboard`, `get_period_selection_keyboard`, `get_node_management_keyboard`, `get_squad_management_keyboard`, `get_squad_edit_keyboard`, `get_monitoring_keyboard`, `get_monitoring_logs_keyboard`, `get_monitoring_logs_navigation_keyboard`, `get_log_detail_keyboard`, `get_monitoring_clear_confirm_keyboard`, `get_monitoring_status_keyboard`, `get_monitoring_settings_keyboard`, `get_log_type_filter_keyboard`, `get_admin_servers_keyboard`, `get_server_edit_keyboard`, `get_admin_pagination_keyboard`, `get_maintenance_keyboard`, `get_sync_simplified_keyboard`, `get_welcome_text_keyboard`, `get_broadcast_button_config`, `get_broadcast_button_labels`, `get_message_buttons_selector_keyboard`, `get_broadcast_media_keyboard`, `get_media_confirm_keyboard`, `get_updated_message_buttons_selector_keyboard_with_media`
- `app/keyboards/inline.py` — Python-модуль
Классы: нет
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_text_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
Функции: `_get_localized_value`, `_build_additional_buttons`, `get_rules_keyboard`, `get_channel_sub_keyboard`, `get_post_registration_keyboard`, `get_language_selection_keyboard`, `_build_cabinet_main_menu_keyboard`, `get_main_menu_keyboard`, `get_info_menu_keyboard`, `get_happ_download_button_row`, `get_happ_cryptolink_keyboard`, `get_happ_download_platform_keyboard`, `get_happ_download_link_keyboard`, `get_back_keyboard`, `get_server_status_keyboard`, `get_insufficient_balance_keyboard`, `get_subscription_keyboard`, `get_payment_methods_keyboard_with_cart`, `get_subscription_confirm_keyboard_with_cart`, `get_insufficient_balance_keyboard_with_cart`, `get_trial_keyboard`, `get_subscription_period_keyboard`, `get_traffic_packages_keyboard`, `get_countries_keyboard`, `get_devices_keyboard`, `_get_device_declension`, `get_subscription_confirm_keyboard`, `get_balance_keyboard`, `get_payment_methods_keyboard`, `get_yookassa_payment_keyboard`, `get_autopay_notification_keyboard`, `get_subscription_expiring_keyboard`, `get_referral_keyboard`, `get_support_keyboard`, `get_pagination_keyboard`, `get_confirmation_keyboard`, `get_autopay_keyboard`, `get_autopay_days_keyboard`, `_get_days_word`, `get_extend_subscription_keyboard`, `get_add_traffic_keyboard`, `get_change_devices_keyboard`, `get_confirm_change_devices_keyboard`, `get_reset_traffic_confirm_keyboard`, `get_manage_countries_keyboard`, `get_device_selection_keyboard`, `get_connection_guide_keyboard`, `get_app_selection_keyboard`, `get_specific_app_keyboard`, `get_extend_subscription_keyboard_with_prices`, `get_cryptobot_payment_keyboard`, `get_devices_management_keyboard`, `get_updated_subscription_settings_keyboard`, `get_device_reset_confirm_keyboard`, `get_device_management_help_keyboard`, `get_ticket_cancel_keyboard`, `get_my_tickets_keyboard`, `get_ticket_view_keyboard`, `get_ticket_reply_cancel_keyboard`, `get_admin_tickets_keyboard`, `get_admin_ticket_view_keyboard`, `get_admin_ticket_reply_cancel_keyboard`
- `app/keyboards/reply.py` — Python-модуль
Классы: нет
Функции: `get_main_reply_keyboard`, `get_admin_reply_keyboard`, `get_cancel_keyboard`, `get_confirmation_reply_keyboard`, `get_skip_keyboard`, `remove_keyboard`, `get_contact_keyboard`, `get_location_keyboard`
@@ -512,7 +512,7 @@
Функции: `is_qr_message`, `_get_language`, `_default_privacy_hint`, `append_privacy_hint`, `prepare_privacy_safe_kwargs`, `is_privacy_restricted_error`, `patch_message_methods`
- `app/utils/miniapp_buttons.py` — Python-модуль
Классы: нет
Функции: `build_miniapp_or_callback_button` — Create a button that opens the miniapp in text menu mode.
Функции: `build_cabinet_url`, `build_miniapp_or_callback_button` — Create a button that opens the cabinet miniapp section or falls back to a callback.
- `app/utils/pagination.py` — Python-модуль
Классы: `PaginationResult` (1 методов)
Функции: `paginate_list`, `get_pagination_info`, `get_page_numbers`
+15 -15
View File
@@ -1,26 +1,26 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.10.1"
version = "3.12.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
requires-python = '==3.13.*'
dependencies = [
'aiogram>=3.22.0',
'sqlalchemy>=2.0.43',
'alembic>=1.16.5',
'asyncpg>=0.30.0',
'aiosqlite>=0.21.0',
'fastapi[standard]>=0.115.6',
'redis>=5.0.1',
'pyyaml>=6.0.2',
'yookassa>=3.9.0',
"aiogram>=3.25.0",
'sqlalchemy>=2.0.46',
'alembic>=1.18.4',
'asyncpg>=0.31.0',
'aiosqlite>=0.22.1',
'fastapi[standard]>=0.129.0',
'redis>=7.1.1',
'pyyaml>=6.0.3',
'yookassa>=3.10.0',
'python-dateutil>=2.9.0.post0',
'cryptography>=41.0.0',
'qrcode[pil]>=7.4.2',
'packaging>=23.2',
'bcrypt>=4.2.0',
'pyjwt>=2.8.0',
'cryptography>=44.0.1',
'qrcode[pil]>=8.0',
'packaging>=26.0',
'bcrypt>=5.0.0',
'pyjwt>=2.11.0',
'pyzipper>=0.3.6',
]
+22 -22
View File
@@ -1,24 +1,24 @@
# Основные зависимости
aiogram==3.22.0
aiohttp==3.12.15
asyncpg==0.30.0
SQLAlchemy==2.0.43
alembic==1.16.5
aiosqlite==0.21.0
aiogram==3.25.0
aiohttp==3.13.3
asyncpg==0.31.0
SQLAlchemy==2.0.46
alembic==1.18.4
aiosqlite==0.22.1
# Дополнительные зависимости
pydantic==2.11.9
pydantic-settings==2.10.1
python-dotenv==1.1.1
redis==5.0.1
PyYAML==6.0.2
fastapi==0.115.6
uvicorn==0.32.1
pydantic==2.12.5
pydantic-settings==2.13.0
python-dotenv==1.2.1
redis==7.1.1
PyYAML==6.0.3
fastapi==0.129.0
uvicorn==0.40.0
websockets>=12.0
python-multipart==0.0.9
python-multipart==0.0.22
# YooKassa SDK
yookassa==3.9.0
yookassa==3.10.0
# NaloGO для чеков в налоговую
# nalogo - используем локальную исправленную версию в app/lib/nalogo/
@@ -28,21 +28,21 @@ httpx # зависимость для nalogo
structlog==23.2.0
# Планировщик задач для техработ
APScheduler==3.11.0
APScheduler==3.11.2
# Утилиты
python-dateutil==2.9.0.post0
pytz==2023.4
cryptography>=41.0.0
qrcode[pil]==7.4.2
cryptography>=44.0.1
qrcode[pil]==8.2
# Личный кабинет (Cabinet)
bcrypt==4.2.0
PyJWT==2.8.0
email-validator==2.1.0
bcrypt==5.0.0
PyJWT==2.11.0
email-validator==2.3.0
# Для работы с версиями
packaging==23.2
packaging==26.0
aiofiles==23.2.1
-395
View File
@@ -1,395 +0,0 @@
"""
Тесты для ModemService - управление модемом в подписке.
"""
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from app.services.modem_service import (
ModemError,
ModemService,
get_modem_service,
)
def create_mock_settings():
"""Создаёт мок настроек приложения."""
settings = MagicMock()
settings.is_modem_enabled.return_value = True
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
settings.get_modem_period_discount.return_value = 0
return settings
def create_sample_user():
"""Создаёт пример пользователя."""
user = SimpleNamespace(
id=1,
telegram_id=123456789,
balance_kopeks=50000, # 500 рублей
language='ru',
subscription=None,
)
return user
def create_sample_subscription():
"""Создаёт пример подписки."""
subscription = SimpleNamespace(
id=1,
user_id=1,
is_trial=False,
modem_enabled=False,
device_limit=2,
end_date=datetime.utcnow() + timedelta(days=30),
updated_at=datetime.utcnow(),
)
return subscription
def create_trial_subscription():
"""Создаёт триальную подписку."""
subscription = SimpleNamespace(
id=2,
user_id=1,
is_trial=True,
modem_enabled=False,
device_limit=1,
end_date=datetime.utcnow() + timedelta(days=7),
updated_at=datetime.utcnow(),
)
return subscription
def create_modem_service(monkeypatch):
"""Создаёт ModemService с замоканными настройками."""
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
return ModemService(), mock_settings
class TestModemServiceAvailability:
"""Тесты проверки доступности модема."""
def test_check_availability_no_subscription(self, monkeypatch):
"""Модем недоступен без подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.subscription = None
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.NO_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_trial_subscription(self, monkeypatch):
"""Модем недоступен для триальной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
trial_subscription = create_trial_subscription()
sample_user.subscription = trial_subscription
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.TRIAL_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
"""Модем недоступен, если отключён в настройках."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
mock_settings.is_modem_enabled.return_value = False
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.MODEM_DISABLED
def test_check_availability_success(self, monkeypatch):
"""Модем доступен для платной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user)
assert result.available
assert result.error is None
assert not result.modem_enabled
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
"""Нельзя подключить уже подключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_enable=True)
assert not result.available
assert result.error == ModemError.ALREADY_ENABLED
assert result.modem_enabled
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
"""Нельзя отключить неподключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_disable=True)
assert not result.available
assert result.error == ModemError.NOT_ENABLED
assert not result.modem_enabled
class TestModemServicePricing:
"""Тесты расчёта цены модема."""
def test_calculate_price_one_month(self, monkeypatch):
"""Расчёт цены на 1 месяц."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 10000
assert result.final_price == 10000
assert result.charged_months == 1
assert result.discount_percent == 0
assert not result.has_discount
def test_calculate_price_three_months(self, monkeypatch):
"""Расчёт цены на 3 месяца."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000 # 3 * 10000
assert result.charged_months == 3
def test_calculate_price_with_discount(self, monkeypatch):
"""Расчёт цены со скидкой."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000
assert result.discount_percent == 10
assert result.discount_amount == 3000
assert result.final_price == 27000
assert result.has_discount
class TestModemServiceBalance:
"""Тесты проверки баланса."""
def test_check_balance_sufficient(self, monkeypatch):
"""Баланса достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 50000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert has_funds
assert missing == 0
def test_check_balance_insufficient(self, monkeypatch):
"""Баланса недостаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 5000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert not has_funds
assert missing == 5000
def test_check_balance_zero_price(self, monkeypatch):
"""Нулевая цена - всегда достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 0
has_funds, missing = modem_service.check_balance(sample_user, 0)
assert has_funds
assert missing == 0
class TestModemServicePeriodWarning:
"""Тесты предупреждений о сроке действия."""
def test_warning_critical(self, monkeypatch):
"""Критическое предупреждение при <= 7 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(7) == 'critical'
assert modem_service.get_period_warning_level(5) == 'critical'
assert modem_service.get_period_warning_level(1) == 'critical'
def test_warning_info(self, monkeypatch):
"""Информационное предупреждение при <= 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(30) == 'info'
assert modem_service.get_period_warning_level(15) == 'info'
assert modem_service.get_period_warning_level(8) == 'info'
def test_warning_none(self, monkeypatch):
"""Нет предупреждения при > 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(31) is None
assert modem_service.get_period_warning_level(60) is None
assert modem_service.get_period_warning_level(90) is None
class TestModemServiceEnable:
"""Тесты подключения модема."""
async def test_enable_modem_success(self, monkeypatch):
"""Успешное подключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=True)
mock_create_transaction = AsyncMock()
mock_update_remnawave = AsyncMock()
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
monkeypatch.setattr('app.services.modem_service.create_transaction', mock_create_transaction)
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
assert result.success
assert result.error is None
assert result.charged_amount == 10000
assert sample_subscription.modem_enabled is True
assert sample_subscription.device_limit == 3 # было 2, стало 3
async def test_enable_modem_insufficient_funds(self, monkeypatch):
"""Недостаточно средств для подключения."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 1000 # недостаточно
mock_db = AsyncMock()
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
assert not result.success
assert result.error == ModemError.INSUFFICIENT_FUNDS
async def test_enable_modem_charge_error(self, monkeypatch):
"""Ошибка списания средств."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=False) # ошибка списания
monkeypatch.setattr('app.services.modem_service.subtract_user_balance', mock_subtract)
result = await modem_service.enable_modem(mock_db, sample_user, sample_subscription)
assert not result.success
assert result.error == ModemError.CHARGE_ERROR
class TestModemServiceDisable:
"""Тесты отключения модема."""
async def test_disable_modem_success(self, monkeypatch):
"""Успешное отключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_subscription.device_limit = 3
sample_user.subscription = sample_subscription
mock_db = AsyncMock()
mock_update_remnawave = AsyncMock()
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.disable_modem(mock_db, sample_user, sample_subscription)
assert result.success
assert result.error is None
assert sample_subscription.modem_enabled is False
assert sample_subscription.device_limit == 2 # было 3, стало 2
class TestModemServiceSingleton:
"""Тесты singleton паттерна."""
def test_get_modem_service_returns_same_instance(self, monkeypatch):
"""get_modem_service возвращает один и тот же экземпляр."""
# Сбрасываем глобальный экземпляр
import app.services.modem_service as modem_module
modem_module._modem_service = None
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
service1 = get_modem_service()
service2 = get_modem_service()
assert service1 is service2
class TestModemEnabledGetter:
"""Тесты безопасного получения статуса модема."""
def test_get_modem_enabled_true(self, monkeypatch):
"""Модем включён."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
assert modem_service.get_modem_enabled(sample_subscription) is True
def test_get_modem_enabled_false(self, monkeypatch):
"""Модем выключен."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
assert modem_service.get_modem_enabled(sample_subscription) is False
def test_get_modem_enabled_none_subscription(self, monkeypatch):
"""Подписка None."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_modem_enabled(None) is False
def test_get_modem_enabled_no_attribute(self, monkeypatch):
"""У подписки нет атрибута modem_enabled."""
modem_service, _ = create_modem_service(monkeypatch)
subscription = SimpleNamespace(id=1) # без modem_enabled
assert modem_service.get_modem_enabled(subscription) is False
Generated
+33 -32
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]]
name = "aiogram"
version = "3.24.0"
version = "3.25.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -23,9 +23,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fd/2f/04f47e81def8f2168679b1551e665e7ee02cf063e7bddace9fb5d1ce2f35/aiogram-3.24.0.tar.gz", hash = "sha256:ec547ede5bfa8a7a4f5fb02c75391333fc43b6f3de6a6d3f00a32e27628df5f6", size = 1713321, upload-time = "2026-01-02T00:56:55.3Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/31/360c4ce76e60e9e7bcdda1af1ab4331d78837fbb22847a62121ad32b7672/aiogram-3.25.0.tar.gz", hash = "sha256:8a8b0c34f8c4ca8a6501b954abb0eeba26743449e35e20b70c0d810347354c3c", size = 1721010, upload-time = "2026-02-10T21:50:25.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a5/7ba5f75b56f87a956b9e5a3e823bcbb5b55fc968914a16f3c7aa659cfc89/aiogram-3.24.0-py3-none-any.whl", hash = "sha256:eb3cc05b0ec53c7e24d7eada5c069aee2f431332e2e7bc2c8adf30d13b02f715", size = 706866, upload-time = "2026-01-02T00:56:53.115Z" },
{ url = "https://files.pythonhosted.org/packages/cf/be/1090252415e192687985517162dbdcee2ec4150cda1fa52bf57ae1f1c2a8/aiogram-3.25.0-py3-none-any.whl", hash = "sha256:0243966e93fbde14e90c0dfd0b3776c637ebf7ddcca2c7ee81ecbd68d9490cce", size = 713972, upload-time = "2026-02-10T21:50:23.253Z" },
]
[[package]]
@@ -94,16 +94,16 @@ wheels = [
[[package]]
name = "alembic"
version = "1.18.1"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
@@ -413,17 +413,18 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.128.0"
version = "0.129.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" }
sdist = { url = "https://files.pythonhosted.org/packages/48/47/75f6bea02e797abff1bca968d5997793898032d9923c1935ae2efdece642/fastapi-0.129.0.tar.gz", hash = "sha256:61315cebd2e65df5f97ec298c888f9de30430dd0612d59d6480beafbc10655af", size = 375450, upload-time = "2026-02-12T13:54:52.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" },
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
]
[package.optional-dependencies]
@@ -977,11 +978,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.10.1"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[[package]]
@@ -1105,16 +1106,16 @@ pil = [
[[package]]
name = "redis"
version = "7.1.0"
version = "7.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/80/2971931d27651affa88a44c0ad7b8c4a19dc29c998abb20b23868d319b59/redis-7.1.1.tar.gz", hash = "sha256:a2814b2bda15b39dad11391cc48edac4697214a8a5a4bd10abe936ab4892eb43", size = 4800064, upload-time = "2026-02-09T18:39:40.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
{ url = "https://files.pythonhosted.org/packages/29/55/1de1d812ba1481fa4b37fb03b4eec0fcb71b6a0d44c04ea3482eb017600f/redis-7.1.1-py3-none-any.whl", hash = "sha256:f77817f16071c2950492c67d40b771fa493eb3fccc630a424a10976dbb794b7a", size = 356057, upload-time = "2026-02-09T18:39:38.602Z" },
]
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.8.0"
version = "3.10.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },
@@ -1145,22 +1146,22 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiogram", specifier = ">=3.22.0" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "alembic", specifier = ">=1.16.5" },
{ name = "asyncpg", specifier = ">=0.30.0" },
{ name = "bcrypt", specifier = ">=4.2.0" },
{ name = "cryptography", specifier = ">=41.0.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
{ name = "packaging", specifier = ">=23.2" },
{ name = "pyjwt", specifier = ">=2.8.0" },
{ name = "aiogram", specifier = ">=3.25.0" },
{ name = "aiosqlite", specifier = ">=0.22.1" },
{ name = "alembic", specifier = ">=1.18.4" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "bcrypt", specifier = ">=5.0.0" },
{ name = "cryptography", specifier = ">=44.0.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.129.0" },
{ name = "packaging", specifier = ">=26.0" },
{ name = "pyjwt", specifier = ">=2.11.0" },
{ name = "python-dateutil", specifier = ">=2.9.0.post0" },
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "pyzipper", specifier = ">=0.3.6" },
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4.2" },
{ name = "redis", specifier = ">=5.0.1" },
{ name = "sqlalchemy", specifier = ">=2.0.43" },
{ name = "yookassa", specifier = ">=3.9.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
{ name = "redis", specifier = ">=7.1.1" },
{ name = "sqlalchemy", specifier = ">=2.0.46" },
{ name = "yookassa", specifier = ">=3.10.0" },
]
[package.metadata.requires-dev]
@@ -1545,7 +1546,7 @@ wheels = [
[[package]]
name = "yookassa"
version = "3.9.0"
version = "3.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
@@ -1554,4 +1555,4 @@ dependencies = [
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/05/cbacdfd7d5478956e184cb9e1e99321e1ae14f325cb8eadaf6e7a64206ab/yookassa-3.9.0.tar.gz", hash = "sha256:e8a78fcd96543a5700a4ae9e1411b54f729998f4c051c06c182b9946926e67f5", size = 139247, upload-time = "2025-12-17T09:11:55.657Z" }
sdist = { url = "https://files.pythonhosted.org/packages/81/d7/d5ee3948cbf0379a6af4e0a6da2c39e2e455a84b10651c8ea75c75fea50f/yookassa-3.10.0.tar.gz", hash = "sha256:5d138fa568129989688f632122719741a8ad983bb7c1f1f1b6294e7a83d67915", size = 140883, upload-time = "2026-01-28T12:23:50.413Z" }