Compare commits

...

56 Commits

Author SHA1 Message Date
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
Egor aabadf1ffd Merge pull request #2592 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.1
2026-02-11 06:11:43 +03:00
github-actions[bot] e5e5bb3354 chore(main): release 3.10.1 2026-02-11 03:11:02 +00:00
Egor ea41b0af7a Merge pull request #2591 from BEDOLAGA-DEV/dev
Dev
2026-02-11 06:10:38 +03:00
Fringg 3193ffbd1b fix: change CryptoBot URL priority to bot_invoice_url for Telegram opening 2026-02-11 05:50:43 +03:00
Egor 5da01cc6df Merge pull request #2590 from BEDOLAGA-DEV/main
w
2026-02-11 04:47:13 +03:00
Fringg 887ea9cf5a style: format subscription.py with ruff 2026-02-11 04:45:42 +03:00
Fringg bee4aa4284 fix: protect server counter callers and fix tariff change detection
- Wrap unprotected add/remove_user_to/from_servers calls in try/except
  in miniapp.py and cabinet subscription.py to prevent 500 errors
- Fix is_tariff_change to include classic-to-tariff transitions
  (subscription.tariff_id=None → new tariff_id) so purchased traffic
  is properly reset when switching modes
2026-02-11 04:44:15 +03:00
Fringg b167ed3dd1 fix: preserve purchased traffic when extending same tariff
extend_subscription was unconditionally resetting purchased_traffic_gb
and deleting TrafficPurchase records whenever traffic_limit_gb was passed,
even when extending the same tariff (not changing). Now only resets
on actual tariff change (is_tariff_change=True), preserving purchased
traffic on same-tariff extensions.
2026-02-11 04:38:08 +03:00
Fringg 6cec024e46 fix: use flush instead of commit in server counter functions
add_user_to_servers and remove_user_from_servers were calling
db.commit() internally, breaking transaction atomicity for all
callers that perform additional operations afterward. Changed to
db.flush() so the caller controls the commit boundary.
2026-02-11 04:15:50 +03:00
Fringg 2094886990 fix: address review issues in backup, updates, and webhook handlers
- backup: add DATE column parsing in restore, use is_file() in delete_backup
- updates: add missing callback.answer() in show_updates_menu early return
- webhook: add server counter decrement and SubscriptionServer cleanup on user deletion, use single commit
2026-02-11 04:09:39 +03:00
Fringg b0fd38d60c fix: clear subscription data when user deleted from Remnawave panel
Previously only status was set to expired and remnawave_uuid cleared.
Now also clears subscription_url, subscription_crypto_link,
remnawave_short_uuid, and connected_squads so the bot correctly
shows no active subscription after panel deletion.
2026-02-11 04:02:09 +03:00
Fringg 3a680b41b0 fix: suppress 'message is not modified' error in updates panel
- Remove dangling version_info['repo_url'] expression
- Handle 'message is not modified' in all three update handlers
  to prevent error screen on repeated button clicks
2026-02-11 03:47:30 +03:00
Fringg 02e40bd6f7 fix: expand backup coverage to all 68 models and harden restore
- Add 37 missing models to backup (payment providers, polls, contests,
  wheel, FAQ, promo offers, webhooks, configs, menu buttons, etc.)
- Add tariff_promo_groups and payment_method_promo_groups association tables
- Replace hardcoded association restore with generic handler
- Fix transaction atomicity: flush instead of commit in inner methods,
  remove inner rollback calls, single commit/rollback in outer handler
- Fix composite PK support for UserPromoGroup (was only detecting first PK)
- Fix duplicate insert bug when clear_existing=True and record already exists
- Add cabinet_refresh_tokens to clear list, fix support_audit_logs deletion order
- Add Time column parsing for ReferralContest.daily_summary_time
- Security: tarfile filter='data', path traversal protection in _restore_files
  and delete_backup, os.sep in startswith checks
2026-02-11 03:35:16 +03:00
Fringg 19dabf3851 fix: allow purchase when recalculated price is lower than cached
Only block purchase when the price increased (user would overpay).
When a promo discount activates between viewing price and confirming,
the recalculated price is lower — allow the purchase at the new price
instead of forcing the user to restart the checkout flow.
2026-02-11 02:30:40 +03:00
Fringg eaf3a07579 fix: use callback fallback when MINIAPP_CUSTOM_URL is not set
Only consider MINIAPP_CUSTOM_URL for miniapp buttons, not the
purchase-only MINIAPP_PURCHASE_URL which cannot display subscription
info and loads indefinitely. When no custom URL is configured, fall
back to regular callback_data so the bot shows subscription natively.
2026-02-11 01:58:40 +03:00
Fringg be1da976e1 fix: ignore 'message is not modified' on privacy policy decline
User clicking Decline twice produced the same edit_text causing
TelegramBadRequest. Silently ignore it and remove pointless retry.
2026-02-11 01:40:49 +03:00
Fringg a1ffd5bda6 fix: prevent cascading greenlet errors after sync rollback
After db.rollback() all ORM objects expire. Subsequent attribute access
triggers lazy load in async context causing greenlet_spawn errors for
every remaining user. Break the sync loop after rollback instead of
continuing with a corrupted session.

Also downgrade TelegramNetworkError to warning in channel_checker.
2026-02-11 01:39:39 +03:00
Fringg d58a80f3ea fix: handle StaleDataError in webhook when user already deleted
When a user is deleted via cabinet, RemnaWave sends user.disabled webhook
but the subscription row is already cascade-deleted. This caused
StaleDataError on commit + PendingRollbackError when logging user.id.

Save user_id before handler call and catch StaleDataError as warning.
2026-02-11 01:18:58 +03:00
Egor 45c7afe34c Update README.md 2026-02-11 01:03:58 +03:00
Fringg e43a8d6ce4 fix: downgrade Telegram timeout errors to warning in monitoring service
Add TelegramNetworkError handling before generic Exception catch in all
notification methods to prevent timeout errors from generating error
reports in chat. Timeouts are transient network issues, not bugs.
2026-02-10 23:11:48 +03:00
Fringg e94b93d0c1 fix: handle nullable traffic_limit_gb and end_date in subscription model
Add None-safety guards to Subscription model properties (is_active,
is_expired, should_be_expired, actual_status, days_left,
traffic_used_percent) and pricing handler comparisons to prevent
TypeError when nullable columns contain None values.
2026-02-10 20:35:42 +03:00
54 changed files with 1703 additions and 1797 deletions
+27 -1
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
@@ -685,7 +711,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.0"
".": "3.11.0"
}
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## [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)
### Bug Fixes
* address review issues in backup, updates, and webhook handlers ([2094886](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/20948869902dc570681b05709ac8d51996330a6e))
* allow purchase when recalculated price is lower than cached ([19dabf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/19dabf38512ae0c2121108d0b92fc8f384292484))
* change CryptoBot URL priority to bot_invoice_url for Telegram opening ([3193ffb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3193ffbd1bee07cb79824d87cb0f77b473b22989))
* clear subscription data when user deleted from Remnawave panel ([b0fd38d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b0fd38d60c22247a0086c570665b92c73a060f2f))
* downgrade Telegram timeout errors to warning in monitoring service ([e43a8d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e43a8d6ce4c40a7212bf90644f82da109717bdcb))
* expand backup coverage to all 68 models and harden restore ([02e40bd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02e40bd6f7ef8e653cae53ccd127f2f79009e0d4))
* handle nullable traffic_limit_gb and end_date in subscription model ([e94b93d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e94b93d0c10b4e61d7750ca47e1b2f888f5873ed))
* handle StaleDataError in webhook when user already deleted ([d58a80f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d58a80f3eaa64a6fc899e10b3b14584fb7fc18a9))
* ignore 'message is not modified' on privacy policy decline ([be1da97](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be1da976e14a35e6cca01a7fca7529c55c1a208b))
* preserve purchased traffic when extending same tariff ([b167ed3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b167ed3dd1c6e6239db2bdbb8424bcb1fb7715d9))
* prevent cascading greenlet errors after sync rollback ([a1ffd5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1ffd5bda6b63145104ce750835d8e6492d781dc))
* protect server counter callers and fix tariff change detection ([bee4aa4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bee4aa42842b8b6611c7c268bcfced408a227bc0))
* suppress 'message is not modified' error in updates panel ([3a680b4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3a680b41b0124848572809d187cab720e1db8506))
* use callback fallback when MINIAPP_CUSTOM_URL is not set ([eaf3a07](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eaf3a07579729031030308d77f61a5227b796c02))
* use flush instead of commit in server counter functions ([6cec024](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6cec024e46ef9177cb59aa81590953c9a75d81bb))
## [3.10.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.1...v3.10.0) (2026-02-10)
+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.0" # x-release-please-version
ARG VERSION="v3.11.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+1 -2
View File
@@ -1118,11 +1118,10 @@ openssl rand -hex 32
**2. Настройка в панели Remnawave:**
В панели Remnawave перейдите в раздел **Настройки > Вебхуки** и создайте новый вебхук:
В env панели Remnawave заполните:
- **URL**: `https://hooks.domain.com/remnawave-webhook`
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
- **Events**: выберите нужные события или все
**3. Настройка прокси:**
+20 -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,26 @@ 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 не задан! '
'Кнопка "Подключиться" не будет работать.'
)
logger.info('Бот успешно настроен')
return bot, dp
+2
View File
@@ -9,6 +9,7 @@ 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 +90,7 @@ 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)
# WebSocket route
router.include_router(websocket_router)
+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()
+2 -2
View File
@@ -420,9 +420,9 @@ async def create_topup(
)
if result:
payment_url = (
result.get('web_app_invoice_url')
result.get('bot_invoice_url')
or result.get('mini_app_invoice_url')
or result.get('bot_invoice_url')
or result.get('web_app_invoice_url')
)
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
else:
+7 -4
View File
@@ -619,7 +619,7 @@ 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:
@@ -724,7 +724,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(
@@ -2407,7 +2407,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(
@@ -3216,7 +3216,10 @@ async def update_countries(
added_server_ids = await get_server_ids_by_uuids(db, added)
if added_server_ids:
await add_subscription_servers(db, user.subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов: {e}')
# Update connected squads
user.subscription.connected_squads = selected_countries
+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
+18 -68
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)
@@ -508,7 +517,7 @@ class Settings(BaseSettings):
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
CONNECT_BUTTON_MODE: str = 'guide'
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
MINIAPP_PURCHASE_URL: str = ''
@@ -1057,7 +1066,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]:
@@ -1529,9 +1538,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 +1548,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 - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
+84 -8
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,40 +759,90 @@ 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)
.values(current_users=ServerSquad.current_users + 1)
)
await db.commit()
await db.flush()
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
return True
except Exception as e:
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
await db.rollback()
return False
raise
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)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.commit()
await db.flush()
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
return True
except Exception as e:
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
await db.rollback()
return False
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]:
+47 -26
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',
@@ -357,9 +357,8 @@ async def extend_subscription(
)
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
is_tariff_change = (
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
)
# Включает переход из классического режима (tariff_id=None) в тарифный
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
if is_tariff_change:
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id}{tariff_id}')
@@ -440,17 +439,28 @@ async def extend_subscription(
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
# Сбрасываем все докупки трафика при смене тарифа
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
if is_tariff_change:
# При СМЕНЕ тарифа сбрасываем все докупки трафика
subscription.traffic_limit_gb = traffic_limit_gb
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
)
else:
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
purchased = subscription.purchased_traffic_gb or 0
subscription.traffic_limit_gb = traffic_limit_gb + purchased
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
)
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
# В режиме тарифов сохраняем докупленный трафик при продлении
@@ -615,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:
@@ -623,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,
)
@@ -642,7 +655,7 @@ async def decrement_subscription_server_counts(
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
subscription.id,
sub_id,
error,
)
@@ -652,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,
)
+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:
+27 -14
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,23 +1153,35 @@ 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:
current_time = datetime.utcnow()
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
return (
self.status == SubscriptionStatus.ACTIVE.value
and self.end_date is not None
and self.end_date > current_time
)
@property
def is_expired(self) -> bool:
"""Проверяет, истёк ли срок подписки"""
return self.end_date <= datetime.utcnow()
return self.end_date is not None and self.end_date <= datetime.utcnow()
@property
def should_be_expired(self) -> bool:
current_time = datetime.utcnow()
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
return (
self.status == SubscriptionStatus.ACTIVE.value
and self.end_date is not None
and self.end_date <= current_time
)
@property
def actual_status(self) -> str:
@@ -1182,12 +1194,12 @@ class Subscription(Base):
return 'disabled'
if self.status == SubscriptionStatus.ACTIVE.value:
if self.end_date <= current_time:
if self.end_date is None or self.end_date <= current_time:
return 'expired'
return 'active'
if self.status == SubscriptionStatus.TRIAL.value:
if self.end_date <= current_time:
if self.end_date is None or self.end_date <= current_time:
return 'expired'
return 'trial'
@@ -1230,6 +1242,8 @@ class Subscription(Base):
@property
def days_left(self) -> int:
if self.end_date is None:
return 0
current_time = datetime.utcnow()
if self.end_date <= current_time:
return 0
@@ -1255,11 +1269,10 @@ class Subscription(Base):
@property
def traffic_used_percent(self) -> float:
if self.traffic_limit_gb == 0:
if not self.traffic_limit_gb:
return 0.0
if self.traffic_limit_gb > 0:
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
return 0.0
used = self.traffic_used_gb or 0.0
return min((used / self.traffic_limit_gb) * 100, 100.0)
def extend_subscription(self, days: int):
if self.end_date > datetime.utcnow():
@@ -1770,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):
@@ -2060,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')
+10 -1
View File
@@ -69,6 +69,10 @@ async def show_updates_menu(callback: types.CallbackQuery, db_user: User, db: As
await callback.answer()
except Exception as e:
if 'message is not modified' in str(e).lower():
logger.debug('📝 Сообщение не изменено в show_updates_menu')
await callback.answer()
return
logger.error(f'Ошибка показа меню обновлений: {e}')
await callback.answer('❌ Ошибка загрузки меню обновлений', show_alert=True)
@@ -118,6 +122,9 @@ async def check_updates(callback: types.CallbackQuery, db_user: User, db: AsyncS
await callback.message.edit_text(message, reply_markup=keyboard, parse_mode='HTML')
except Exception as e:
if 'message is not modified' in str(e).lower():
logger.debug('📝 Сообщение не изменено в check_updates')
return
logger.error(f'Ошибка проверки обновлений: {e}')
await callback.message.edit_text(
f'❌ <b>ОШИБКА ПРОВЕРКИ ОБНОВЛЕНИЙ</b>\n\n'
@@ -142,7 +149,6 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
newer_releases = version_info['newer_releases']
has_updates = version_info['has_updates']
last_check = version_info['last_check']
version_info['repo_url']
current_info = '📦 <b>ТЕКУЩАЯ ВЕРСИЯ</b>\n\n'
@@ -198,6 +204,9 @@ async def show_version_info(callback: types.CallbackQuery, db_user: User, db: As
)
except Exception as e:
if 'message is not modified' in str(e).lower():
logger.debug('📝 Сообщение не изменено в show_version_info')
return
logger.error(f'Ошибка получения информации о версиях: {e}')
await callback.message.edit_text(
f'❌ <b>ОШИБКА ЗАГРУЗКИ</b>\n\n'
-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_'))
+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,
+34 -13
View File
@@ -931,14 +931,11 @@ async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FS
await callback.message.edit_text(
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
)
except TelegramBadRequest as e:
if 'message is not modified' not in str(e):
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
except Exception as e:
logger.error(f'Ошибка при показе сообщения об отклонении политики конфиденциальности: {e}')
try:
await callback.message.edit_text(
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
)
except:
pass
logger.warning(f'Ошибка при показе сообщения об отклонении политики: {e}')
logger.info(f'✅ Политика конфиденциальности обработана для пользователя {callback.from_user.id}')
@@ -1228,6 +1225,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:
@@ -1507,6 +1518,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:
@@ -1735,6 +1760,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)
@@ -1880,8 +1907,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)
@@ -1974,8 +1999,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)
@@ -2028,8 +2051,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')
+5 -4
View File
@@ -404,15 +404,16 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
status_text = '⌛ Истекла'
type_text = 'Платная подписка'
if subscription.traffic_limit_gb == 0:
traffic_limit = subscription.traffic_limit_gb or 0
if traffic_limit == 0:
if settings.is_traffic_fixed():
traffic_text = '∞ Безлимитный'
else:
traffic_text = '∞ Безлимитный'
elif settings.is_traffic_fixed():
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
traffic_text = f'{traffic_limit} ГБ'
else:
traffic_text = f'{subscription.traffic_limit_gb} ГБ'
traffic_text = f'{traffic_limit} ГБ'
subscription_cost = await get_subscription_cost(subscription, db)
@@ -444,7 +445,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
info_text += f'\n💰 <b>Стоимость подписки в месяц:</b> {texts.format_price(subscription_cost)}'
# Отображаем докупленный трафик
if subscription.traffic_limit_gb > 0: # Только для лимитированных тарифов
if (subscription.traffic_limit_gb or 0) > 0: # Только для лимитированных тарифов
from datetime import datetime
from sqlalchemy import select as sql_select
+27 -59
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,
@@ -1603,11 +1596,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 +1802,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(
@@ -2420,26 +2403,31 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
promo_offer_discount_percent = 0
# Валидация: проверяем что cached_total_price соответствует ожидаемой финальной цене
# Допускаем небольшое расхождение из-за округления (до 5%)
price_difference = abs(final_price - cached_total_price)
max_allowed_difference = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
if price_difference > max_allowed_difference:
# Слишком большое расхождение - блокируем покупку
logger.error(
f'Критическое расхождение цены для пользователя {db_user.telegram_id}: '
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
f'разница={price_difference / 100}₽ (>{max_allowed_difference / 100}₽). '
f'Покупка заблокирована.'
)
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
return
if price_difference > 100: # допуск 1₽
# Небольшое расхождение - логируем предупреждение но продолжаем
logger.warning(
f'Расхождение цены для пользователя {db_user.telegram_id}: '
# Блокируем только если цена ВЫРОСЛА (пользователь переплатит).
# Если цена снизилась (промо-скидка активировалась) — разрешаем покупку по новой цене.
price_difference = final_price - cached_total_price
if price_difference > 0:
max_allowed_increase = max(500, int(final_price * 0.05)) # 5% или минимум 5₽
if price_difference > max_allowed_increase:
logger.error(
f'Цена выросла для пользователя {db_user.telegram_id}: '
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽, '
f'разница=+{price_difference / 100}₽ (>{max_allowed_increase / 100}₽). '
f'Покупка заблокирована.'
)
await callback.answer('Цена изменилась. Пожалуйста, начните оформление заново.', show_alert=True)
return
if price_difference > 100: # допуск 1₽
logger.warning(
f'Небольшой рост цены для пользователя {db_user.telegram_id}: '
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
f'Используем пересчитанную цену.'
)
elif price_difference < -100: # цена снизилась более чем на 1₽
logger.info(
f'Цена снизилась для пользователя {db_user.telegram_id}: '
f'кэш={cached_total_price / 100}₽, пересчет={final_price / 100}₽. '
f'Используем пересчитанную цену.'
f'Применяем новую цену.'
)
# Используем пересчитанную цену
@@ -3060,13 +3048,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),
@@ -4113,11 +4095,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
@@ -4152,10 +4129,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)
@@ -4169,7 +4142,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
@@ -4289,7 +4261,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
@@ -4307,7 +4278,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,
@@ -4316,17 +4286,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,
)
# Проверяем баланс пользователя
+32 -83
View File
@@ -8,7 +8,6 @@ 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
@@ -96,21 +95,12 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
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),
)
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 символов. Попробуйте еще раз:')
@@ -123,21 +113,12 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
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),
)
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(
@@ -169,21 +150,12 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
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),
)
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 символов) или отправьте фото с подписью:'),
@@ -263,12 +235,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=text_msg, parse_mode=None
)
else:
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
else:
await message.answer(text_msg)
await state.clear()
@@ -286,21 +253,12 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
'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),
)
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)
return
@@ -356,22 +314,13 @@ 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:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=creation_text,
reply_markup=keyboard,
parse_mode='HTML',
)
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=creation_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
-5
View File
@@ -2620,11 +2620,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)}')
+4 -1
View File
@@ -6,7 +6,7 @@ from typing import Any
import redis.asyncio as aioredis
from aiogram import BaseMiddleware, Bot, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
@@ -197,6 +197,9 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.error(f'❌ Ошибка запроса к каналу {channel_id}: {e}')
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramNetworkError as e:
logger.warning(f'⚠️ Таймаут при проверке подписки на канал: {e}')
return await handler(event, data)
except Exception as e:
logger.error(f'❌ Неожиданная ошибка при проверке подписки: {e}')
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(
+271 -63
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 datetime, timedelta
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
@@ -24,14 +27,41 @@ from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
BroadcastHistory,
ButtonClickLog,
CloudPaymentsPayment,
ContestAttempt,
ContestRound,
ContestTemplate,
CryptoBotPayment,
DiscountOffer,
FaqPage,
FaqSetting,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MainMenuButton,
MenuLayoutHistory,
MonitoringLog,
MulenPayPayment,
Pal24Payment,
PaymentMethodConfig,
PinnedMessage,
PlategaPayment,
Poll,
PollAnswer,
PollOption,
PollQuestion,
PollResponse,
PrivacyPolicy,
PromoCode,
PromoCodeUse,
PromoGroup,
PromoOfferLog,
PromoOfferTemplate,
PublicOffer,
ReferralContest,
ReferralContestEvent,
ReferralContestVirtualParticipant,
ReferralEarning,
SentNotification,
ServerSquad,
@@ -39,19 +69,33 @@ from app.database.models import (
Squad,
Subscription,
SubscriptionConversion,
SubscriptionEvent,
SubscriptionServer,
SubscriptionTemporaryAccess,
SupportAuditLog,
SystemSetting,
Tariff,
Ticket,
TicketMessage,
TicketNotification,
TrafficPurchase,
Transaction,
User,
UserMessage,
UserPromoGroup,
WataPayment,
WebApiToken,
Webhook,
WebhookDelivery,
WelcomeText,
WheelConfig,
WheelPrize,
WheelSpin,
WithdrawalRequest,
YooKassaPayment,
payment_method_promo_groups,
server_squad_promo_groups,
tariff_promo_groups,
)
@@ -122,6 +166,53 @@ class BackupService:
TicketMessage,
SupportAuditLog,
WebApiToken,
# --- Payment providers (FK: users, transactions) ---
HeleketPayment,
WataPayment,
PlategaPayment,
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
# --- Settings/content ---
PaymentMethodConfig,
PrivacyPolicy,
PublicOffer,
FaqSetting,
FaqPage,
PinnedMessage,
MainMenuButton,
MenuLayoutHistory,
# --- User data (FK: users, promo_groups, subscriptions) ---
UserPromoGroup,
TrafficPurchase,
SubscriptionEvent,
SubscriptionTemporaryAccess,
PromoOfferTemplate,
PromoOfferLog,
# --- Referral/contests (FK: users) ---
WithdrawalRequest,
ReferralContest,
ReferralContestEvent,
ReferralContestVirtualParticipant,
ContestTemplate,
ContestRound,
ContestAttempt,
# --- Polls (FK chain: polls -> questions -> options -> answers) ---
Poll,
PollQuestion,
PollOption,
PollResponse,
PollAnswer,
# --- Webhooks ---
Webhook,
WebhookDelivery,
# --- Wheel (FK chain: configs -> prizes -> spins) ---
WheelConfig,
WheelPrize,
WheelSpin,
# --- Support ---
TicketNotification,
ButtonClickLog,
]
self.backup_models_ordered = self._base_backup_models.copy()
@@ -131,6 +222,8 @@ class BackupService:
self.association_tables = {
'server_squad_promo_groups': server_squad_promo_groups,
'tariff_promo_groups': tariff_promo_groups,
'payment_method_promo_groups': payment_method_promo_groups,
}
def _load_settings(self) -> BackupSettings:
@@ -509,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:
@@ -538,7 +638,7 @@ class BackupService:
except Exception as exc:
logger.error('Ошибка при экспорте данных: %s', exc)
raise exc
raise
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
files_info: list[dict[str, Any]] = []
@@ -623,7 +723,7 @@ class BackupService:
mode = 'r:gz' if backup_path.suffixes and backup_path.suffixes[-1] == '.gz' else 'r'
with tarfile.open(backup_path, mode) as tar:
tar.extractall(temp_path)
tar.extractall(temp_path, filter='data')
metadata_path = temp_path / 'metadata.json'
if not metadata_path.exists():
@@ -785,20 +885,31 @@ class BackupService:
logger.info('📁 Снимок директории data восстановлен')
async def _restore_files(self, files_info: list[dict[str, Any]], temp_path: Path):
allowed_base = self.data_dir.resolve()
for file_info in files_info:
relative_path = file_info.get('relative_path')
target_path = Path(file_info.get('path', ''))
if not relative_path or not target_path:
continue
source_file = temp_path / relative_path
target_resolved = target_path.resolve()
if not str(target_resolved).startswith(str(allowed_base) + os.sep) and target_resolved != allowed_base:
logger.warning('Заблокирована запись за пределами data_dir: %s', target_path)
continue
source_file = (temp_path / relative_path).resolve()
if not str(source_file).startswith(str(temp_path.resolve()) + os.sep):
logger.warning('Path traversal в relative_path: %s', relative_path)
continue
if not source_file.exists():
logger.warning('Файл %s отсутствует в архиве', relative_path)
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(shutil.copy2, source_file, target_path)
logger.info('📁 Файл %s восстановлен', target_path)
target_resolved.parent.mkdir(parents=True, exist_ok=True)
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
logger.info('📁 Файл %s восстановлен', target_resolved)
async def _restore_database_payload(
self,
@@ -914,7 +1025,7 @@ class BackupService:
except Exception as exc:
await db.rollback()
logger.error('Ошибка при восстановлении: %s', exc)
raise exc
raise
return restored_tables, restored_records
@@ -987,17 +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}')
await db.rollback()
raise e
raise
await db.commit()
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):
@@ -1031,7 +1164,7 @@ class BackupService:
logger.error(f'Ошибка при обновлении реферальной связи: {e}')
continue
await db.commit()
await db.flush()
logger.info('✅ Реферальные связи обновлены')
def _process_record_data(self, record_data: dict, model, table_name: str) -> dict:
@@ -1058,6 +1191,18 @@ class BackupService:
except (ValueError, TypeError) as e:
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
processed_data[key] = datetime.utcnow()
elif column_type_str == 'TIME' and isinstance(value, str):
try:
processed_data[key] = dt_time.fromisoformat(value)
except (ValueError, TypeError) as e:
logger.warning(f'Не удалось парсить время {value} для поля {key}: {e}')
processed_data[key] = dt_time(hour=12, minute=0)
elif column_type_str == 'DATE' and isinstance(value, str):
try:
processed_data[key] = dt_date.fromisoformat(value)
except (ValueError, TypeError) as e:
logger.warning(f'Не удалось парсить дату {value} для поля {key}: {e}')
processed_data[key] = None
elif ('BOOLEAN' in column_type_str or 'BOOL' in column_type_str) and isinstance(value, str):
processed_data[key] = value.lower() in ('true', '1', 'yes', 'on')
elif (
@@ -1089,11 +1234,8 @@ class BackupService:
return processed_data
def _get_primary_key_column(self, model) -> str | None:
for col in model.__table__.columns:
if col.primary_key:
return col.name
return None
def _get_primary_key_columns(self, model) -> list[str]:
return [col.name for col in model.__table__.columns if col.primary_key]
async def _export_association_tables(self, db: AsyncSession) -> dict[str, list[dict[str, Any]]]:
association_data: dict[str, list[dict[str, Any]]] = {}
@@ -1119,63 +1261,65 @@ class BackupService:
restored_tables = 0
restored_records = 0
if 'server_squad_promo_groups' in association_data:
restored = await self._restore_server_squad_promo_groups(
db, association_data['server_squad_promo_groups'], clear_existing
for table_name, table_obj in self.association_tables.items():
if table_name not in association_data:
continue
col_names = [col.name for col in table_obj.columns]
restored = await self._restore_association_table(
db, table_obj, table_name, association_data[table_name], clear_existing, col_names
)
restored_tables += 1
restored_records += restored
return restored_tables, restored_records
async def _restore_server_squad_promo_groups(
self, db: AsyncSession, records: list[dict[str, Any]], clear_existing: bool
async def _restore_association_table(
self,
db: AsyncSession,
table_obj,
table_name: str,
records: list[dict[str, Any]],
clear_existing: bool,
col_names: list[str],
) -> int:
if not records:
return 0
if clear_existing:
await db.execute(server_squad_promo_groups.delete())
await db.execute(table_obj.delete())
restored = 0
for record in records:
server_id = record.get('server_squad_id')
promo_id = record.get('promo_group_id')
values = {col: record.get(col) for col in col_names}
if server_id is None or promo_id is None:
logger.warning('Пропущена некорректная запись server_squad_promo_groups: %s', record)
if any(v is None for v in values.values()):
logger.warning('Пропущена некорректная запись %s: %s', table_name, record)
continue
try:
first_col = col_names[0]
exists_stmt = (
select(server_squad_promo_groups.c.server_squad_id)
.where(
server_squad_promo_groups.c.server_squad_id == server_id,
server_squad_promo_groups.c.promo_group_id == promo_id,
)
select(table_obj.c[first_col])
.where(*[table_obj.c[col] == values[col] for col in col_names])
.limit(1)
)
existing = await db.execute(exists_stmt)
if existing.scalar_one_or_none() is not None:
logger.debug(
'Запись server_squad_promo_groups (%s, %s) уже существует',
server_id,
promo_id,
)
logger.debug('Запись %s %s уже существует', table_name, values)
continue
await db.execute(
server_squad_promo_groups.insert().values(server_squad_id=server_id, promo_group_id=promo_id)
)
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(
'Ошибка при восстановлении связи server_squad_promo_groups (%s, %s): %s', server_id, promo_id, e
)
await db.rollback()
raise e
logger.error('Ошибка при восстановлении связи %s %s: %s', table_name, values, e)
raise
return restored
@@ -1205,21 +1349,31 @@ class BackupService:
logger.warning(f'⚠️ Тариф {tariff_id} не найден, устанавливаем tariff_id=NULL для подписки')
processed_data['tariff_id'] = None
primary_key_col = self._get_primary_key_column(model)
pk_cols = self._get_primary_key_columns(model)
if primary_key_col and primary_key_col in processed_data:
existing_record = await db.execute(
select(model).where(getattr(model, primary_key_col) == processed_data[primary_key_col])
)
if pk_cols and all(col in processed_data for col in pk_cols):
where_clause = [getattr(model, col) == processed_data[col] for col in pk_cols]
existing_record = await db.execute(select(model).where(*where_clause))
existing = existing_record.scalar_one_or_none()
if existing and not clear_existing:
if existing:
for key, value in processed_data.items():
if key != primary_key_col:
if key not in pk_cols:
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)
@@ -1229,17 +1383,69 @@ class BackupService:
except Exception as e:
logger.error(f'Ошибка восстановления записи в {table_name}: {e}')
logger.error(f'Проблемные данные: {record_data}')
await db.rollback()
raise e
raise
return restored_count
async def _clear_database_tables(self, db: AsyncSession, backup_data: dict[str, Any] | None = None):
tables_order = [
# --- Association tables (no FK deps on them, safe to delete first) ---
'server_squad_promo_groups',
'tariff_promo_groups',
'payment_method_promo_groups',
# --- Polls (child -> parent order) ---
'poll_answers',
'poll_responses',
'poll_options',
'poll_questions',
'polls',
# --- Wheel (child -> parent) ---
'wheel_spins',
'wheel_prizes',
'wheel_configs',
# --- Contests (child -> parent) ---
'contest_attempts',
'contest_rounds',
'contest_templates',
'referral_contest_virtual_participants',
'referral_contest_events',
'referral_contests',
# --- Webhooks ---
'webhook_deliveries',
'webhooks',
# --- Promo offers ---
'promo_offer_logs',
'promo_offer_templates',
'subscription_temporary_access',
# --- User engagement ---
'subscription_events',
'traffic_purchases',
'user_promo_groups',
'withdrawal_requests',
# --- Support extras ---
'ticket_notifications',
'button_click_logs',
# --- Payment providers ---
'heleket_payments',
'wata_payments',
'platega_payments',
'cloudpayments_payments',
'freekassa_payments',
'kassa_ai_payments',
# --- Content/config ---
'pinned_messages',
'main_menu_buttons',
'menu_layout_history',
'faq_pages',
'faq_settings',
'privacy_policies',
'public_offers',
'payment_method_configs',
# --- Original tables (preserved order) ---
'support_audit_logs',
'ticket_messages',
'tickets',
'support_audit_logs',
'cabinet_refresh_tokens',
'advertising_campaign_registrations',
'advertising_campaigns',
'subscription_servers',
@@ -1408,9 +1614,11 @@ class BackupService:
async def delete_backup(self, backup_filename: str) -> tuple[bool, str]:
try:
backup_path = self.backup_dir / backup_filename
backup_path = (self.backup_dir / backup_filename).resolve()
if not str(backup_path).startswith(str(self.backup_dir.resolve()) + os.sep):
return False, '❌ Недопустимое имя файла бекапа'
if not backup_path.exists():
if not backup_path.is_file():
return False, f'❌ Файл бекапа не найден: {backup_filename}'
backup_path.unlink()
-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
+55 -1
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from typing import Any
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -1286,6 +1286,13 @@ class MonitoringService:
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',
@@ -1335,6 +1342,13 @@ class MonitoringService:
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',
@@ -1410,6 +1424,13 @@ class MonitoringService:
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',
@@ -1473,6 +1494,13 @@ class MonitoringService:
exc,
)
return False
except TelegramNetworkError as error:
logger.warning(
'Таймаут отправки уведомления об отписке от канала пользователю %s: %s',
user.telegram_id,
error,
)
return False
except Exception as error:
logger.error(
'Ошибка отправки уведомления об отписке от канала пользователю %s: %s',
@@ -1537,6 +1565,13 @@ class MonitoringService:
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',
@@ -1629,6 +1664,13 @@ class MonitoringService:
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',
@@ -1653,6 +1695,12 @@ class MonitoringService:
user.telegram_id,
exc,
)
except TelegramNetworkError as e:
logger.warning(
'Таймаут отправки уведомления об автоплатеже пользователю %s: %s',
user.telegram_id,
e,
)
except Exception as e:
logger.error(
'Ошибка отправки уведомления об автоплатеже пользователю %s: %s',
@@ -1690,6 +1738,12 @@ class MonitoringService:
user.telegram_id,
exc,
)
except TelegramNetworkError as e:
logger.warning(
'Таймаут отправки уведомления о неудачном автоплатеже пользователю %s: %s',
user.telegram_id,
e,
)
except Exception as e:
logger.error(
'Ошибка отправки уведомления о неудачном автоплатеже пользователю %s: %s',
+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
+17 -15
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()
@@ -1335,9 +1328,18 @@ class RemnaWaveService:
pending_uuid_mutations.clear()
try:
await db.rollback() # Выполняем rollback при ошибке
except:
except Exception:
pass
continue
# After rollback all ORM objects in the session are expired.
# Accessing their attributes triggers a lazy load which fails
# in async context (greenlet_spawn error). Break the loop to
# prevent cascading failures for every remaining user.
logger.warning(
'⚠️ Сессия повреждена после rollback, прерываем обработку (обработано %d/%d пользователей)',
i + 1,
len(unique_panel_users),
)
break
else:
if uuid_mutation and uuid_mutation.has_changes():
+101 -10
View File
@@ -16,17 +16,22 @@ 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,
expire_subscription,
get_subscription_by_user_id,
reactivate_subscription,
update_subscription_usage,
)
from app.database.crud.user import get_user_by_remnawave_uuid, get_user_by_telegram_id
from app.database.models import Subscription, SubscriptionStatus, User
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
from app.services.notification_delivery_service import NotificationType, notification_delivery_service
@@ -56,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': '🟢 Нода создана',
@@ -164,11 +189,23 @@ class RemnaWaveWebhookService:
)
return False
user_id = user.id
try:
await handler(db, user, subscription, data)
return True
except (StaleDataError, PendingRollbackError):
logger.warning(
'RemnaWave webhook %s: entity already deleted for user %s (concurrent deletion)',
event_name,
user_id,
)
try:
await db.rollback()
except Exception:
pass
return True
except Exception:
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user.id)
logger.exception('Error processing RemnaWave webhook event %s for user %s', event_name, user_id)
try:
await db.rollback()
except Exception:
@@ -338,7 +375,7 @@ 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=buy_text, callback_data='buy_traffic')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='subscription')],
]
)
@@ -356,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:
@@ -559,22 +608,64 @@ 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:
await expire_subscription(db, subscription)
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,
)
else:
await db.commit()
# Clear subscription data — panel user no longer exists
subscription.subscription_url = None
subscription.subscription_crypto_link = None
subscription.remnawave_short_uuid = None
subscription.connected_squads = None
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
# Remove SubscriptionServer link rows
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub_id))
# Clear remnawave linkage
if user.remnawave_uuid:
user.remnawave_uuid = None
await db.commit()
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
+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,
+66
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': 'Настройки фильтров отображаемых имен и защиты от фишинга.',
@@ -356,6 +358,7 @@ class BotConfigurationService:
'MAINTENANCE_': 'MAINTENANCE',
'VERSION_CHECK': 'VERSION',
'BACKUP_': 'BACKUP',
'WEBHOOK_NOTIFY_': 'WEBHOOK_NOTIFICATIONS',
'WEBHOOK_': 'WEBHOOK',
'LOG_': 'LOG',
'WEB_API_': 'WEB_API',
@@ -809,6 +812,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:
-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
+9 -13
View File
@@ -4,32 +4,28 @@ from aiogram.types import InlineKeyboardButton
from app.config import settings
DEFAULT_UNAVAILABLE_CALLBACK = 'menu_profile_unavailable'
def build_miniapp_or_callback_button(
text: str,
*,
callback_data: str,
unavailable_callback: str = DEFAULT_UNAVAILABLE_CALLBACK,
) -> InlineKeyboardButton:
"""Create a button that opens the miniapp in text menu mode.
"""Create a button that opens the miniapp or falls back to a callback.
When the simplified text menu mode is enabled we should avoid exposing
deep bot flows and redirect the user to the configured miniapp instead.
If the miniapp URL is missing we fall back to a safe callback that shows
an alert about the unavailable profile rather than opening disabled
sections of the bot.
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.
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.get_main_menu_miniapp_url()
miniapp_url = (settings.MINIAPP_CUSTOM_URL or '').strip()
if miniapp_url:
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=miniapp_url),
)
safe_callback = unavailable_callback or DEFAULT_UNAVAILABLE_CALLBACK
return InlineKeyboardButton(text=text, callback_data=safe_callback)
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),
+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}>'
+11 -4
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,7 +5925,6 @@ async def update_subscription_servers_endpoint(
if added_server_ids:
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
await add_user_to_servers(db, added_server_ids)
removed_server_ids = [
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
@@ -5934,7 +5932,16 @@ async def update_subscription_servers_endpoint(
if removed_server_ids:
await remove_subscription_servers(db, subscription.id, removed_server_ids)
await remove_user_from_servers(db, removed_server_ids)
if added_server_ids or removed_server_ids:
try:
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('Ошибка обновления счётчика серверов: %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
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.10.0"
version = "3.11.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
-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