Compare commits

...

67 Commits

Author SHA1 Message Date
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
Egor 2ad26a9156 Merge pull request #2588 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.10.0
2026-02-10 07:59:27 +03:00
github-actions[bot] 3383b9c790 chore(main): release 3.10.0 2026-02-10 04:58:24 +00:00
Egor 6acaf18203 Merge pull request #2587 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-10 07:57:54 +03:00
Fringg 019fbc12b6 fix: webhook:close button not working due to channel check timeout
Channel checker middleware called bot.get_chat_member() which could
timeout (60s), causing callback.answer() to fail with "query too old".

Skip channel check for lightweight UI callbacks (webhook:close,
ban_notify:delete, noop). Also answer callback before delete attempt
and add fallback to remove keyboard if delete fails.
2026-02-10 07:54:24 +03:00
Fringg 5156d635f0 fix: sync subscription status from panel in user.modified webhook
When subscription was extended in panel, webhook updated end_date but
left status as expired. Now syncs ACTIVE/DISABLED status from panel
payload when end_date is in the future.
2026-02-10 07:49:10 +03:00
Fringg f77922522a fix: allow non-HTTP deep links in crypto link webhook updates
_is_valid_url only accepted http(s), silently dropping valid deep links
like happ://, vless://, ss:// from revoked webhook payloads.
Added _is_valid_link that accepts any URI scheme.
2026-02-10 07:41:32 +03:00
Fringg 0db00a8f90 style: format cryptobot.py with ruff 2026-02-10 07:33:42 +03:00
Fringg fe54640885 fix: add missing placeholders to Arabic SUBSCRIPTION_INFO template
Was just a header text without {status}, {type}, etc. placeholders,
causing KeyError when .format() was called.
2026-02-10 07:30:52 +03:00
Fringg ec8eaf52bf fix: downgrade transient API errors (502/503/504) to warning level
502/503/504 are transient errors that don't need ERROR reports in chat.
Also downgrade API connection test failure to warning.
2026-02-10 07:27:20 +03:00
Fringg fe5f5ded96 feat: add MULENPAY_WEBSITE_URL setting for post-payment redirect
Previously website_url was hardcoded to WEBHOOK_URL, redirecting users
to the webhook endpoint after payment. Now configurable via env var.
2026-02-10 07:25:58 +03:00
Fringg 2cb6d731e9 fix: stop CryptoBot webhook retry loop and save cabinet payments to DB
Cabinet was calling CryptoBotService.create_invoice() directly without
saving CryptoBotPayment to DB. When webhook arrived, payment lookup
failed and returned HTTP 400, causing infinite retries.

Now cabinet uses PaymentService.create_cryptobot_payment() (same as
miniapp) with proper USD conversion via currency_converter.

Also return HTTP 200 for unknown invoice_ids to stop retry spam.
2026-02-10 07:25:54 +03:00
Fringg 184c52d4ea feat: webhook protection — prevent sync/monitoring from overwriting webhook data
Add last_webhook_update_at timestamp to Subscription model. When a webhook
handler modifies a subscription, it stamps this field. Auto-sync, monitoring,
and force-check services skip subscriptions updated by webhook within the
last 60 seconds, preventing stale panel data from overwriting fresh
real-time changes.

- Add last_webhook_update_at column + migration
- Stamp all 8 webhook handlers with commit in every code path
- Add is_recently_updated_by_webhook() guard in 12 sync/monitoring paths
- Add REMNAWAVE_WEBHOOK_* variables to .env.example
- Add webhook setup documentation to README with Caddy/nginx examples
- Fix pre-existing yookassa webhook test (mock AsyncSessionLocal)
2026-02-10 07:16:22 +03:00
Fringg 8e85e244cb feat: handle errors.bandwidth_usage_threshold_reached_max_notifications webhook
Last remaining unhandled RemnaWave backend event — sends admin
notification when the bandwidth threshold notification limit is reached.
2026-02-10 06:34:20 +03:00
Fringg 43a326a98c feat: handle service.subpage_config_changed webhook event
Add admin notification when subscription page config is
created, updated or deleted in RemnaWave panel.
2026-02-10 06:32:52 +03:00
Fringg d9de15a5a0 feat: add close button to all webhook notifications
Add dismissible close button (✖️) to every webhook notification message.
Users can now close any webhook notification by tapping the button,
which deletes the message via webhook:close callback handler.
2026-02-10 06:22:17 +03:00
Fringg 17ce64037f fix: build composite device name from platform + hwid short suffix
Show "tag (platform)" when tag is set, "iOS (ab12cd34)" when only
platform and hwid available, or just platform as last resort.
2026-02-10 06:16:37 +03:00
Fringg 79793c47bb fix: extract device name from nested hwidUserDevice object
RemnaWave sends device info in data.hwidUserDevice, not top-level.
Try tag, deviceName, platform, hwid fields from nested object first.
2026-02-10 06:14:01 +03:00
Fringg 7091eb9c14 fix: add action buttons to webhook notifications and fix empty device names
- Add keyboard buttons to all webhook notifications: renew, connect,
  my subscription, buy traffic — context-appropriate per event type
- Extract device name from multiple possible payload fields (deviceName,
  tag, hwid, device, platform, name) with fallback to dash
- Log payload keys for device events to identify correct field names
2026-02-10 06:09:00 +03:00
Fringg dc1e96bbe9 fix: security and architecture fixes for webhook handlers
- Add html.escape() to all untrusted webhook data in admin and device
  notifications (prevents HTML/Telegram injection)
- Add public send_webhook_notification() and is_enabled property to
  AdminNotificationService (eliminates private method access)
- Add dedicated NotificationType enum values for device and not_connected
  events (fixes incorrect semantic mapping)
- Extend user resolution to handle nested user objects and userUuid for
  device-scope events
- Replace manual __anext__() DB session with AsyncSessionLocal context
  manager; skip DB session for admin-only events
- Replace deprecated datetime.utcnow() with datetime.now(UTC)
- Use db.flush() instead of db.commit() in handlers (router commits)
- Wrap _notify_user in try/except to prevent notification failures from
  rolling back successful DB mutations
2026-02-10 05:55:48 +03:00
Fringg 1e37fd9dd2 feat: add all remaining RemnaWave webhook events (node, service, crm, device)
Handle all 44 webhook events: admin alerts for node health (connection
lost/restored), service security (login attempts), CRM billing reminders,
plus user-facing device added/deleted and not_connected notifications
with localized messages across all 5 languages.
2026-02-10 05:47:35 +03:00
Fringg 9aa22af339 fix: use event field directly as event_name (already includes scope prefix)
RemnaWave sends event as "user.modified", not "modified".
Concatenating scope + event produced "user.user.modified" which
didn't match any handler keys.
2026-02-10 05:31:39 +03:00
Fringg 26637f0ae5 feat: unified notification delivery for webhook events (email + WS support)
- Replace direct bot.send_message with notification_delivery_service
- Email-only and OAuth users now receive webhook notifications via email/WS
- Add 10 new NotificationType enum values for webhook subscription events
- Map all webhook text_keys to NotificationType for unified routing
2026-02-10 05:16:59 +03:00
Fringg 6d67cad3e7 feat: add RemnaWave incoming webhooks for real-time subscription events
- Add FastAPI webhook endpoint with HMAC-SHA256 signature verification
- Handle 16 user events: expired, disabled, enabled, limited, traffic_reset,
  modified, deleted, revoked, created, expires_in_72h/48h/24h,
  expired_24h_ago, first_connected, bandwidth_threshold
- URL validation for subscription_url/subscription_crypto_link (XSS prevention)
- 64KB body size limit, 32-char minimum secret enforcement
- Sanitized percent value in bandwidth threshold notifications
- DB rollback on handler errors to prevent dirty session commits
- Localization for all 5 languages (ru, en, ua, zh, fa)
2026-02-10 05:13:39 +03:00
Fringg 90d9df8f0e fix: preserve payment initiation time in transaction created_at
Transaction created_at and completed_at showed identical timestamps
because webhook handlers created transactions with is_completed=True
in a single step. Now all 10 payment providers pass payment.created_at
to the transaction so created_at reflects when the user initiated
the payment, not when the webhook processed it.

Also: remove duplicate datetime import in inline.py, upgrade button
stats DB error logging from debug to warning, add index on
button_click_logs.button_type for analytics queries.
2026-02-10 04:26:23 +03:00
Egor ef654a09bb Merge pull request #2586 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.9.1
2026-02-10 04:05:23 +03:00
github-actions[bot] 74f3b388d8 chore(main): release 3.9.1 2026-02-10 01:04:57 +00:00
Egor 5af1f358b2 Merge pull request #2585 from BEDOLAGA-DEV/dev
Release: dev -> main
2026-02-10 04:04:30 +03:00
Fringg 994325360c fix: don't delete Heleket invoice message on status check
_process_heleket_payload deleted the invoice message on every call,
including manual "check status" presses. Now only deletes on final
statuses (paid, cancel, fail, etc.) so the payment UI stays visible
while the user is still waiting.

Also includes subscription fallback query fix (actual DB columns).
2026-02-10 03:50:21 +03:00
Fringg f0e7f8e3be fix: use actual DB columns for subscription fallback query
Subscription.is_active is a Python property, not a column — query
status/end_date/is_trial columns instead. Also restore subscription=None
initialization to avoid UnboundLocalError on line 112.
2026-02-10 03:44:34 +03:00
Fringg 40d8a6dc8b fix: safe HTML preview truncation and lazy-load subscription fallback
Rules editor crashed when preview truncated mid-HTML tag (e.g.
<blockquote> cut to <blockquo), causing Telegram parse error.
Strip HTML tags before truncating preview text.

Also fix MissingGreenlet in build_topup_success_keyboard: fall back
to a direct DB query instead of showing wrong button text.
2026-02-10 03:32:20 +03:00
72 changed files with 2258 additions and 1668 deletions
+12
View File
@@ -187,6 +187,16 @@ REMNAWAVE_AUTO_SYNC_ENABLED=false
# Времена синхронизации (через запятую, формат HH:MM по МСК)
REMNAWAVE_AUTO_SYNC_TIMES=03:00
# ===== REMNAWAVE WEBHOOKS (входящие события из панели) =====
# Включить приём вебхуков от панели Remnawave (real-time события)
REMNAWAVE_WEBHOOK_ENABLED=false
# Путь для приёма вебхуков (должен совпадать с настройкой в панели)
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
# Общий секрет для подписи HMAC-SHA256 (минимум 32 символа)
# Сгенерируйте: openssl rand -hex 32
# ВАЖНО: этот же секрет указывается в панели Remnawave при создании вебхука
REMNAWAVE_WEBHOOK_SECRET=
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
@@ -536,6 +546,8 @@ MULENPAY_MIN_AMOUNT_KOPEKS=10000
MULENPAY_MAX_AMOUNT_KOPEKS=10000000
# Ожидаемый origin для iframe (опционально, для безопасности)
# MULENPAY_IFRAME_EXPECTED_ORIGIN=https://mulenpay.ru
# URL для редиректа после оплаты (по умолчанию WEBHOOK_URL)
# MULENPAY_WEBSITE_URL=https://your-cabinet-url.com
# PAYPALYCH / PAL24
PAL24_ENABLED=false
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.9.0"
".": "3.10.2"
}
+82
View File
@@ -1,5 +1,87 @@
# Changelog
## [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)
### New Features
* add all remaining RemnaWave webhook events (node, service, crm, device) ([1e37fd9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e37fd9dd271814e644af591343cada6ab12d612))
* add close button to all webhook notifications ([d9de15a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d9de15a5a06aec3901415bdfd25b55d2ca01d28c))
* add MULENPAY_WEBSITE_URL setting for post-payment redirect ([fe5f5de](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe5f5ded965e36300e1c73f25f16de22f84651ad))
* add RemnaWave incoming webhooks for real-time subscription events ([6d67cad](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6d67cad3e7aa07b8490d88b73c38c4aca6b9e315))
* handle errors.bandwidth_usage_threshold_reached_max_notifications webhook ([8e85e24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8e85e244cb786fb4c06162f2b98d01202e893315))
* handle service.subpage_config_changed webhook event ([43a326a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43a326a98ccc3351de04d9b2d660d3e7e0cb0efc))
* unified notification delivery for webhook events (email + WS support) ([26637f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/26637f0ae5c7264c0430487d942744fd034e78e8))
* webhook protection — prevent sync/monitoring from overwriting webhook data ([184c52d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/184c52d4ea3ce02d40cf8a5ab42be855c7c7ae23))
### Bug Fixes
* add action buttons to webhook notifications and fix empty device names ([7091eb9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7091eb9c148aaf913c4699fc86fef5b548002668))
* add missing placeholders to Arabic SUBSCRIPTION_INFO template ([fe54640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fe546408857128649930de9473c7cde1f7cc450a))
* allow non-HTTP deep links in crypto link webhook updates ([f779225](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f77922522a85b3017be44b5fc71da9c95ec16379))
* build composite device name from platform + hwid short suffix ([17ce640](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17ce64037f198837c8f2aa7bf863871f60bdf547))
* downgrade transient API errors (502/503/504) to warning level ([ec8eaf5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ec8eaf52bfdc2bde612e4fc0324575ba7dc6b2e1))
* extract device name from nested hwidUserDevice object ([79793c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79793c47bbbdae8b0f285448d5f70e90c9d4f4b0))
* preserve payment initiation time in transaction created_at ([90d9df8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/90d9df8f0e949913f09c4ebed8fe5280453ab3ab))
* security and architecture fixes for webhook handlers ([dc1e96b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/dc1e96bbe9b4496e91e9dea591c7fc0ef4cc245b))
* stop CryptoBot webhook retry loop and save cabinet payments to DB ([2cb6d73](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2cb6d731e96cbfc305b098d8424b84bfd6826fb4))
* sync subscription status from panel in user.modified webhook ([5156d63](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5156d635f0b5bc0493e8f18ce9710cca6ff4ffc8))
* use event field directly as event_name (already includes scope prefix) ([9aa22af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9aa22af3390a249d1b500d75a7d7189daaed265e))
* webhook:close button not working due to channel check timeout ([019fbc1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/019fbc12b6cf61d374bbed4bce3823afc60445c9))
## [3.9.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.9.0...v3.9.1) (2026-02-10)
### Bug Fixes
* don't delete Heleket invoice message on status check ([9943253](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/994325360ca7665800177bfad8f831154f4d733f))
* safe HTML preview truncation and lazy-load subscription fallback ([40d8a6d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/40d8a6dc8baf3f0f7c30b0883898b4655a907eb5))
* use actual DB columns for subscription fallback query ([f0e7f8e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f0e7f8e3bec27d97a3f22445948b8dde37a92438))
## [3.9.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.8.0...v3.9.0) (2026-02-09)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.9.0" # x-release-please-version
ARG VERSION="v3.10.2" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+118
View File
@@ -610,6 +610,16 @@ hooks.domain.com {
}
}
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
# app-config.json с CORS
handle /app-config.json {
header Access-Control-Allow-Origin "*"
@@ -818,6 +828,18 @@ http {
proxy_request_buffering off;
}
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
# app-config.json с CORS
location = /app-config.json {
add_header Access-Control-Allow-Origin "*";
@@ -1055,6 +1077,102 @@ REMNAWAVE_SECRET_KEY=XXXXXXX:DDDDDDDD
REMNAWAVE_SECRET_KEY=secret_key_name
```
### 📡 Вебхуки Remnawave (real-time события)
Бот может принимать входящие вебхуки от панели Remnawave для мгновенной реакции на события подписок. Это значительно улучшает скорость обновления данных по сравнению с периодической синхронизацией.
#### Поддерживаемые события
| Событие | Описание |
|---------|----------|
| `user.expired` | Подписка истекла |
| `user.disabled` | Подписка деактивирована |
| `user.enabled` | Подписка активирована |
| `user.limited` | Превышен лимит трафика |
| `user.traffic_reset` | Трафик сброшен |
| `user.modified` | Данные подписки изменены (трафик, дата, URL) |
| `user.deleted` | Пользователь удалён |
| `user.revoked` | Ключи подписки отозваны |
| `user.created` | Пользователь создан |
| `user.expires_in_*` | Предупреждения об истечении (72ч, 48ч, 24ч) |
| `user.first_connected` | Первое подключение |
| `user.bandwidth_usage_threshold_reached` | Порог трафика достигнут |
| `user_hwid_devices.*` | Устройство добавлено/удалено |
| `node.*`, `service.*` | Административные события (ноды, сервис) |
#### Настройка
**1. Переменные окружения в `.env`:**
```env
REMNAWAVE_WEBHOOK_ENABLED=true
REMNAWAVE_WEBHOOK_PATH=/remnawave-webhook
REMNAWAVE_WEBHOOK_SECRET=your_secret_min_32_chars_here
```
Сгенерируйте секрет:
```bash
openssl rand -hex 32
```
**2. Настройка в панели Remnawave:**
В env панели Remnawave заполните:
- **URL**: `https://hooks.domain.com/remnawave-webhook`
- **Secret**: тот же секрет, что и в `REMNAWAVE_WEBHOOK_SECRET`
**3. Настройка прокси:**
Добавьте путь `/remnawave-webhook` в конфигурацию обратного прокси.
**Caddy:**
```caddy
handle /remnawave-webhook {
reverse_proxy remnawave_bot:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
transport http {
read_buffer 0
}
}
}
```
**Nginx:**
```nginx
location = /remnawave-webhook {
proxy_pass http://remnawave_bot_unified;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_request_buffering off;
}
```
**4. Проверка работоспособности:**
```bash
# Health-check (GET запрос)
curl -s https://hooks.domain.com/remnawave-webhook | jq
# Ожидаемый ответ:
# {"status": "ok", "service": "remnawave_webhook", "enabled": true}
```
**Важно:**
- Секрет должен быть не менее 32 символов
- Бот верифицирует подпись `X-Remnawave-Signature` (HMAC-SHA256) для каждого запроса
- При включённых вебхуках бот автоматически защищает подписки от перезаписи данными из периодической синхронизации в течение 60 секунд после получения события
- Если бот и панель на одном сервере, URL вебхука может быть `http://remnawave_bot:8080/remnawave-webhook` (внутри Docker-сети)
### 💳 Freekassa
Платёжный провайдер [Freekassa](https://freekassa.ru) поддерживает NSPK СБП и банковские карты.
-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())
+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()
+39 -13
View File
@@ -3,6 +3,7 @@
import logging
import math
import time
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, status
@@ -12,7 +13,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.models import PaymentMethod, Transaction, User
from app.external.cryptobot import CryptoBotService
from app.services.payment_method_config_service import get_enabled_methods_for_user
from app.services.payment_service import PaymentService
from app.services.payment_verification_service import (
@@ -23,6 +23,7 @@ from app.services.payment_verification_service import (
method_display_name,
run_manual_check,
)
from app.utils.currency_converter import currency_converter
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.balance import (
@@ -381,24 +382,49 @@ async def create_topup(
)
elif request.payment_method == 'cryptobot':
cryptobot_service = CryptoBotService()
# Convert RUB to USDT (approximate)
usdt_amount = amount_rubles / 100 # Approximate rate
result = await cryptobot_service.create_invoice(
amount=usdt_amount,
asset='USDT',
description=f'Balance top-up {amount_rubles:.2f} RUB',
if not settings.is_cryptobot_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='CryptoBot payment method is unavailable',
)
try:
rate = await currency_converter.get_usd_to_rub_rate()
except Exception:
rate = 0.0
if not rate or rate <= 0:
rate = 95.0
try:
amount_usd = float(
(Decimal(request.amount_kopeks) / Decimal(100) / Decimal(str(rate))).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
)
except (InvalidOperation, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Unable to convert amount to USD',
)
payment_service = PaymentService()
result = await payment_service.create_cryptobot_payment(
db=db,
user_id=user.id,
amount_usd=amount_usd,
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
description=settings.get_balance_payment_description(
request.amount_kopeks, telegram_user_id=user.telegram_id
),
payload=f'cabinet_topup_{user.id}_{request.amount_kopeks}',
)
if result:
# Priority: web_app for desktop/browser, mini_app for mobile, bot as fallback
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('pay_url')
or result.get('web_app_invoice_url')
)
payment_id = str(result.get('invoice_id'))
payment_id = result.get('invoice_id') or str(result.get('local_payment_id', 'pending'))
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+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
+14 -65
View File
@@ -105,6 +105,11 @@ class Settings(BaseSettings):
REMNAWAVE_AUTO_SYNC_TIMES: str = '03:00'
CABINET_REMNA_SUB_CONFIG: str | None = None # UUID конфига страницы подписки из RemnaWave
# RemnaWave incoming webhooks (real-time event delivery from backend)
REMNAWAVE_WEBHOOK_ENABLED: bool = False
REMNAWAVE_WEBHOOK_PATH: str = '/remnawave-webhook'
REMNAWAVE_WEBHOOK_SECRET: str | None = None # HMAC-SHA256 shared secret (min 32 chars)
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
@@ -162,11 +167,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 = ''
@@ -395,6 +395,7 @@ class Settings(BaseSettings):
MULENPAY_MIN_AMOUNT_KOPEKS: int = 10000
MULENPAY_MAX_AMOUNT_KOPEKS: int = 10000000
MULENPAY_IFRAME_EXPECTED_ORIGIN: str | None = None
MULENPAY_WEBSITE_URL: str | None = None
PAL24_ENABLED: bool = False
PAL24_DISPLAY_NAME: str = 'PAL24'
@@ -1051,7 +1052,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]:
@@ -1089,6 +1090,13 @@ class Settings(BaseSettings):
def get_remnawave_auto_sync_times(self) -> list[time]:
return self.parse_daily_time_list(self.REMNAWAVE_AUTO_SYNC_TIMES)
def is_remnawave_webhook_enabled(self) -> bool:
return (
self.REMNAWAVE_WEBHOOK_ENABLED
and bool(self.REMNAWAVE_WEBHOOK_SECRET)
and len(self.REMNAWAVE_WEBHOOK_SECRET or '') >= 32
)
def get_traffic_monitored_nodes(self) -> list[str]:
"""Возвращает список UUID нод для мониторинга (пусто = все)"""
if not self.TRAFFIC_MONITORED_NODES:
@@ -1516,9 +1524,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'
@@ -1535,62 +1540,6 @@ class Settings(BaseSettings):
"""Возвращает 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 - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
+30 -6
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}')
@@ -740,14 +766,13 @@ async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) ->
.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:
@@ -759,14 +784,13 @@ async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]
.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 get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
+49 -17
View File
@@ -1,11 +1,12 @@
import logging
from collections.abc import Iterable
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
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
@@ -23,6 +24,16 @@ from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
_WEBHOOK_GUARD_SECONDS = 60
def is_recently_updated_by_webhook(subscription: Subscription) -> bool:
"""Return True if subscription was updated by webhook within guard window."""
if not subscription.last_webhook_update_at:
return False
elapsed = (datetime.now(UTC).replace(tzinfo=None) - subscription.last_webhook_update_at).total_seconds()
return elapsed < _WEBHOOK_GUARD_SECONDS
async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscription | None:
result = await db.execute(
@@ -347,9 +358,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}')
@@ -430,17 +440,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
# В режиме тарифов сохраняем докупленный трафик при продлении
@@ -605,6 +626,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:
@@ -613,12 +637,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,
)
@@ -632,7 +656,7 @@ async def decrement_subscription_server_counts(
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
subscription.id,
sub_id,
error,
)
@@ -642,12 +666,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, sorted(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,
)
+2
View File
@@ -37,6 +37,7 @@ async def create_transaction(
payment_method: PaymentMethod | None = None,
external_id: str | None = None,
is_completed: bool = True,
created_at: datetime | None = None,
) -> Transaction:
transaction = Transaction(
user_id=user_id,
@@ -47,6 +48,7 @@ async def create_transaction(
external_id=external_id,
is_completed=is_completed,
completed_at=datetime.utcnow() if is_completed else None,
**({'created_at': created_at} if created_at else {}),
)
db.add(transaction)
+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:
+21 -10
View File
@@ -1137,6 +1137,8 @@ class Subscription(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_webhook_update_at = Column(DateTime, nullable=True)
remnawave_short_uuid = Column(String(255), nullable=True)
# Тариф (для режима продаж "Тарифы")
@@ -1157,17 +1159,25 @@ class Subscription(Base):
@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:
@@ -1180,12 +1190,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'
@@ -1228,6 +1238,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
@@ -1253,11 +1265,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():
@@ -2398,7 +2409,7 @@ class ButtonClickLog(Base):
clicked_at = Column(DateTime, default=func.now(), index=True)
# Дополнительная информация
button_type = Column(String(20), nullable=True) # builtin, callback, url, mini_app
button_type = Column(String(20), nullable=True, index=True) # builtin, callback, url, mini_app
button_text = Column(String(255), nullable=True) # Текст кнопки на момент клика
__table_args__ = (
+34
View File
@@ -3613,6 +3613,33 @@ async def add_subscription_crypto_link_column() -> bool:
return False
async def add_subscription_last_webhook_update_column() -> bool:
column_exists = await check_column_exists('subscriptions', 'last_webhook_update_at')
if column_exists:
logger.info('️ Колонка last_webhook_update_at уже существует')
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
elif db_type == 'postgresql':
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at TIMESTAMP'))
elif db_type == 'mysql':
await conn.execute(text('ALTER TABLE subscriptions ADD COLUMN last_webhook_update_at DATETIME'))
else:
logger.error(f'Неподдерживаемый тип БД для добавления last_webhook_update_at: {db_type}')
return False
logger.info('✅ Добавлена колонка last_webhook_update_at в таблицу subscriptions')
return True
except Exception as e:
logger.error(f'Ошибка добавления колонки last_webhook_update_at: {e}')
return False
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
@@ -7104,6 +7131,13 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с колонками OAuth провайдеров')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ LAST_WEBHOOK_UPDATE_AT ===')
webhook_column_ready = await add_subscription_last_webhook_update_column()
if webhook_column_ready:
logger.info('✅ Колонка last_webhook_update_at готова')
else:
logger.warning('⚠️ Проблемы с колонкой last_webhook_update_at')
async with engine.begin() as conn:
total_subs = await conn.execute(text('SELECT COUNT(*) FROM subscriptions'))
unique_users = await conn.execute(text('SELECT COUNT(DISTINCT user_id) FROM subscriptions'))
+4 -3
View File
@@ -400,8 +400,9 @@ class RemnaWaveAPI:
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
logger.error(f'API Error {response.status}: {error_message}')
logger.error(f'Response: {response_text[:500]}')
log = logger.warning if response.status in (502, 503, 504) else logger.error
log('API Error %s: %s', response.status, error_message)
log('Response: %s', response_text[:500])
raise RemnaWaveAPIError(error_message, response.status, response_data)
return response_data
@@ -1284,5 +1285,5 @@ async def test_api_connection(api: RemnaWaveAPI) -> bool:
await api.get_system_stats()
return True
except Exception as e:
logger.error(f'API connection test failed: {e}')
logger.warning('API connection test failed: %s', e)
return False
+11 -2
View File
@@ -1,4 +1,5 @@
import logging
import re
from aiogram import Dispatcher, F, types
from aiogram.fsm.context import FSMContext
@@ -11,6 +12,14 @@ from app.utils.decorators import admin_required, error_handler
from app.utils.validators import get_html_help_text, validate_html_tags
def _safe_preview(html_text: str, limit: int = 500) -> str:
"""Создаёт превью текста, безопасно обрезая HTML-теги."""
plain = re.sub(r'<[^>]+>', '', html_text)
if len(plain) <= limit:
return plain
return plain[:limit] + '...'
logger = logging.getLogger(__name__)
@@ -79,7 +88,7 @@ async def start_edit_rules(callback: types.CallbackQuery, db_user: User, state:
try:
current_rules = await get_current_rules_content(db, db_user.language)
preview = current_rules[:500] + ('...' if len(current_rules) > 500 else '')
preview = _safe_preview(current_rules, 500)
text = (
'✏️ <b>Редактирование правил</b>\n\n'
@@ -139,7 +148,7 @@ async def process_rules_edit(message: types.Message, db_user: User, state: FSMCo
if len(preview_text) > 4000:
preview_text = (
'📋 <b>Предварительный просмотр новых правил:</b>\n\n'
f'{new_rules[:500]}...\n\n'
f'{_safe_preview(new_rules, 500)}\n\n'
f'⚠️ <b>Внимание!</b> Новые правила будут показываться всем пользователям.\n\n'
f'Текст правил: {len(new_rules)} символов\n'
f'Сохранить изменения?'
+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_'))
+20 -1
View File
@@ -25,6 +25,24 @@ async def handle_delete_ban_notification(
await callback.answer('Не удалось удалить', show_alert=False)
async def handle_webhook_notification_close(
callback: types.CallbackQuery,
):
"""Удаляет webhook-уведомление при нажатии кнопки Закрыть."""
try:
await callback.answer()
except Exception:
pass
try:
await callback.message.delete()
except Exception as e:
logger.warning('Не удалось удалить webhook-уведомление: %s', e)
try:
await callback.message.edit_reply_markup(reply_markup=None)
except Exception:
pass
async def handle_unknown_callback(callback: types.CallbackQuery, db_user: User):
texts = get_texts(db_user.language if db_user else 'ru')
@@ -86,8 +104,9 @@ async def show_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSess
def register_handlers(dp: Dispatcher):
# Удаление уведомлений о банах
# Удаление уведомлений
dp.callback_query.register(handle_delete_ban_notification, F.data == 'ban_notify:delete')
dp.callback_query.register(handle_webhook_notification_close, F.data == 'webhook:close')
dp.callback_query.register(show_rules, F.data == 'menu_rules')
+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,
)
# Проверяем баланс пользователя
-7
View File
@@ -47,8 +47,6 @@ async def get_main_menu_keyboard_async(
Иначе делегирует в синхронную версию.
"""
if settings.MENU_LAYOUT_ENABLED:
from datetime import datetime
from app.services.menu_layout_service import MenuContext, MenuLayoutService
# Получаем данные для плейсхолдеров
@@ -2622,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(
+20 -1
View File
@@ -1698,5 +1698,24 @@
"RESUME_DAILY_BUTTON": "▶️ Resume subscription",
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!"
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Subscription activated</b>\n\nYour subscription is active again. Enjoy!",
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Traffic limit reached</b>\n\nYou have used all available traffic. Purchase additional traffic or wait for a reset.",
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Traffic reset</b>\n\nYour traffic has been reset. Full limit is available again.",
"WEBHOOK_SUB_DELETED": "🗑️ <b>Subscription deleted</b>\n\nYour subscription was deleted from the panel.",
"WEBHOOK_SUB_REVOKED": "🔑 <b>Subscription key updated</b>\n\nYour connection credentials have been updated. Use the new link to connect.",
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Subscription expires in 3 days</b>\n\nDon't forget to renew your subscription to keep VPN access.",
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Subscription expires in 2 days</b>\n\nRenew your subscription in advance to maintain VPN access.",
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Subscription expires tomorrow!</b>\n\nLess than 24 hours left. Renew your subscription now.",
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Subscription expired yesterday</b>\n\nYour subscription has ended. Renew now to restore VPN access.",
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>First connection!</b>\n\nYou connected to the VPN for the first time. Welcome!",
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>{percent}% traffic used</b>\n\nYou have used a significant portion of your traffic. Monitor your usage.",
"WEBHOOK_RENEW_BUTTON": "🔄 Renew subscription",
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Not connected yet</b>\n\nYour subscription is active but no VPN connection has been made. Connect to start using the service.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
}
+20 -2
View File
@@ -1436,7 +1436,7 @@
"SUBSCRIPTION_HAPP_OPEN_TITLE": "🔗 <b>اتصال از طریق Happ</b>",
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 دکمه زیر بزنید تا راهنمای تنظیم VPN روی دستگاه را ببینید",
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>لینک وارد کردن در اپلیکیشن VPN:</b>\n<code>{subscription_url}</code>",
"SUBSCRIPTION_INFO": "اطلاعات اشتراک",
"SUBSCRIPTION_INFO": "\n📱 <b>اطلاعات اشتراک</b>\n\n📊 <b>وضعیت:</b> {status}\n🎭 <b>نوع:</b> {type}\n📅 <b>اعتبار تا:</b> {end_date}\n⏰ <b>روزهای باقی‌مانده:</b> {days_left}\n\n📈 <b>ترافیک:</b> {traffic_used} / {traffic_limit}\n🌍 <b>سرورها:</b> {countries_count} کشور\n📱 <b>دستگاه‌ها:</b> {devices_used} / {devices_limit}\n\n💳 <b>پرداخت خودکار:</b> {autopay_status}\n",
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nلینک در حال ایجاد است. چند ثانیه بعد به «اشتراک من» بروید.",
"SUBSCRIPTION_LINK_HIDDEN_NOTICE": "ℹ️ لینک اشتراک از دکمه‌های زیر یا بخش «اشتراک من» در دسترس است.",
"SUBSCRIPTION_LINK_HINT": "💡 لینک را در اپلیکیشن VPN وارد کنید",
@@ -1717,5 +1717,23 @@
"RESUME_DAILY_BUTTON": "▶️ ازسرگیری اشتراک",
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست می‌روند!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!"
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
"WEBHOOK_SUB_LIMITED": "⚠️ <b>محدودیت ترافیک</b>\n\nشما تمام ترافیک موجود را مصرف کرده‌اید. ترافیک اضافی بخرید یا منتظر بازنشانی باشید.",
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>ترافیک بازنشانی شد</b>\n\nترافیک شما بازنشانی شد. محدودیت کامل دوباره در دسترس است.",
"WEBHOOK_SUB_DELETED": "🗑️ <b>اشتراک حذف شد</b>\n\nاشتراک شما از پنل حذف شده است.",
"WEBHOOK_SUB_REVOKED": "🔑 <b>کلید اشتراک به‌روز شد</b>\n\nاطلاعات اتصال شما به‌روز شده است. از لینک جدید برای اتصال استفاده کنید.",
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>اشتراک تا ۳ روز دیگر منقضی می‌شود</b>\n\nفراموش نکنید اشتراک خود را تمدید کنید.",
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>اشتراک تا ۲ روز دیگر منقضی می‌شود</b>\n\nاشتراک خود را زودتر تمدید کنید تا دسترسی VPN حفظ شود.",
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>اشتراک فردا منقضی می‌شود!</b>\n\nکمتر از ۲۴ ساعت باقی مانده. همین الان تمدید کنید.",
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>اشتراک دیروز منقضی شد</b>\n\nاشتراک شما به پایان رسیده. همین الان تمدید کنید تا دسترسی VPN بازگردد.",
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>اولین اتصال!</b>\n\nشما برای اولین بار به VPN متصل شدید. خوش آمدید!",
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>{percent}% ترافیک مصرف شد</b>\n\nبخش قابل توجهی از ترافیک خود را مصرف کرده‌اید. مصرف خود را زیر نظر داشته باشید.",
"WEBHOOK_RENEW_BUTTON": "🔄 تمدید اشتراک",
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>هنوز متصل نشده‌اید</b>\n\nاشتراک شما فعال است اما هنوز اتصال VPN برقرار نشده. برای شروع استفاده متصل شوید.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>دستگاه جدید</b>\n\nدستگاه جدیدی به اشتراک شما اضافه شد: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>دستگاه حذف شد</b>\n\nدستگاهی از اشتراک شما حذف شد: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ بستن"
}
+20 -1
View File
@@ -1719,5 +1719,24 @@
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!"
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Достигнут лимит трафика</b>\n\nВы исчерпали весь доступный трафик. Докупите трафик или дождитесь сброса.",
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафик сброшен</b>\n\nВаш трафик обнулён. Лимит снова доступен в полном объёме.",
"WEBHOOK_SUB_DELETED": "🗑️ <b>Подписка удалена</b>\n\nВаша подписка была удалена из панели.",
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ подписки обновлён</b>\n\nВаши данные подключения были обновлены. Используйте новую ссылку для подключения.",
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Подписка истекает через 3 дня</b>\n\nНе забудьте продлить подписку, чтобы не потерять доступ.",
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Подписка истекает через 2 дня</b>\n\nПродлите подписку заранее, чтобы сохранить доступ к VPN.",
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Подписка истекает завтра!</b>\n\nОсталось менее 24 часов. Продлите подписку прямо сейчас.",
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Подписка истекла вчера</b>\n\nВаша подписка завершена. Продлите сейчас, чтобы вернуть доступ к VPN.",
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Первое подключение!</b>\n\nВы впервые подключились к VPN. Добро пожаловать!",
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Использован {percent}% трафика</b>\n\nВы использовали значительную часть трафика. Следите за расходом.",
"WEBHOOK_RENEW_BUTTON": "🔄 Продлить подписку",
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Вы ещё не подключились</b>\n\nВаша подписка активна, но VPN-соединение не было установлено. Подключитесь, чтобы начать пользоваться.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК вашей подписке подключено новое устройство: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
}
+19 -1
View File
@@ -1586,5 +1586,23 @@
"MODEM_PERIOD_NOTE": "\nℹ️ До закінчення підписки: <b>{days} дн.</b>\nПісля продовження модем потрібно буде оплатити знову.",
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?"
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
"WEBHOOK_SUB_LIMITED": "⚠️ <b>Досягнуто ліміт трафіку</b>\n\nВи вичерпали весь доступний трафік. Докупіть трафік або дочекайтесь скидання.",
"WEBHOOK_SUB_TRAFFIC_RESET": "🔄 <b>Трафік скинуто</b>\n\nВаш трафік обнулено. Ліміт знову доступний у повному обсязі.",
"WEBHOOK_SUB_DELETED": "🗑️ <b>Підписку видалено</b>\n\nВашу підписку було видалено з панелі.",
"WEBHOOK_SUB_REVOKED": "🔑 <b>Ключ підписки оновлено</b>\n\nВаші дані підключення були оновлені. Використовуйте нове посилання для підключення.",
"WEBHOOK_SUB_EXPIRES_72H": "⏳ <b>Підписка закінчується через 3 дні</b>\n\nНе забудьте продовжити підписку, щоб не втратити доступ.",
"WEBHOOK_SUB_EXPIRES_48H": "⏳ <b>Підписка закінчується через 2 дні</b>\n\nПродовжте підписку заздалегідь, щоб зберегти доступ до VPN.",
"WEBHOOK_SUB_EXPIRES_24H": "🔴 <b>Підписка закінчується завтра!</b>\n\nЗалишилось менше 24 годин. Продовжте підписку прямо зараз.",
"WEBHOOK_SUB_EXPIRED_24H_AGO": "💤 <b>Підписка закінчилась вчора</b>\n\nВаша підписка завершена. Продовжте зараз, щоб повернути доступ до VPN.",
"WEBHOOK_SUB_FIRST_CONNECTED": "🎉 <b>Перше підключення!</b>\n\nВи вперше підключились до VPN. Ласкаво просимо!",
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD": "📊 <b>Використано {percent}% трафіку</b>\n\nВи використали значну частину трафіку. Слідкуйте за витратою.",
"WEBHOOK_RENEW_BUTTON": "🔄 Продовжити підписку",
"WEBHOOK_USER_NOT_CONNECTED": "📡 <b>Ви ще не підключились</b>\n\nВаша підписка активна, але VPN-з'єднання не було встановлено. Підключіться, щоб почати користуватися.",
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новий пристрій</b>\n\nДо вашої підписки підключено новий пристрій: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Пристрій видалено</b>\n\nПристрій відключено від підписки: <code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрити"
}
+19 -1
View File
@@ -1919,5 +1919,23 @@
"MODEM_PERIOD_NOTE":"\n️ 距离订阅结束:<b>{days}天</b>\n续订后需重新支付调制解调器费用。",
"MODEM_PRICE_WITH_DISCOUNT":"费用:<s>{base_price}</s> <b>{final_price}</b>{months}个月)\n🎁 折扣{discount}%-{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT":"费用:{price}{months}个月)",
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?"
"MODEM_CONFIRM_ENABLE_BASE":"📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
"WEBHOOK_SUB_EXPIRED":"❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
"WEBHOOK_SUB_DISABLED":"🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
"WEBHOOK_SUB_ENABLED":"✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
"WEBHOOK_SUB_LIMITED":"⚠️ <b>已达到流量限制</b>\n\n您已用完所有可用流量。请购买额外流量或等待重置。",
"WEBHOOK_SUB_TRAFFIC_RESET":"🔄 <b>流量已重置</b>\n\n您的流量已重置。完整限额再次可用。",
"WEBHOOK_SUB_DELETED":"🗑️ <b>订阅已删除</b>\n\n您的订阅已从面板中删除。",
"WEBHOOK_SUB_REVOKED":"🔑 <b>订阅密钥已更新</b>\n\n您的连接凭证已更新。请使用新链接进行连接。",
"WEBHOOK_SUB_EXPIRES_72H":"⏳ <b>订阅将在3天后到期</b>\n\n请记得续订以保持VPN访问。",
"WEBHOOK_SUB_EXPIRES_48H":"⏳ <b>订阅将在2天后到期</b>\n\n请提前续订以保持VPN访问。",
"WEBHOOK_SUB_EXPIRES_24H":"🔴 <b>订阅明天到期!</b>\n\n不到24小时。请立即续订。",
"WEBHOOK_SUB_EXPIRED_24H_AGO":"💤 <b>订阅昨天已过期</b>\n\n您的订阅已结束。请立即续订以恢复VPN访问。",
"WEBHOOK_SUB_FIRST_CONNECTED":"🎉 <b>首次连接!</b>\n\n您首次连接了VPN。欢迎!",
"WEBHOOK_SUB_BANDWIDTH_THRESHOLD":"📊 <b>已使用{percent}%流量</b>\n\n您已使用了大部分流量。请注意使用量。",
"WEBHOOK_RENEW_BUTTON":"🔄 续订订阅",
"WEBHOOK_USER_NOT_CONNECTED":"📡 <b>尚未连接</b>\n\n您的订阅已激活,但尚未建立VPN连接。请连接以开始使用服务。",
"WEBHOOK_DEVICE_ADDED":"📱 <b>新设备</b>\n\n您的订阅已添加新设备:<code>{device}</code>",
"WEBHOOK_DEVICE_DELETED":"📱 <b>设备已移除</b>\n\n设备已从您的订阅中移除:<code>{device}</code>",
"WEBHOOK_CLOSE_BUTTON":"✖️ 关闭"
}
+5
View File
@@ -5,6 +5,7 @@ from datetime import datetime
from typing import Any
from aiogram import BaseMiddleware
from aiogram.exceptions import 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,10 @@ 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 Exception as e:
logger.error(f'Ошибка в AuthMiddleware: {e}')
logger.error(f'Event type: {type(event)}')
+2 -2
View File
@@ -204,6 +204,6 @@ class ButtonStatsMiddleware(BaseMiddleware):
button_text=button_text,
)
except Exception as e:
logger.debug(f'Ошибка записи клика в БД {button_id}: {e}')
logger.warning(f'Ошибка записи клика в БД {button_id}: {e}')
except Exception as e:
logger.debug(f'Ошибка создания сессии БД для логирования клика: {e}')
logger.warning(f'Ошибка создания сессии БД для логирования клика: {e}')
+13 -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
@@ -109,6 +109,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
logger.debug('❌ telegram_id не найден, пропускаем')
return await handler(event, data)
# Skip channel check for lightweight UI callbacks (close/delete notifications)
if isinstance(event, CallbackQuery) and event.data in (
'webhook:close',
'ban_notify:delete',
'noop',
'current_page',
):
return await handler(event, data)
# Админам разрешаем пропускать проверку подписки
if settings.is_admin(telegram_id):
logger.debug(
@@ -188,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)
+15 -7
View File
@@ -1216,6 +1216,21 @@ class AdminNotificationService:
def _is_enabled(self) -> bool:
return self.enabled and bool(self.chat_id)
@property
def is_enabled(self) -> bool:
"""Public check for whether admin notifications are configured and active."""
return self._is_enabled()
async def send_webhook_notification(self, text: str) -> bool:
"""Send a generic webhook/infrastructure notification to admin chat.
Used by RemnaWaveWebhookService for node, service, and CRM events.
The caller is responsible for HTML-escaping all untrusted data in `text`.
"""
if not self._is_enabled():
return False
return await self._send_message(text)
def _get_payment_method_display(self, payment_method: str | None) -> str:
if not payment_method:
return '💰 С баланса'
@@ -1519,7 +1534,6 @@ class AdminNotificationService:
'traffic': '📊 ДОКУПКА ТРАФИКА',
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
'servers': '🌐 СМЕНА СЕРВЕРОВ',
'modem': '📡 МОДЕМ',
}
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
@@ -1555,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}')
@@ -1623,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(
+217 -58
View File
@@ -7,7 +7,7 @@ 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 pathlib import Path
from typing import Any
@@ -24,14 +24,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 +66,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 +163,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 +219,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,7 +599,7 @@ 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, (list, dict)):
record_dict[column.name] = json_lib.dumps(value) if value else None
@@ -538,7 +628,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 +713,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 +875,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 +1015,7 @@ class BackupService:
except Exception as exc:
await db.rollback()
logger.error('Ошибка при восстановлении: %s', exc)
raise exc
raise
return restored_tables, restored_records
@@ -994,10 +1095,9 @@ class BackupService:
except Exception as e:
logger.error(f'Ошибка при восстановлении пользователя: {e}')
await db.rollback()
raise e
raise
await db.commit()
await db.flush()
logger.info('✅ Пользователи без реферальных связей восстановлены')
async def _update_user_referrals(self, db: AsyncSession, backup_data: dict):
@@ -1031,7 +1131,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 +1158,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 +1201,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 +1228,60 @@ 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)
)
await db.execute(table_obj.insert().values(**values))
restored += 1
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,17 +1311,16 @@ 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)
@@ -1229,17 +1334,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 +1565,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
+112 -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
@@ -262,9 +262,18 @@ class MonitoringService:
async def _check_expired_subscriptions(self, db: AsyncSession):
try:
from app.database.crud.subscription import is_recently_updated_by_webhook
expired_subscriptions = await get_expired_subscriptions(db)
for subscription in expired_subscriptions:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск expire подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
from app.database.crud.subscription import expire_subscription
await expire_subscription(db, subscription)
@@ -288,6 +297,15 @@ class MonitoringService:
async def update_remnawave_user(self, db: AsyncSession, subscription: Subscription) -> RemnaWaveUser | None:
try:
from app.database.crud.subscription import is_recently_updated_by_webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно',
subscription.id,
)
return None
user = await get_user_by_id(db, subscription.user_id)
if not user or not user.remnawave_uuid:
logger.error(f'RemnaWave UUID не найден для пользователя {subscription.user_id}')
@@ -299,6 +317,14 @@ class MonitoringService:
except Exception:
pass
# Re-check guard after refresh (webhook could have committed between first check and refresh)
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск RemnaWave обновления подписки %s: обновлена вебхуком недавно (после refresh)',
subscription.id,
)
return None
current_time = datetime.utcnow()
is_active = subscription.status == SubscriptionStatus.ACTIVE.value and subscription.end_date > current_time
@@ -550,6 +576,8 @@ class MonitoringService:
logger.error(f'Ошибка проверки неактивных тестовых подписок: {e}')
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
@@ -636,6 +664,12 @@ class MonitoringService:
continue
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск деактивации trial подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
@@ -670,6 +704,12 @@ class MonitoringService:
'trial_channel_unsubscribed',
)
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск реактивации trial подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.utcnow()
await db.commit()
@@ -1007,6 +1047,15 @@ class MonitoringService:
failed_count = 0
for subscription in autopay_subscriptions:
from app.database.crud.subscription import is_recently_updated_by_webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск автоплатежа подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
user = subscription.user
if not user:
continue
@@ -1237,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',
@@ -1286,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',
@@ -1361,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',
@@ -1424,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',
@@ -1488,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',
@@ -1580,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',
@@ -1604,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',
@@ -1641,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',
@@ -1874,11 +1977,19 @@ class MonitoringService:
}
async def force_check_subscriptions(self, db: AsyncSession) -> dict[str, int]:
from app.database.crud.subscription import is_recently_updated_by_webhook
try:
expired_subscriptions = await get_expired_subscriptions(db)
expired_count = 0
for subscription in expired_subscriptions:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск force-check подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
await deactivate_subscription(db, subscription)
expired_count += 1
@@ -57,6 +57,21 @@ class NotificationType(Enum):
EMAIL_VERIFICATION = 'email_verification'
PASSWORD_RESET = 'password_reset'
# Webhook subscription events
WEBHOOK_SUB_EXPIRED = 'webhook_sub_expired'
WEBHOOK_SUB_DISABLED = 'webhook_sub_disabled'
WEBHOOK_SUB_ENABLED = 'webhook_sub_enabled'
WEBHOOK_SUB_LIMITED = 'webhook_sub_limited'
WEBHOOK_SUB_TRAFFIC_RESET = 'webhook_sub_traffic_reset'
WEBHOOK_SUB_DELETED = 'webhook_sub_deleted'
WEBHOOK_SUB_REVOKED = 'webhook_sub_revoked'
WEBHOOK_SUB_EXPIRING = 'webhook_sub_expiring'
WEBHOOK_SUB_FIRST_CONNECTED = 'webhook_sub_first_connected'
WEBHOOK_SUB_BANDWIDTH_THRESHOLD = 'webhook_sub_bandwidth_threshold'
WEBHOOK_USER_NOT_CONNECTED = 'webhook_user_not_connected'
WEBHOOK_DEVICE_ADDED = 'webhook_device_added'
WEBHOOK_DEVICE_DELETED = 'webhook_device_deleted'
# Other
BROADCAST = 'broadcast'
PAYMENT_RECEIVED = 'payment_received'
+1
View File
@@ -237,6 +237,7 @@ class CloudPaymentsPaymentMixin:
payment_method=PaymentMethod.CLOUDPAYMENTS,
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
payment.transaction_id = transaction.id
+24 -7
View File
@@ -7,16 +7,19 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import select
from sqlalchemy.exc import MissingGreenlet
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import get_db
from app.database.database import AsyncSessionLocal, get_db
from app.database.models import Subscription
from app.localization.texts import get_texts
from app.services.subscription_checkout_service import (
has_subscription_checkout_draft,
@@ -46,12 +49,26 @@ class PaymentCommonMixin:
and not getattr(subscription, 'is_trial', False)
and getattr(subscription, 'is_active', False)
)
except MissingGreenlet as error:
logger.warning(
'Не удалось лениво загрузить подписку пользователя %s при построении клавиатуры после пополнения: %s',
getattr(user, 'id', None),
error,
)
except MissingGreenlet:
# user вне сессии — загружаем подписку отдельным запросом
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Subscription.status, Subscription.is_trial, Subscription.end_date)
.where(Subscription.user_id == user.id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
row = result.one_or_none()
if row:
is_active = row.status == 'active' and row.end_date > datetime.utcnow()
has_active_subscription = bool(is_active and not row.is_trial)
except Exception as db_error:
logger.warning(
'Не удалось загрузить подписку пользователя %s из БД: %s',
getattr(user, 'id', None),
db_error,
)
except Exception as error: # pragma: no cover - защитный код
logger.error(
'Ошибка загрузки подписки пользователя %s при построении клавиатуры после пополнения: %s',
+5 -2
View File
@@ -156,8 +156,10 @@ class CryptoBotPaymentMixin:
cryptobot_crud = import_module('app.database.crud.cryptobot')
payment = await cryptobot_crud.get_cryptobot_payment_by_invoice_id(db, invoice_id)
if not payment:
logger.error('CryptoBot платеж не найден в БД: %s', invoice_id)
return False
logger.warning(
'CryptoBot платеж не найден в БД: %s (возвращаем 200 чтобы остановить ретраи)', invoice_id
)
return True
if payment.status == 'paid':
logger.info('CryptoBot платеж %s уже обработан', invoice_id)
@@ -251,6 +253,7 @@ class CryptoBotPaymentMixin:
payment_method=PaymentMethod.CRYPTOBOT,
external_id=invoice_id,
is_completed=True,
created_at=getattr(updated_payment, 'created_at', None),
)
await cryptobot_crud.link_cryptobot_payment_to_transaction(db, invoice_id, transaction.id)
+1
View File
@@ -279,6 +279,7 @@ class FreekassaPaymentMixin:
payment_method=PaymentMethod.FREEKASSA,
external_id=str(intid) if intid else payment.order_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
# Связываем платеж с транзакцией
+5 -2
View File
@@ -260,7 +260,10 @@ class HeleketPaymentMixin:
invoice_message = metadata.get('invoice_message') or {}
invoice_message_removed = False
if getattr(self, 'bot', None) and invoice_message:
status_normalized = (status or '').lower()
is_final = status_normalized in {'paid', 'paid_over', 'cancel', 'fail', 'system_fail', 'refund_paid'}
if getattr(self, 'bot', None) and invoice_message and is_final:
chat_id = invoice_message.get('chat_id')
message_id = invoice_message.get('message_id')
if chat_id and message_id:
@@ -300,7 +303,6 @@ class HeleketPaymentMixin:
)
return updated_payment
status_normalized = (status or '').lower()
if status_normalized not in {'paid', 'paid_over'}:
logger.info('Heleket платеж %s в статусе %s, зачисление не требуется', updated_payment.uuid, status)
return updated_payment
@@ -323,6 +325,7 @@ class HeleketPaymentMixin:
payment_method=PaymentMethod.HELEKET,
external_id=updated_payment.uuid,
is_completed=True,
created_at=getattr(updated_payment, 'created_at', None),
)
linked_payment = await heleket_crud.link_heleket_payment_to_transaction(
+1
View File
@@ -272,6 +272,7 @@ class KassaAiPaymentMixin:
payment_method=PaymentMethod.KASSA_AI,
external_id=str(intid) if intid else payment.order_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
# Связываем платеж с транзакцией
+2 -1
View File
@@ -75,7 +75,7 @@ class MulenPayPaymentMixin:
uuid=payment_uuid,
items=items,
language=language or settings.MULENPAY_LANGUAGE,
website_url=settings.WEBHOOK_URL,
website_url=settings.MULENPAY_WEBSITE_URL or settings.WEBHOOK_URL,
)
if not response:
@@ -253,6 +253,7 @@ class MulenPayPaymentMixin:
payment_method=PaymentMethod.MULENPAY,
external_id=payment.uuid,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
await payment_module.link_mulenpay_payment_to_transaction(
+1
View File
@@ -385,6 +385,7 @@ class Pal24PaymentMixin:
payment_method=PaymentMethod.PAL24,
external_id=str(payment_id) if payment_id else payment.bill_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
await payment_module.link_pal24_payment_to_transaction(db, payment, transaction.id)
+1
View File
@@ -366,6 +366,7 @@ class PlategaPaymentMixin:
payment_method=PaymentMethod.PLATEGA,
external_id=transaction_external_id or payment.correlation_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
created_transaction = True
+1
View File
@@ -472,6 +472,7 @@ class WataPaymentMixin:
payment_method=PaymentMethod.WATA,
external_id=transaction_external_id or payment.payment_link_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
await payment_module.link_wata_payment_to_transaction(db, payment, transaction.id)
+16
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')
# Проверяем, не обрабатывается ли уже этот платеж (защита от дублирования)
@@ -589,6 +604,7 @@ class YooKassaPaymentMixin:
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
)
if not getattr(payment, 'transaction_id', None):
+48 -10
View File
@@ -1335,9 +1335,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():
@@ -1454,10 +1463,20 @@ class RemnaWaveService:
for telegram_id, db_user in users_to_deactivate:
cleanup_mutation: _UUIDMapMutation | None = None
try:
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
subscription = db_user.subscription
# Skip if recently updated by webhook
from app.database.crud.subscription import is_recently_updated_by_webhook
if subscription and is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
subscription.id,
)
continue
logger.info(f'🗑️ Деактивация подписки пользователя {telegram_id} (нет в панели)')
if db_user.remnawave_uuid and hwid_api_client:
try:
devices_reset = await hwid_api_client.reset_user_devices(db_user.remnawave_uuid)
@@ -1664,7 +1683,7 @@ class RemnaWaveService:
async def _update_subscription_from_panel_data(self, db: AsyncSession, user, panel_user):
try:
from app.database.crud.subscription import get_subscription_by_user_id
from app.database.crud.subscription import get_subscription_by_user_id, is_recently_updated_by_webhook
from app.database.models import SubscriptionStatus
# Всегда используем async CRUD запрос для получения подписки,
@@ -1675,6 +1694,14 @@ class RemnaWaveService:
await self._create_subscription_from_panel_data(db, user, panel_user)
return
# Skip if recently updated by webhook (prevent stale data overwrite)
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск синхронизации подписки %s: обновлена вебхуком недавно',
subscription.id,
)
return
panel_status = panel_user.get('status', 'ACTIVE')
expire_at_str = panel_user.get('expireAt', '')
@@ -2499,12 +2526,20 @@ class RemnaWaveService:
await self._update_subscription_from_panel_data(db, user, panel_user)
stats['updated'] += 1
elif subscription.status != SubscriptionStatus.DISABLED.value:
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
from app.database.crud.subscription import (
deactivate_subscription,
is_recently_updated_by_webhook,
)
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, subscription)
stats['updated'] += 1
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск деактивации подписки %s: обновлена вебхуком недавно',
subscription.id,
)
else:
logger.info(f'🗑️ Деактивируем подписку пользователя {user.telegram_id} (нет в панели)')
await deactivate_subscription(db, subscription)
stats['updated'] += 1
except Exception as sub_error:
logger.error(f'❌ Ошибка синхронизации подписки {subscription.id}: {sub_error}')
@@ -2547,6 +2582,8 @@ class RemnaWaveService:
user = subscription.user
issues_fixed = 0
from app.database.crud.subscription import is_recently_updated_by_webhook
current_time = self._now_utc()
# Конвертируем end_date в UTC для корректного сравнения
end_date_utc = self._local_to_utc(subscription.end_date)
@@ -2555,6 +2592,7 @@ class RemnaWaveService:
if (
end_date_utc + expiry_buffer <= current_time
and subscription.status == SubscriptionStatus.ACTIVE.value
and not is_recently_updated_by_webhook(subscription)
):
time_since_expiry = current_time - end_date_utc
logger.warning(
+780
View File
@@ -0,0 +1,780 @@
"""
Service for processing incoming RemnaWave backend webhooks.
Handles all webhook scopes: user, user_hwid_devices, node, service, crm.
User events update subscription state and notify the user.
Admin events (node, service, crm) send alerts to the admin notification chat.
"""
from __future__ import annotations
import html
import logging
import re
from datetime import UTC, datetime
from typing import Any
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.exc import StaleDataError
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, 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
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
# Mapping from locale text_key to NotificationType for unified delivery
_TEXT_KEY_TO_NOTIFICATION_TYPE: dict[str, NotificationType] = {
'WEBHOOK_SUB_EXPIRED': NotificationType.WEBHOOK_SUB_EXPIRED,
'WEBHOOK_SUB_DISABLED': NotificationType.WEBHOOK_SUB_DISABLED,
'WEBHOOK_SUB_ENABLED': NotificationType.WEBHOOK_SUB_ENABLED,
'WEBHOOK_SUB_LIMITED': NotificationType.WEBHOOK_SUB_LIMITED,
'WEBHOOK_SUB_TRAFFIC_RESET': NotificationType.WEBHOOK_SUB_TRAFFIC_RESET,
'WEBHOOK_SUB_DELETED': NotificationType.WEBHOOK_SUB_DELETED,
'WEBHOOK_SUB_REVOKED': NotificationType.WEBHOOK_SUB_REVOKED,
'WEBHOOK_SUB_EXPIRES_72H': NotificationType.WEBHOOK_SUB_EXPIRING,
'WEBHOOK_SUB_EXPIRES_48H': NotificationType.WEBHOOK_SUB_EXPIRING,
'WEBHOOK_SUB_EXPIRES_24H': NotificationType.WEBHOOK_SUB_EXPIRING,
'WEBHOOK_SUB_EXPIRED_24H_AGO': NotificationType.WEBHOOK_SUB_EXPIRED,
'WEBHOOK_SUB_FIRST_CONNECTED': NotificationType.WEBHOOK_SUB_FIRST_CONNECTED,
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD': NotificationType.WEBHOOK_SUB_BANDWIDTH_THRESHOLD,
'WEBHOOK_USER_NOT_CONNECTED': NotificationType.WEBHOOK_USER_NOT_CONNECTED,
'WEBHOOK_DEVICE_ADDED': NotificationType.WEBHOOK_DEVICE_ADDED,
'WEBHOOK_DEVICE_DELETED': NotificationType.WEBHOOK_DEVICE_DELETED,
}
# Admin event display names for notification messages
_ADMIN_NODE_EVENTS: dict[str, str] = {
'node.created': '🟢 Нода создана',
'node.modified': '🔧 Нода изменена',
'node.disabled': '🔴 Нода отключена',
'node.enabled': '🟢 Нода включена',
'node.deleted': '🗑️ Нода удалена',
'node.connection_lost': '🚨 Потеряно соединение с нодой',
'node.connection_restored': '✅ Соединение с нодой восстановлено',
'node.traffic_notify': '📊 Уведомление о трафике ноды',
}
_ADMIN_SERVICE_EVENTS: dict[str, str] = {
'service.panel_started': '🚀 Панель RemnaWave запущена',
'service.login_attempt_failed': '🔐 Неудачная попытка входа в панель',
'service.login_attempt_success': '🔓 Успешный вход в панель',
'service.subpage_config_changed': '📄 Конфиг страницы подписки изменён',
}
_ADMIN_CRM_EVENTS: dict[str, str] = {
'crm.infra_billing_node_payment_in_7_days': '💳 Оплата ноды через 7 дней',
'crm.infra_billing_node_payment_in_48hrs': '💳 Оплата ноды через 48 часов',
'crm.infra_billing_node_payment_in_24hrs': '⚠️ Оплата ноды через 24 часа',
'crm.infra_billing_node_payment_due_today': '🔴 Оплата ноды сегодня',
'crm.infra_billing_node_payment_overdue_24hrs': '❗ Просрочка оплаты ноды: 24 часа',
'crm.infra_billing_node_payment_overdue_48hrs': '❗ Просрочка оплаты ноды: 48 часов',
'crm.infra_billing_node_payment_overdue_7_days': '🚨 Просрочка оплаты ноды: 7 дней',
}
_ADMIN_ERROR_EVENTS: dict[str, str] = {
'errors.bandwidth_usage_threshold_reached_max_notifications': '⚠️ Достигнут лимит уведомлений о трафике',
}
class RemnaWaveWebhookService:
"""Processes incoming webhooks from RemnaWave backend."""
def __init__(self, bot: Bot) -> None:
self.bot = bot
self._admin_service = AdminNotificationService(bot)
# User-scoped handlers: require user resolution
self._user_handlers: dict[str, Any] = {
'user.expired': self._handle_user_expired,
'user.disabled': self._handle_user_disabled,
'user.enabled': self._handle_user_enabled,
'user.limited': self._handle_user_limited,
'user.traffic_reset': self._handle_user_traffic_reset,
'user.modified': self._handle_user_modified,
'user.deleted': self._handle_user_deleted,
'user.revoked': self._handle_user_revoked,
'user.created': self._handle_user_created,
'user.expires_in_72_hours': self._handle_expires_in_72h,
'user.expires_in_48_hours': self._handle_expires_in_48h,
'user.expires_in_24_hours': self._handle_expires_in_24h,
'user.expired_24_hours_ago': self._handle_expired_24h_ago,
'user.first_connected': self._handle_first_connected,
'user.bandwidth_usage_threshold_reached': self._handle_bandwidth_threshold,
'user.not_connected': self._handle_user_not_connected,
'user_hwid_devices.added': self._handle_device_added,
'user_hwid_devices.deleted': self._handle_device_deleted,
}
# Admin-scoped handlers: no user resolution, notify admin chat
self._admin_handlers: dict[str, str] = {
**_ADMIN_NODE_EVENTS,
**_ADMIN_SERVICE_EVENTS,
**_ADMIN_CRM_EVENTS,
**_ADMIN_ERROR_EVENTS,
}
def is_admin_event(self, event_name: str) -> bool:
"""Check if the event is admin-scoped (no DB session needed)."""
return event_name in self._admin_handlers
async def process_event(self, db: AsyncSession | None, event_name: str, data: dict) -> bool:
"""Route event to the appropriate handler.
Returns True if the event was processed, False if skipped/unknown.
db may be None for admin events that don't require database access.
"""
# Check admin-scoped handlers (no DB needed)
if event_name in self._admin_handlers:
return await self._process_admin_event(event_name, data)
# Check user-scoped handlers (require DB session)
user_handler = self._user_handlers.get(event_name)
if user_handler:
if db is None:
logger.error('RemnaWave webhook: DB session required for user event %s', event_name)
return False
return await self._process_user_event(db, event_name, data, user_handler)
logger.debug('Unhandled RemnaWave webhook event: %s', event_name)
return False
async def _process_user_event(self, db: AsyncSession, event_name: str, data: dict, handler: Any) -> bool:
"""Resolve user and execute user-scoped handler."""
user, subscription = await self._resolve_user_and_subscription(db, data)
if not user:
logger.warning(
'RemnaWave webhook: user not found for event %s, data telegramId=%s uuid=%s',
event_name,
data.get('telegramId'),
data.get('uuid'),
)
return False
user_id = user.id
try:
await handler(db, user, subscription, data)
return True
except StaleDataError:
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)
try:
await db.rollback()
except Exception:
logger.debug('Rollback after webhook handler error also failed')
return False
async def _process_admin_event(self, event_name: str, data: dict) -> bool:
"""Format and send admin notification for infrastructure events."""
if not self._admin_service.is_enabled:
logger.debug('Admin notifications disabled, skipping event %s', event_name)
return True
title = self._admin_handlers.get(event_name, event_name)
# Build message from event data (escape all untrusted values to prevent HTML injection)
lines = [f'<b>{title}</b>']
# Extract common fields
name = html.escape(data.get('name') or data.get('nodeName') or data.get('username') or '')
if name:
lines.append(f'Имя: <code>{name}</code>')
address = html.escape(data.get('address') or data.get('ip') or '')
if address:
lines.append(f'Адрес: <code>{address}</code>')
port = data.get('port')
if port:
lines.append(f'Порт: <code>{html.escape(str(port))}</code>')
version = html.escape(data.get('version') or data.get('panelVersion') or '')
if version:
lines.append(f'Версия: <code>{version}</code>')
# CRM billing fields
amount = html.escape(str(data.get('amount') or data.get('price') or ''))
if amount:
lines.append(f'Сумма: <code>{amount}</code>')
due_date = html.escape(data.get('dueDate') or data.get('paymentDate') or '')
if due_date:
lines.append(f'Дата: <code>{due_date}</code>')
# Login attempt fields
ip_addr = html.escape(data.get('ipAddress') or data.get('ip') or '')
if ip_addr and not address:
lines.append(f'IP: <code>{ip_addr}</code>')
message = html.escape(data.get('message') or '')
if message:
lines.append(f'Сообщение: {message}')
# Subpage config fields
subpage = data.get('subpageConfig')
if isinstance(subpage, dict):
action = subpage.get('action', '')
action_labels = {'CREATED': 'Создан', 'UPDATED': 'Обновлён', 'DELETED': 'Удалён'}
lines.append(f'Действие: {action_labels.get(action, html.escape(str(action)))}')
sub_uuid = subpage.get('uuid', '')
if sub_uuid:
lines.append(f'UUID: <code>{html.escape(str(sub_uuid))}</code>')
try:
await self._admin_service.send_webhook_notification('\n'.join(lines))
return True
except Exception:
logger.exception('Failed to send admin notification for event %s', event_name)
return False
# ------------------------------------------------------------------
# User resolution
# ------------------------------------------------------------------
async def _resolve_user_and_subscription(
self, db: AsyncSession, data: dict
) -> tuple[User | None, Subscription | None]:
"""Find bot user by telegramId or uuid from webhook payload.
Handles both user-scope events (top-level telegramId/uuid) and
device-scope events (userUuid, or nested user.telegramId/user.uuid).
"""
user: User | None = None
# Try top-level telegramId first
telegram_id = data.get('telegramId')
if telegram_id:
try:
user = await get_user_by_telegram_id(db, int(telegram_id))
except (ValueError, TypeError):
pass
# Try top-level uuid
if not user:
uuid = data.get('uuid') or data.get('userUuid')
if uuid:
user = await get_user_by_remnawave_uuid(db, uuid)
# Try nested user object (e.g. user_hwid_devices events)
if not user:
nested_user = data.get('user')
if isinstance(nested_user, dict):
nested_tid = nested_user.get('telegramId')
if nested_tid:
try:
user = await get_user_by_telegram_id(db, int(nested_tid))
except (ValueError, TypeError):
pass
if not user:
nested_uuid = nested_user.get('uuid')
if nested_uuid:
user = await get_user_by_remnawave_uuid(db, nested_uuid)
if not user:
return None, None
subscription = await get_subscription_by_user_id(db, user.id)
return user, subscription
# ------------------------------------------------------------------
# Notification helpers
# ------------------------------------------------------------------
@staticmethod
def _is_valid_url(value: str) -> bool:
"""Basic URL validation to prevent stored XSS via crafted URLs."""
if not value or len(value) > 2048:
return False
return bool(re.match(r'^https?://', value))
@staticmethod
def _is_valid_link(value: str) -> bool:
"""Validate URL or deep link (happ://, vless://, ss://, etc.)."""
if not value or len(value) > 4096:
return False
return bool(re.match(r'^[a-zA-Z][a-zA-Z0-9+\-.]*://', value))
def _get_renew_keyboard(self, user: User) -> InlineKeyboardMarkup:
texts = get_texts(user.language)
button_text = texts.get('WEBHOOK_RENEW_BUTTON', 'Renew subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_extend')],
]
)
def _get_subscription_keyboard(self, user: User) -> InlineKeyboardMarkup:
texts = get_texts(user.language)
button_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription')],
]
)
def _get_connect_keyboard(self, user: User) -> InlineKeyboardMarkup:
texts = get_texts(user.language)
button_text = texts.get('CONNECT_BUTTON', 'Connect')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=button_text, callback_data='subscription_connect')],
]
)
def _get_traffic_keyboard(self, user: User) -> InlineKeyboardMarkup:
texts = get_texts(user.language)
buy_text = texts.get('BUY_TRAFFIC_BUTTON', 'Buy traffic')
sub_text = texts.get('MY_SUBSCRIPTION_BUTTON', 'My subscription')
return InlineKeyboardMarkup(
inline_keyboard=[
[build_miniapp_or_callback_button(text=buy_text, callback_data='subscription_add_traffic')],
[build_miniapp_or_callback_button(text=sub_text, callback_data='subscription')],
]
)
async def _notify_user(
self,
user: User,
text_key: str,
*,
reply_markup: InlineKeyboardMarkup | None = None,
format_kwargs: dict[str, Any] | None = None,
) -> None:
"""Send a notification to user via appropriate channel.
Telegram users receive a bot message; email-only users receive
an email and/or WebSocket notification through the unified
notification delivery service.
"""
texts = get_texts(user.language)
message = texts.get(text_key)
if not message:
logger.warning('Missing locale key %s for language %s', text_key, user.language)
return
if format_kwargs:
try:
message = message.format(**format_kwargs)
except (KeyError, IndexError):
logger.warning('Failed to format message %s with kwargs %s', text_key, format_kwargs)
return
# Append "Close" button to every webhook notification keyboard
close_text = texts.get('WEBHOOK_CLOSE_BUTTON', '✖️ Закрыть')
close_row = [InlineKeyboardButton(text=close_text, callback_data='webhook:close')]
if reply_markup:
reply_markup = InlineKeyboardMarkup(
inline_keyboard=[*reply_markup.inline_keyboard, close_row],
)
else:
reply_markup = InlineKeyboardMarkup(inline_keyboard=[close_row])
notification_type = _TEXT_KEY_TO_NOTIFICATION_TYPE.get(text_key)
if not notification_type:
logger.warning('No NotificationType mapping for text_key %s', text_key)
return
context = {'text_key': text_key, **(format_kwargs or {})}
try:
await notification_delivery_service.send_notification(
user=user,
notification_type=notification_type,
context=context,
bot=self.bot,
telegram_message=message,
telegram_markup=reply_markup,
)
except Exception:
logger.exception('Notification delivery failed for user %s, text_key %s', user.id, text_key)
# ------------------------------------------------------------------
# Webhook timestamp helper
# ------------------------------------------------------------------
@staticmethod
def _stamp_webhook_update(subscription: Subscription) -> None:
"""Mark subscription as recently updated by webhook to prevent sync overwrite."""
subscription.last_webhook_update_at = datetime.now(UTC).replace(tzinfo=None)
# ------------------------------------------------------------------
# User event handlers
# ------------------------------------------------------------------
async def _handle_user_expired(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status != SubscriptionStatus.EXPIRED.value:
await expire_subscription(db, subscription)
logger.info('Webhook: subscription %s expired for user %s', subscription.id, user.id)
else:
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED', reply_markup=self._get_renew_keyboard(user))
async def _handle_user_disabled(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status != SubscriptionStatus.DISABLED.value:
await deactivate_subscription(db, subscription)
logger.info('Webhook: subscription %s disabled for user %s', subscription.id, user.id)
else:
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_DISABLED', reply_markup=self._get_subscription_keyboard(user))
async def _handle_user_enabled(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status == SubscriptionStatus.DISABLED.value:
await reactivate_subscription(db, subscription)
logger.info('Webhook: subscription %s re-enabled for user %s', subscription.id, user.id)
else:
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_ENABLED', reply_markup=self._get_connect_keyboard(user))
async def _handle_user_limited(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
if subscription.status == SubscriptionStatus.ACTIVE.value:
await deactivate_subscription(db, subscription)
logger.info('Webhook: subscription %s limited (traffic) for user %s', subscription.id, user.id)
else:
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_LIMITED', reply_markup=self._get_traffic_keyboard(user))
async def _handle_user_traffic_reset(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
self._stamp_webhook_update(subscription)
await update_subscription_usage(db, subscription, 0.0)
# Re-enable if was disabled due to traffic limit
if subscription.status == SubscriptionStatus.DISABLED.value:
await reactivate_subscription(db, subscription)
logger.info('Webhook: traffic reset for subscription %s, user %s', subscription.id, user.id)
await self._notify_user(user, 'WEBHOOK_SUB_TRAFFIC_RESET', reply_markup=self._get_subscription_keyboard(user))
async def _handle_user_modified(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
"""Sync subscription fields from webhook payload without notifying user."""
if not subscription:
return
changed = False
# Sync traffic limit
traffic_limit_bytes = data.get('trafficLimitBytes')
if traffic_limit_bytes is not None:
try:
new_limit_gb = int(traffic_limit_bytes) // (1024**3)
if subscription.traffic_limit_gb != new_limit_gb:
subscription.traffic_limit_gb = new_limit_gb
changed = True
except (ValueError, TypeError):
pass
# Sync used traffic
used_traffic_bytes = data.get('usedTrafficBytes')
if used_traffic_bytes is not None:
try:
new_used_gb = round(int(used_traffic_bytes) / (1024**3), 2)
subscription.traffic_used_gb = new_used_gb
changed = True
except (ValueError, TypeError):
pass
# Sync expire date
expire_at = data.get('expireAt')
if expire_at:
try:
parsed_dt = datetime.fromisoformat(expire_at.replace('Z', '+00:00'))
new_end_date = parsed_dt.astimezone(UTC).replace(tzinfo=None)
if subscription.end_date != new_end_date:
subscription.end_date = new_end_date
changed = True
except (ValueError, TypeError):
pass
# Sync status from panel
panel_status = data.get('status')
if panel_status:
now = datetime.now(UTC).replace(tzinfo=None)
end_date = subscription.end_date
if panel_status == 'ACTIVE' and end_date and end_date > now:
if subscription.status != SubscriptionStatus.ACTIVE.value:
subscription.status = SubscriptionStatus.ACTIVE.value
changed = True
logger.info(
'Webhook: subscription %s reactivated (%s → active) for user %s',
subscription.id,
subscription.status,
user.id,
)
elif panel_status == 'DISABLED':
if subscription.status != SubscriptionStatus.DISABLED.value:
subscription.status = SubscriptionStatus.DISABLED.value
changed = True
# Sync subscription URL (validate to prevent stored XSS)
subscription_url = data.get('subscriptionUrl')
if (
subscription_url
and self._is_valid_url(subscription_url)
and subscription.subscription_url != subscription_url
):
subscription.subscription_url = subscription_url
changed = True
# Always stamp to protect from sync overwrite, even if no fields changed
self._stamp_webhook_update(subscription)
if changed:
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
logger.info('Webhook: subscription %s modified (synced from panel) for user %s', subscription.id, user.id)
await db.commit()
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.refresh(user)
except Exception:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.error('Webhook: user %s not found after rollback', user_id)
return
if subscription:
if subscription.status != SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.EXPIRED.value
logger.info(
'Webhook: subscription %s marked expired (user deleted in panel) for user %s',
sub_id,
user_id,
)
# 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 self._notify_user(user, 'WEBHOOK_SUB_DELETED', reply_markup=self._get_renew_keyboard(user))
async def _handle_user_revoked(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
if subscription:
new_url = data.get('subscriptionUrl')
new_crypto_link = data.get('subscriptionCryptoLink')
changed = False
if new_url and self._is_valid_url(new_url) and subscription.subscription_url != new_url:
subscription.subscription_url = new_url
changed = True
if (
new_crypto_link
and self._is_valid_link(new_crypto_link)
and subscription.subscription_crypto_link != new_crypto_link
):
subscription.subscription_crypto_link = new_crypto_link
changed = True
# Always stamp to protect from sync overwrite
self._stamp_webhook_update(subscription)
if changed:
subscription.updated_at = datetime.now(UTC).replace(tzinfo=None)
logger.info(
'Webhook: subscription %s credentials revoked/updated for user %s', subscription.id, user.id
)
await db.commit()
await self._notify_user(user, 'WEBHOOK_SUB_REVOKED', reply_markup=self._get_connect_keyboard(user))
async def _handle_user_created(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
logger.info('Webhook: user %s created externally in panel (uuid=%s)', user.id, data.get('uuid'))
async def _handle_expires_in_72h(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_72H', reply_markup=self._get_renew_keyboard(user))
async def _handle_expires_in_48h(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_48H', reply_markup=self._get_renew_keyboard(user))
async def _handle_expires_in_24h(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRES_24H', reply_markup=self._get_renew_keyboard(user))
async def _handle_expired_24h_ago(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
await self._notify_user(user, 'WEBHOOK_SUB_EXPIRED_24H_AGO', reply_markup=self._get_renew_keyboard(user))
async def _handle_first_connected(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
logger.info('Webhook: user %s first VPN connection', user.id)
await self._notify_user(user, 'WEBHOOK_SUB_FIRST_CONNECTED', reply_markup=self._get_subscription_keyboard(user))
async def _handle_bandwidth_threshold(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Extract threshold percentage from meta or data
percent = data.get('thresholdPercent') or data.get('threshold', '')
if not percent:
# Try to extract from meta
meta = data.get('meta', {})
if isinstance(meta, dict):
percent = meta.get('thresholdPercent', '80')
# Sanitize to numeric value only (prevent format string injection)
percent_str = re.sub(r'[^\d.]', '', str(percent)) or '80'
await self._notify_user(
user,
'WEBHOOK_SUB_BANDWIDTH_THRESHOLD',
reply_markup=self._get_traffic_keyboard(user),
format_kwargs={'percent': percent_str},
)
async def _handle_user_not_connected(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
logger.info('Webhook: user %s has not connected to VPN', user.id)
await self._notify_user(user, 'WEBHOOK_USER_NOT_CONNECTED', reply_markup=self._get_connect_keyboard(user))
# ------------------------------------------------------------------
# Device event handlers (user_hwid_devices scope)
# ------------------------------------------------------------------
@staticmethod
def _extract_device_name(data: dict) -> str:
"""Extract device name from webhook payload.
RemnaWave sends device info in data['hwidUserDevice'] nested object.
Builds a composite name: "tag (platform)" or just "platform" or hwid short.
"""
device_obj = data.get('hwidUserDevice')
if not isinstance(device_obj, dict):
# Fallback: top-level fields
raw = data.get('deviceName') or data.get('tag') or data.get('hwid') or ''
return html.escape(str(raw)) if raw else ''
tag = (device_obj.get('tag') or device_obj.get('deviceName') or device_obj.get('name') or '').strip()
platform = (device_obj.get('platform') or '').strip()
hwid = (device_obj.get('hwid') or '').strip()
if tag and platform:
return html.escape(f'{tag} ({platform})')
if tag:
return html.escape(tag)
if platform and hwid:
# Show platform + short hwid suffix for identification
hwid_short = hwid[:8] if len(hwid) > 8 else hwid
return html.escape(f'{platform} ({hwid_short})')
if platform:
return html.escape(platform)
if hwid:
hwid_short = hwid[:12] if len(hwid) > 12 else hwid
return html.escape(hwid_short)
return ''
async def _handle_device_added(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
device_name = self._extract_device_name(data)
logger.info('Webhook: device added for user %s: %s', user.id, device_name or '(empty)')
await self._notify_user(
user,
'WEBHOOK_DEVICE_ADDED',
reply_markup=self._get_subscription_keyboard(user),
format_kwargs={'device': device_name or ''},
)
async def _handle_device_deleted(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
device_name = self._extract_device_name(data)
logger.info('Webhook: device deleted for user %s: %s', user.id, device_name or '(empty)')
await self._notify_user(
user,
'WEBHOOK_DEVICE_DELETED',
reply_markup=self._get_subscription_keyboard(user),
format_kwargs={'device': device_name or ''},
)
+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,
+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),
+8 -2
View File
@@ -5926,7 +5926,10 @@ 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)
try:
await add_user_to_servers(db, added_server_ids)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов (add): {e}')
removed_server_ids = [
catalog[uuid].get('server_id') for uuid in removed if catalog[uuid].get('server_id') is not None
@@ -5934,7 +5937,10 @@ 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)
try:
await remove_user_from_servers(db, removed_server_ids)
except Exception as e:
logger.error(f'Ошибка обновления счётчика серверов (remove): {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
+149
View File
@@ -0,0 +1,149 @@
"""
FastAPI router for receiving incoming webhooks from RemnaWave backend.
Handles HMAC-SHA256 signature verification, payload parsing, and
event dispatch to RemnaWaveWebhookService.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from aiogram import Bot
from fastapi import APIRouter, Request, status
from fastapi.responses import JSONResponse
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.services.remnawave_webhook_service import RemnaWaveWebhookService
logger = logging.getLogger(__name__)
# Max accepted webhook payload size (64 KB) to prevent memory exhaustion DoS
_MAX_BODY_SIZE = 64 * 1024
def _verify_signature(raw_body: bytes, received_signature: str, secret: str) -> bool:
"""Verify HMAC-SHA256 signature from RemnaWave backend."""
expected = hmac.new(secret.encode('utf-8'), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received_signature)
def create_remnawave_webhook_router(bot: Bot) -> APIRouter:
router = APIRouter()
webhook_service = RemnaWaveWebhookService(bot)
webhook_path = settings.REMNAWAVE_WEBHOOK_PATH
@router.get(webhook_path)
async def remnawave_webhook_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'service': 'remnawave_webhook',
'enabled': settings.is_remnawave_webhook_enabled(),
}
)
@router.post(webhook_path)
async def remnawave_webhook(request: Request) -> JSONResponse:
raw_body = await request.body()
if not raw_body:
return JSONResponse(
{'status': 'error', 'reason': 'empty_body'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if len(raw_body) > _MAX_BODY_SIZE:
logger.warning('RemnaWave webhook: payload too large (%d bytes)', len(raw_body))
return JSONResponse(
{'status': 'error', 'reason': 'payload_too_large'},
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
# Verify HMAC-SHA256 signature (always required)
secret = settings.REMNAWAVE_WEBHOOK_SECRET
if not secret:
logger.error('RemnaWave webhook: secret not configured, rejecting request')
return JSONResponse(
{'status': 'error', 'reason': 'webhook_not_configured'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
signature = request.headers.get('X-Remnawave-Signature') or ''
if not signature:
logger.warning('RemnaWave webhook: missing signature header')
return JSONResponse(
{'status': 'error', 'reason': 'missing_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
if not _verify_signature(raw_body, signature, secret):
logger.warning('RemnaWave webhook: invalid signature')
return JSONResponse(
{'status': 'error', 'reason': 'invalid_signature'},
status_code=status.HTTP_401_UNAUTHORIZED,
)
# Parse JSON payload
try:
payload = json.loads(raw_body.decode('utf-8'))
except json.JSONDecodeError:
return JSONResponse(
{'status': 'error', 'reason': 'invalid_json'},
status_code=status.HTTP_400_BAD_REQUEST,
)
# Extract and validate event info
scope = payload.get('scope', '')
event = payload.get('event', '')
data = payload.get('data')
if not scope or not event:
logger.warning('RemnaWave webhook: missing scope or event')
return JSONResponse(
{'status': 'error', 'reason': 'missing_scope_or_event'},
status_code=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(data, dict):
data = {}
# RemnaWave sends event as full qualified name (e.g. "user.modified"),
# so we use event directly instead of concatenating scope + event.
event_name = event
logger.info('RemnaWave webhook received: scope=%s, event=%s', scope, event_name)
# Process event — return 200 to prevent retries for application-level errors.
# Only return non-200 for infrastructure failures (DB unavailable).
# Admin events (node/service/crm) don't need a DB session.
if webhook_service.is_admin_event(event_name):
try:
processed = await webhook_service.process_event(None, event_name, data)
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
logger.exception('RemnaWave webhook processing error for event %s', event_name)
return JSONResponse({'status': 'ok', 'processed': False})
# User events require a DB session
try:
async with AsyncSessionLocal() as db:
try:
processed = await webhook_service.process_event(db, event_name, data)
await db.commit()
return JSONResponse({'status': 'ok', 'processed': processed})
except Exception:
await db.rollback()
logger.exception('RemnaWave webhook processing error for event %s', event_name)
return JSONResponse({'status': 'ok', 'processed': False})
except Exception:
logger.error('RemnaWave webhook: failed to get database session')
return JSONResponse(
{'status': 'error', 'reason': 'database_unavailable'},
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
)
return router
+16
View File
@@ -111,6 +111,16 @@ def create_unified_app(
payments_router = payments.create_payment_router(bot, payment_service)
if payments_router:
app.include_router(payments_router)
# Mount RemnaWave incoming webhook router
remnawave_webhook_enabled = settings.is_remnawave_webhook_enabled()
if remnawave_webhook_enabled:
from app.webserver.remnawave_webhook import create_remnawave_webhook_router
remnawave_router = create_remnawave_webhook_router(bot)
app.include_router(remnawave_router)
logger.info('RemnaWave webhook router mounted at %s', settings.REMNAWAVE_WEBHOOK_PATH)
payment_providers_state = {
'tribute': settings.TRIBUTE_ENABLED,
'mulenpay': settings.is_mulenpay_enabled(),
@@ -181,6 +191,11 @@ def create_unified_app(
'path': str(miniapp_path),
}
remnawave_webhook_state = {
'enabled': remnawave_webhook_enabled,
'path': settings.REMNAWAVE_WEBHOOK_PATH if remnawave_webhook_enabled else None,
}
return JSONResponse(
{
'status': 'ok',
@@ -188,6 +203,7 @@ def create_unified_app(
'web_api_enabled': settings.is_web_api_enabled(),
'payment_webhooks': payment_state,
'telegram_webhook': telegram_state,
'remnawave_webhook': remnawave_webhook_state,
'miniapp_static': miniapp_state,
}
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.9.0"
version = "3.10.2"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+12 -3
View File
@@ -111,10 +111,19 @@ async def _post_webhook(client: TestClient, payload: dict, **headers: str) -> we
def _patch_get_db(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_get_db():
yield DummyDB()
"""Mock AsyncSessionLocal used by the webhook handler."""
from unittest.mock import MagicMock
monkeypatch.setattr('app.external.yookassa_webhook.get_db', fake_get_db)
mock_session = AsyncMock()
mock_session.commit = AsyncMock()
mock_session.rollback = AsyncMock()
mock_session.execute = AsyncMock()
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=mock_session)
ctx.__aexit__ = AsyncMock(return_value=False)
monkeypatch.setattr('app.external.yookassa_webhook.AsyncSessionLocal', lambda: ctx)
@pytest.mark.asyncio
-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