Compare commits

...

543 Commits

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

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

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

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

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

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

Also fixed the same missing cleanup in user_service.hard_delete_user.
2026-02-11 18:25:42 +03:00
Fringg b5998ea9d2 fix: use traffic topup config and add WATA 429 retry
- Cabinet API: use get_traffic_topup_packages() instead of
  get_traffic_packages() in classic mode endpoints (lines 622, 727, 2410)
  to prevent infinite free traffic exploit via initial-purchase packages
- WATA service: add retry logic for 429 rate limit responses with
  Retry-After parsing from header and response body, up to 2 retries,
  downgrade 429 from error to warning log level
2026-02-11 18:20:30 +03:00
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
Egor 6488dcfcb2 Merge pull request #2584 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.9.0
2026-02-09 23:08:05 +03:00
github-actions[bot] 9ec5f7f59e chore(main): release 3.9.0 2026-02-09 20:07:13 +00:00
Egor 0621a3febc Merge pull request #2582 from BEDOLAGA-DEV/dev
Release: remove auto-activation, Flask cleanup, production bug fixes
2026-02-09 22:42:36 +03:00
Fringg ebd6bee05e feat: allow tariff deletion with active subscriptions
Remove blocking check that prevented tariff deletion when subscriptions
exist. DB schema already supports SET NULL on tariff FK, so subscriptions
gracefully become "legacy" and users pick a new tariff on renewal.
Return affected_subscriptions count in API response.
2026-02-09 22:30:26 +03:00
Fringg 119f463c36 refactor: remove Flask, use FastAPI exclusively for all webhooks
Delete dead Flask-based PAL24 webhook server (app/external/pal24_webhook.py).
PAL24 webhooks already handled by unified FastAPI server on port 8080.

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

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

Fix: only update end_date from panel when the panel user status is ACTIVE.
For EXPIRED/DISABLED users, the panel date may be a _safe_expire_at artifact
and should not override the real expiry date in the local database.
2026-02-09 17:39:25 +03:00
Fringg efa3a5d457 refactor: remove "both" mode from BOT_RUN_MODE, keep only polling and webhook 2026-02-09 17:32:17 +03:00
Fringg 0b86f379b4 fix: nullify payment FK references before deleting transactions in user restoration
The user restoration flow deleted transactions without first clearing
foreign key references from payment tables (yookassa_payments,
cryptobot_payments, etc.) and referral_earnings. This caused
IntegrityError when a deleted user had payment records linked to
transactions.
2026-02-09 17:19:45 +03:00
Fringg 1cae7130bc fix: promo code max_uses=0 conversion and trial UX after promo activation
- Convert max_uses=0 to 999999 (unlimited) in cabinet and webapi routes,
  matching bot handler behavior. Fixes miniapp-created promo codes being
  immediately invalid due to is_valid check (current_uses < max_uses).
- Skip trial offer in post-registration keyboard when promo code already
  activated a subscription, showing "back to menu" button instead.
2026-02-09 17:13:11 +03:00
Fringg 45410168af fix: use selection.period.days instead of selection.period_days
PurchaseSelection dataclass has period: PurchasePeriodConfig (with .days),
not period_days. This caused admin notification to fail silently on every
subscription purchase from cabinet.
2026-02-09 16:45:36 +03:00
Ali Morshedzadeh 5482e609f8 Add initial Persian locale support and language handling updates 2026-02-09 16:53:50 +03:30
Fringg e79f598d17 fix: skip users with active subscriptions in admin inactive cleanup
Admin "Clear all" button was deleting inactive users regardless of
subscription status, destroying paid subscriptions. Now matches the
monitoring service behavior by checking is_active before deletion.
2026-02-09 05:53:30 +03:00
Egor 056070b6a4 Merge pull request #2578 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.8.0
2026-02-08 23:36:16 +03:00
github-actions[bot] 8b53c73ce8 chore(main): release 3.8.0 2026-02-08 20:35:58 +00:00
Egor e6ebf81752 Merge pull request #2577 from BEDOLAGA-DEV/dev
feat: admin panel enhancements & bug fixes
2026-02-08 23:35:17 +03:00
Fringg 11b8ab1959 feat: add admin updates endpoint for bot and cabinet releases
GET /cabinet/admin/updates/releases returns release history
and version info for both projects from GitHub API with caching.
2026-02-08 23:20:47 +03:00
Fringg 17e9259eb1 fix: include additional devices in tariff renewal price and display
Tariff renewal showed tariff.device_limit (default) instead of
subscription.device_limit (actual) and didn't add extra device
cost to the renewal price. Fixed in show_tariff_extend,
select_tariff_extend_period, and confirm_tariff_extend.
2026-02-08 23:01:11 +03:00
Fringg 02c30f8e7e feat: add system info endpoint for admin dashboard
Exposes bot version, Python version, uptime, total users and active
subscriptions via GET /cabinet/admin/stats/system-info.
2026-02-08 22:52:12 +03:00
Fringg 15c7cc2a58 feat: add server-side sorting for enrichment columns 2026-02-08 22:39:25 +03:00
Fringg f2dbab6171 feat: add enrichment data to CSV export
Extract _build_enrichment() helper, reuse in both GET /enrichment
endpoint and CSV export. CSV now includes: Connected Devices,
Total Spent (RUB), Sub Start, Sub End, Last Node columns.
2026-02-08 22:36:45 +03:00
Fringg 17af51ce0b fix: use correct pagination params (start/size) for bulk HWID devices
Remnawave API uses start/size (not take/skip) with default size=25.
Now fetches all devices with size=1000 per page. Remove debug logging.
2026-02-08 22:32:20 +03:00
Fringg 8f7fa76e6a fix: revert device pagination, add raw user data field discovery
Bulk device endpoint ignores take/skip params, causing duplicates.
Revert to single call. Add logging to discover extra fields in
panel user response that might include device count.
2026-02-08 22:26:06 +03:00
Fringg 4648a82da9 fix: paginate bulk device endpoint to fetch all HWID devices
The GET /api/hwid/devices endpoint returns only 25 devices by default.
Add take/skip pagination to fetch all devices across all pages.
2026-02-08 22:21:55 +03:00
Fringg 5be82f2d78 fix: add enrichment device mapping debug logs 2026-02-08 22:18:46 +03:00
Fringg 9e3aa23f69 chore: remove debug logging from enrichment endpoint 2026-02-08 22:14:35 +03:00
Fringg 46da31d89c fix: add debug logging for bulk device response structure 2026-02-08 22:11:54 +03:00
Fringg 5f219c33e6 fix: use bulk device endpoint instead of per-user calls
Replace O(users) per-user GET /api/hwid/devices/{uuid} calls
with single GET /api/hwid/devices bulk call to avoid rate limiting.
2026-02-08 22:06:15 +03:00
Fringg 94fcf20d17 fix: add email field to traffic table for OAuth/email users
Include user email in UserTrafficItem schema, search filter,
CSV export, and frontend display (shown below name when no
Telegram username exists).
2026-02-08 22:04:42 +03:00
Fringg 9d39901f78 fix: use per-user panel endpoints for reliable device counts and last node data
Replace bulk /api/hwid/devices and /api/subscriptions calls with
proven per-user endpoints: get_all_users() (paginated) for last
connected node and get_user_devices() with semaphore for device counts.
2026-02-08 22:01:32 +03:00
Fringg 5cf3f2f76e feat: add traffic usage enrichment endpoint with devices, spending, dates, last node
Add GET /admin/traffic/enrichment that returns per-user enrichment data
(connected devices, total spending, subscription dates, last connected node)
via bulk panel API calls with 5-min server-side cache.
2026-02-08 21:49:42 +03:00
Fringg 2f90f9134d feat: add admin traffic packages and device limit management
Add TrafficPurchaseItem schema, extend subscription info with traffic
purchases, add add_traffic/remove_traffic/set_device_limit actions,
extend tariff builder with device/traffic config fields.
2026-02-08 21:13:44 +03:00
Fringg c57de1081a feat: add admin device management endpoints
Add GET/DELETE endpoints for managing user devices from admin panel:
- GET /{user_id}/devices - list connected devices
- DELETE /{user_id}/devices/{hwid} - remove single device
- DELETE /{user_id}/devices - reset all devices
2026-02-08 20:49:04 +03:00
Fringg 33d5155a8d style: format schemas and remnawave_service with ruff 2026-02-08 20:39:22 +03:00
Fringg 9828ff0845 fix: read bot version from pyproject.toml when VERSION env is not set
Previously the bot only checked os.getenv('VERSION'), returning
'UNKNOW' when unset. Now falls back to importlib.metadata and
direct pyproject.toml parsing, so the version stays correct after
release-please updates it.
2026-02-08 20:38:17 +03:00
Fringg da6f746b09 feat: add endpoint for updating user referral commission percent
POST /{user_id}/referral-commission allows admins to set individual
referral commission percentage (0-100) or null for system default.
2026-02-08 20:29:53 +03:00
Fringg 165965d8ea fix: add email/UUID fallback for OAuth user panel sync
OAuth users registering via cabinet have no telegram_id, causing
panel sync failures. All RemnaWave panel lookups now use a 3-level
chain: UUID → telegram_id → email. Also pass email and user_id to
format_remnawave_username to generate unique panel usernames.
2026-02-08 19:55:34 +03:00
Egor e7e01ce9c8 Merge pull request #2576 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.2
2026-02-08 19:03:29 +03:00
github-actions[bot] c4c49571ec chore(main): release 3.7.2 2026-02-08 16:03:08 +00:00
Egor 4a63124818 Merge pull request #2575 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-08 19:02:40 +03:00
Fringg d6fa86b870 fix: remove dots from Remnawave username sanitization
Remnawave API only allows letters, numbers, underscores and dashes in
usernames. The sanitizer regex was also allowing dots, causing OAuth
users with email-based usernames (e.g. john.doe@gmail.com) to fail
subscription creation with "Validation failed: invalid_string".
2026-02-08 19:00:03 +03:00
Fringg 55d281b0e3 fix: handle FK violation in create_yookassa_payment when user is deleted
Catch IntegrityError on INSERT into yookassa_payments when user_id
references a deleted user. Rollback the session and return None instead
of letting the unhandled exception propagate. Protects all callers
(webhook restore, bot handlers, cabinet API, miniapp API).
2026-02-08 18:52:34 +03:00
Egor a42bc9b281 Merge pull request #2574 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.1
2026-02-08 18:03:31 +03:00
github-actions[bot] 5bc5567ab1 chore(main): release 3.7.1 2026-02-08 15:03:06 +00:00
Egor d88ca980ec Merge pull request #2573 from BEDOLAGA-DEV/dev
fix: release-please config — remove blocked workflow files
2026-02-08 18:02:46 +03:00
Fringg 0ef4f55304 fix: resolve merge conflict in release-please config 2026-02-08 18:02:20 +03:00
Fringg 5070bb34e8 fix: remove workflow files and pyproject.toml from release-please extra-files
GitHub Actions cannot modify .github/workflows/ files (403 "Resource not
accessible by integration"), causing "Error adding to tree" failure.
pyproject.toml is already handled natively by python release type.
Only Dockerfile needs the generic updater for x-release-please-version markers.
2026-02-08 18:00:59 +03:00
Egor 02d38d7891 Merge pull request #2572 from BEDOLAGA-DEV/dev
Release: dev → main
2026-02-08 17:55:51 +03:00
Fringg c46cc85144 style: format tariff.py with ruff 2026-02-08 17:54:07 +03:00
Fringg 071c23dd52 fix: resolve multiple production errors and performance issues
- tickets.py: guard against non-text messages in waiting_for_title FSM state
- payments.py: fix Wata webhook using wrong field name (order_id vs orderId),
  add full payload to error log
- tariff.py: stop overwriting admin tariff settings on every bot restart,
  sync_default_tariff_from_config now only creates if no tariff exists
- start.py: catch TelegramBadRequest specifically for "message is not modified"
  instead of bare except with useless retry
- admin/tickets.py: downgrade ticket notification log from error to warning
  for expected case of OAuth/email users without telegram_id
- pricing.py, countries.py, purchase.py: guard against expired FSM state
  causing KeyError on 'period_days'
- blacklist_service.py: add 5-min in-memory cache to is_user_blacklisted()
  to reduce DB load from per-request checks
- remnawave_service.py: fix "Session is closed" race condition — create
  new RemnaWaveAPI instance per get_api_client() call instead of reusing
  shared instance whose aiohttp session gets overwritten by parallel coroutines
2026-02-08 17:40:51 +03:00
Egor 5f3e426750 Merge pull request #2571 from BEDOLAGA-DEV/fix/hwid-reset-and-webhook-fk-check
fix: resolve HWID reset and webhook FK violation
2026-02-08 16:48:50 +03:00
Fringg a9eee19c95 fix: resolve HWID reset context manager bug and webhook FK violation
- Fix async context manager usage in sync_users: __aenter__() result
  was not assigned, so hwid_api_client held the context manager object
  instead of the actual API client, causing AttributeError on
  reset_user_devices()
- Add user existence check in _restore_missing_yookassa_payment before
  INSERT to prevent ForeignKeyViolationError when user_id from payment
  metadata no longer exists in users table
2026-02-08 16:48:07 +03:00
Fringg 552a8ff8d8 chore: fix release-please to auto-bump Dockerfile and workflow versions
- Switch release-please to manifest mode (config-file + manifest-file)
- Add Dockerfile and docker workflow files as generic extra-files
- Add x-release-please-version annotations for automatic version replacement
- Bump hardcoded v3.6.0 to v3.7.0 to match current release
2026-02-07 13:57:54 +03:00
Egor bec78beb25 Merge pull request #2569 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.7.0
2026-02-07 13:51:12 +03:00
github-actions[bot] a6561a4788 chore(main): release 3.7.0 2026-02-07 10:49:47 +00:00
Egor c49acc956f Merge pull request #2568 from BEDOLAGA-DEV/dev
chore: release bot updates
2026-02-07 13:49:14 +03:00
Egor 4c40b5b370 Merge pull request #2567 from BEDOLAGA-DEV/feat/traffic-filters-daterange
feat: traffic filters, date range & risk columns in CSV export
2026-02-07 13:30:34 +03:00
Fringg 7c1a142653 feat: add risk columns to traffic CSV export
- Add total_threshold_gb and node_threshold_gb to ExportCsvRequest
- Compute GB/day, risk level, risk ratio for each user when thresholds set
- CSV includes Total GB/day, Risk Level, Risk Ratio, Risk GB/day columns
2026-02-07 13:29:16 +03:00
Egor a161e2f904 Merge pull request #2566 from BEDOLAGA-DEV/feat/traffic-filters-daterange
feat: node/status filters + custom date range for traffic page
2026-02-07 11:54:54 +03:00
Fringg ad260d9fe0 feat: add node/status filters and custom date range to traffic page
- Add node filter: filter traffic by selected nodes, recalculate totals
- Add status filter: filter by subscription status (active/trial/expired/disabled)
- Add custom date range: support start_date/end_date params alongside period
- Refactor _aggregate_traffic to use date strings with stable 5-min cache keys
- Add cache eviction for expired entries to prevent memory leaks
- CSV export now respects all active filters and custom date range
- Extract _get_status helper, add _compute_date_range helper
2026-02-07 11:53:04 +03:00
Fringg 3fd3bce2cf Revert "Merge pull request #2565 from BEDOLAGA-DEV/feat/traffic-filters-devices"
This reverts commit ad6522f547, reversing
changes made to 61bb8fcafd.
2026-02-07 11:29:31 +03:00
Egor ad6522f547 Merge pull request #2565 from BEDOLAGA-DEV/feat/traffic-filters-devices
feat: add node/status filters, date range, devices to traffic page
2026-02-07 11:21:41 +03:00
Fringg 9ea533a864 feat: add node/status filters, custom date range, connected devices to traffic page
- Add node filter (comma-separated UUIDs) and status filter query params
- Add custom date range (start_date/end_date) as alternative to period
- Fetch connected device count per user via HWID API (semaphore=10)
- Cache key changed to (start_str, end_str) tuple for both modes
- CSV export now respects all active filters and date range
- Backend returns available_statuses and filtered nodes list
- Validate future dates, max 31-day range
2026-02-07 11:19:45 +03:00
Egor 61bb8fcafd Merge pull request #2564 from BEDOLAGA-DEV/fix/yookassa-cabinet-payment-db-record
fix: use PaymentService for cabinet YooKassa payments
2026-02-07 10:36:12 +03:00
Fringg ff5bba3fc5 fix: use PaymentService for cabinet YooKassa payments to save local DB record
Cabinet was calling YooKassaService.create_payment() directly, bypassing
PaymentService which saves the payment record to the local database.
When YooKassa webhook arrived, the payment was not found in the DB,
causing payment processing failures.

Now uses PaymentService.create_yookassa_payment() and
create_yookassa_sbp_payment() consistently with all other payment methods.
Also standardizes metadata key from 'type' to 'purpose' to match bot flow.
2026-02-07 10:35:08 +03:00
Egor cc1c8bacb4 Merge pull request #2563 from BEDOLAGA-DEV/fix/traffic-legacy-endpoint
fix: use legacy per-node endpoint for traffic aggregation
2026-02-07 10:06:22 +03:00
Fringg b707b7995b fix: use legacy per-node endpoint with correct response format 2026-02-07 10:05:49 +03:00
Egor a076dfb550 Merge pull request #2562 from BEDOLAGA-DEV/fix/traffic-node-users-parsing
fix: correct response parsing for non-legacy node-users endpoint
2026-02-07 10:01:07 +03:00
Fringg 91ac90c2ae fix: correct response parsing for non-legacy node-users endpoint 2026-02-07 10:00:29 +03:00
Egor b12544d3ea Merge pull request #2561 from BEDOLAGA-DEV/fix/traffic-429-rate-limit
fix: resolve 429 rate limiting on traffic page
2026-02-07 09:49:21 +03:00
Fringg 38018514dc style: apply ruff formatting 2026-02-07 09:48:54 +03:00
Fringg 924d6bc09c fix: resolve 429 rate limiting on traffic page
- Switch from per-user to per-node API strategy in _aggregate_traffic
  (O(nodes) calls instead of O(users), ~10 vs ~200 requests)
- Add retry with exponential backoff for 429 in _make_request
- Reduce concurrency limit from 20 to 5 to prevent request bursts
2026-02-07 09:46:59 +03:00
Egor 1021c2cdcd Merge pull request #2560 from BEDOLAGA-DEV/feat/traffic-tariff-filter
feat: tariff filter + fix traffic data aggregation
2026-02-07 09:32:15 +03:00
Fringg fa01819674 feat: add tariff filter, fix traffic data aggregation
- Switch from get_bandwidth_stats_node_users (broken UUID matching) to
  get_bandwidth_stats_user per user (same API as working detail page)
- Add tariff filter with available_tariffs in response
- Add concurrency-limited parallel per-user bandwidth stats fetching
2026-02-07 09:31:47 +03:00
Egor eeed2d6369 Merge pull request #2559 from BEDOLAGA-DEV/fix/traffic-sort-type-error
fix: handle mixed types in traffic sort
2026-02-07 09:14:30 +03:00
Fringg a194be0843 fix: handle mixed types in traffic sort for string fields
Sort by tariff_name/full_name crashed with TypeError when some values
were None (fallback to 0) mixed with strings. Use empty string fallback
for string fields with case-insensitive comparison.
2026-02-07 09:13:57 +03:00
Egor aa1cd3829c Merge pull request #2558 from BEDOLAGA-DEV/feat/admin-traffic-usage
feat: add admin traffic usage API
2026-02-07 09:06:06 +03:00
Fringg 6c2c25d2cc feat: add admin traffic usage API with per-node statistics
Add paginated GET /admin/traffic endpoint aggregating per-user traffic
across all nodes with server-side sorting, search, and 5-min in-memory
cache. Add POST /admin/traffic/export-csv to generate CSV and send
to admin via Telegram DM.
2026-02-07 09:04:52 +03:00
Egor 0b61c7fe48 Merge pull request #2557 from BEDOLAGA-DEV/fix/version-notification-html-tags
fix: close unclosed HTML tags in version notification
2026-02-07 08:21:50 +03:00
Fringg b6745508da fix: close unclosed HTML tags when truncating version notification
Telegram API rejects messages with mismatched HTML tags. When
truncate_for_blockquote cuts the description mid-way, it can leave
tags like <i>, <b> unclosed inside the blockquote. Telegram then
fails with "Unmatched end tag" error.

Add _close_open_tags helper that scans for unclosed tags and appends
closing tags in reverse order. Also ensure the total length with
closing tags still fits within the message budget.
2026-02-07 08:18:39 +03:00
Egor f5391c3159 Merge pull request #2556 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.6.0
2026-02-07 07:24:28 +03:00
github-actions[bot] 9a81932d2b chore(main): release 3.6.0 2026-02-07 04:23:45 +00:00
Egor 8b50fde9aa Merge pull request #2555 from BEDOLAGA-DEV/dev
chore: sync dev → main (v3.6.0)
2026-02-07 07:23:01 +03:00
Fringg 8b924df64f chore: bump version to 3.6.0 in Dockerfile and workflows 2026-02-07 07:15:15 +03:00
Egor 7102c50f52 Merge pull request #2554 from BEDOLAGA-DEV/feat/node-usage-30day-cache
feat: return 30-day daily breakdown for node usage
2026-02-07 06:51:04 +03:00
Fringg e4c65ca220 feat: return 30-day daily breakdown for node usage
Always fetch 30 days with daily_bytes per node and categories.
Frontend computes period totals locally without extra API calls.
Removes days query param.
2026-02-07 06:50:47 +03:00
Egor 557dbf3ebe Merge pull request #2553 from BEDOLAGA-DEV/fix/parse-bandwidth-series
fix: parse bandwidth stats series format for node usage
2026-02-07 06:42:08 +03:00
Fringg 462f7a99b9 fix: parse bandwidth stats series format for node usage
Response is {categories, series: [{uuid, name, countryCode, total}]}.
Parse series array instead of treating dict keys as node UUIDs.
2026-02-07 06:42:03 +03:00
Egor c68c4e5984 Merge pull request #2552 from BEDOLAGA-DEV/fix/node-usage-single-api-call
fix: reduce node usage to 2 API calls to avoid 429 rate limit
2026-02-07 06:37:18 +03:00
Fringg f00a051bb3 fix: reduce node usage to 2 API calls to avoid 429 rate limit
Per-node queries (8+ calls) hit Remnawave rate limit. Switch back to
single get_bandwidth_stats_user call with %Y-%m-%d date format (same
as traffic_monitoring_service). Add response logging to debug format.
Also optimize panel-info to use accessible-nodes instead of all-nodes.
2026-02-07 06:36:38 +03:00
Egor b94e3edf80 Merge pull request #2551 from BEDOLAGA-DEV/fix/node-usage-per-node-query
fix: query per-node legacy endpoint for user traffic breakdown
2026-02-07 06:30:10 +03:00
Fringg 51ca3e42b7 fix: query per-node legacy endpoint for user traffic breakdown
The /api/bandwidth-stats/users/{uuid} endpoint rejects date params.
Switch to querying each accessible node via the working legacy
endpoint /api/bandwidth-stats/nodes/{uuid}/users/legacy and finding
the user in the per-node results.
2026-02-07 06:29:44 +03:00
Egor 943e9a86aa Merge pull request #2550 from BEDOLAGA-DEV/fix/node-usage-accessible-nodes
fix: use accessible nodes API and fix date format for node usage
2026-02-07 06:22:45 +03:00
Fringg c4da591731 fix: use accessible nodes API and fix date format for node usage
- Add get_user_accessible_nodes() to fetch user's available nodes
- Fix date format from ISO datetime to date-only (Y-m-d) for bandwidth stats
- Show all accessible nodes (with zero traffic if no stats)
- Add country_code to node usage response
2026-02-07 06:22:07 +03:00
Egor 287a43ba65 Merge pull request #2549 from BEDOLAGA-DEV/feature/admin-user-detail-enhanced
feat: add panel info, node usage endpoints and campaign to user detail
2026-02-07 06:09:13 +03:00
Fringg 070321230b feat: add panel info, node usage endpoints and campaign to user detail
- Add campaign_name/campaign_id to UserDetailResponse
- Add GET /admin/users/{user_id}/panel-info endpoint (config, links, traffic, connection)
- Add GET /admin/users/{user_id}/node-usage endpoint (per-node traffic breakdown)
- Add UserPanelInfoResponse, UserNodeUsageItem, UserNodeUsageResponse schemas
2026-02-07 06:07:10 +03:00
Egor 8886d0dea2 Merge pull request #2548 from BEDOLAGA-DEV/feat/user-tickets-tab
feat: add user_id filter to admin tickets endpoint
2026-02-07 05:22:20 +03:00
Fringg d3819c492f feat: add user_id filter to admin tickets endpoint
Allow filtering tickets by user_id query parameter in GET /admin/tickets.
2026-02-07 05:21:22 +03:00
Egor 3cbb9ef024 Merge pull request #2546 from BEDOLAGA-DEV/feature/oauth-authorization
feat: OAuth 2.0 authorization (Google, Yandex, Discord, VK)
2026-02-07 02:37:46 +03:00
Fringg 41633af763 refactor: fix transaction boundaries, extract _finalize_oauth_login, replace deprecated datetime.utcnow 2026-02-07 02:35:55 +03:00
DenyaBanan 916ad9d567 Fix 401 error
If there is a token, the bot checks it anyway, and cannot connect to the remnawave panel.
2026-02-07 03:33:16 +04:00
Fringg ccd9ab02c5 refactor: remove duplicated helpers, import from auth.py 2026-02-07 02:31:56 +03:00
Fringg d0a9cfe6a9 refactor: replace dataclass with BaseModel for OAuthUserInfo 2026-02-07 02:29:01 +03:00
Fringg 333a3c5901 fix: increase OAuth HTTP timeout to 30s 2026-02-07 02:23:02 +03:00
Fringg 0de6418bca refactor: add strict typing to OAuth providers, replace urlencode with httpx params 2026-02-07 02:14:37 +03:00
Fringg e9b98b837a feat: migrate OAuth state storage from in-memory to Redis 2026-02-07 02:08:02 +03:00
Fringg 97be4afbff feat: add OAuth 2.0 authorization (Google, Yandex, Discord, VK)
- Add OAuth provider config vars and helpers to config.py
- Add google_id, yandex_id, discord_id, vk_id columns to User model
- Create OAuth provider service with state management and 4 providers
- Add CRUD functions for OAuth user lookup, linking, and creation
- Add 3 API endpoints: providers list, authorize URL, callback
- Add alembic migration and universal_migration support
- Fix trial disable logic to cover OAuth auth_types
2026-02-07 01:58:55 +03:00
Egor 9ca24efe43 Merge pull request #2545 from BEDOLAGA-DEV/feature/disposable-email-blocking
feat: block registration with disposable email addresses
2026-02-07 00:36:37 +03:00
Fringg 116c8453bb feat: block registration with disposable email addresses
Add DisposableEmailService that fetches ~72k disposable email domains
from github.com/disposable/disposable-email-domains into an in-memory
frozenset with 24h auto-refresh via asyncio background task.

Integrated into three email entry points in cabinet auth routes:
- POST /email/register (link email to Telegram account)
- POST /email/register/standalone (standalone email registration)
- POST /email/change (change existing email)

Controlled by DISPOSABLE_EMAIL_CHECK_ENABLED setting (default: true).
Falls back to allowing all emails if domain list fetch fails.
2026-02-07 00:34:11 +03:00
Egor 4e7438b9f9 Merge pull request #2544 from BEDOLAGA-DEV/feature/trial-disabled-for-user-type
feat: disable trial by user type (email/telegram/all)
2026-02-07 00:20:38 +03:00
Fringg c4794db1dd feat: add TRIAL_DISABLED_FOR setting to disable trial by user type
New setting allows granular control over trial availability:
- none: trial available for all (default)
- email: trial disabled for email users
- telegram: trial disabled for telegram users
- all: trial disabled for everyone

Enforced in bot handlers, cabinet API, and miniapp routes.
Automatically appears in admin panel as dropdown via CHOICES.
2026-02-07 00:19:25 +03:00
Fringg 1ffb8a5b85 fix: pass tariff object instead of tariff_id to set_tariff_promo_groups 2026-02-07 00:01:55 +03:00
Egor 7ab1a7b88d Merge pull request #2543 from BEDOLAGA-DEV/dev
chore: sync dev → main (v3.5.0)
2026-02-06 23:57:09 +03:00
Fringg e3f932afe4 chore: bump version to 3.5.0 in Dockerfile and workflows 2026-02-06 23:55:36 +03:00
Egor 5ca2f62854 Merge pull request #2542 from BEDOLAGA-DEV/main
chore: sync main → dev
2026-02-06 23:48:19 +03:00
c0mrade 8afe613451 Merge pull request #2541 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.5.0
2026-02-06 23:44:40 +03:00
github-actions[bot] 8de9c6e532 chore(main): release 3.5.0 2026-02-06 20:42:00 +00:00
Egor b69fcbde11 Merge pull request #2540 from BEDOLAGA-DEV/dev
Release 3.4.1
2026-02-06 23:33:38 +03:00
Fringg 44d6b6b266 chore: bump version to 3.4.1 2026-02-06 23:31:46 +03:00
c0mrade 4234769e92 revert: remove signature pop from HMAC validation
Telegram includes signature in the hash computation, so removing it
from the data-check-string breaks HMAC validation for all users.
2026-02-06 22:27:57 +03:00
c0mrade c2cabbee09 fix: restore unquote for user data parsing in telegram auth
parse_qsl does not fully decode nested URL-encoded JSON in the user
field, so unquote() is still needed before json.loads().
2026-02-06 22:13:32 +03:00
c0mrade 067b1b6716 chore: remove unused unquote import 2026-02-06 21:55:45 +03:00
c0mrade 5b64046137 fix: exclude signature field from Telegram initData HMAC validation
Telegram Bot API 8.0+ adds a `signature` field to WebApp initData.
Per the official spec, both `hash` and `signature` must be excluded
from the data-check-string before HMAC verification. Without this,
users with newer Telegram clients get a hash mismatch and 401.

Also remove redundant `unquote()` in telegram_auth.py — `parse_qsl`
already URL-decodes values, so the extra decode could corrupt user
data containing percent-like sequences.
2026-02-06 21:51:38 +03:00
c0mrade 085a61721a Merge pull request #2538 from BEDOLAGA-DEV/feat/tariff-sorting-dnd
feat: tariff reorder API endpoint
2026-02-06 17:45:27 +03:00
Fringg 4c2e11e64b feat: add tariff reorder API endpoint
Add PUT /cabinet/admin/tariffs/order endpoint for drag-and-drop
tariff sorting in admin cabinet. Move db.commit() from CRUD to
route level for consistency.
2026-02-06 17:42:01 +03:00
c0mrade 7c5f35b1cf Merge pull request #2539 from BEDOLAGA-DEV/feat/remnawave-original-config-format
Feat/remnawave original config format
2026-02-06 17:35:13 +03:00
Egor 561708b777 Merge pull request #2537 from BEDOLAGA-DEV/fix/blacklist-middleware
fix: enforce blacklist via middleware
2026-02-06 15:54:01 +03:00
Fringg 806a959662 style: format blacklist middleware 2026-02-06 15:52:19 +03:00
Fringg 966a599c2c fix: enforce blacklist via middleware instead of per-handler checks
Add BlacklistMiddleware for aiogram that blocks all message/callback/pre_checkout
from blacklisted users globally. Add blacklist check to cabinet API dependency.
Fix case-insensitive username matching. Remove 10 redundant manual checks from handlers.
2026-02-06 15:48:21 +03:00
c0mrade 0ed98c39b6 fix: improve button URL resolution and pass uiConfig to frontend
- Add {{HAPP_CRYPT3_LINK}} template support in _resolve_button_url
- Only resolve templates for subscriptionLink and copyButton, not external
- Always send subscriptionUrl and subscriptionCryptoLink (hideLink is display-only flag)
- Pass uiConfig from RemnaWave config for block renderer selection
2026-02-05 20:08:47 +03:00
c0mrade 095bc00b33 feat: pass platform-level fields from RemnaWave config to frontend
Preserve svgIconKey, displayName and other platform-level fields
instead of only forwarding apps array. Build platformNames from
RemnaWave displayName with English-only fallback.
2026-02-05 14:27:46 +03:00
c0mrade 43762ce8f4 feat: serve original RemnaWave config from app-config endpoint
- Return original blocks/svgLibrary instead of converting to steps
- Enrich apps with deepLink and buttons with resolvedUrl
- Add _resolve_button_url helper for template substitution
- Keep legacy file-based format as fallback
2026-02-05 08:29:57 +03:00
Egor 51752713b3 Merge pull request #2536 from BEDOLAGA-DEV/dev
Release v3.4.0
2026-02-05 07:49:30 +03:00
Fringg b6fc63e33c chore: bump version to 3.4.0
Update version references across all files:
- pyproject.toml
- Dockerfile
- docker-hub.yml
- docker-registry.yml
- .release-please-manifest.json
2026-02-05 07:49:02 +03:00
Egor 488d5c99f7 Merge pull request #2535 from BEDOLAGA-DEV/feat/release-workflows
feat(ci): add release-please and release workflows
2026-02-05 07:42:50 +03:00
Fringg 9151882245 feat(ci): add release-please and release workflows
- Add release-please workflow for automated changelog and version bumps
- Add release workflow with categorized changelog (features, fixes, perf)
- Include contributors section and diff stats in release notes
- Add Docker pull instructions in release body
- Configure changelog sections for conventional commits
2026-02-05 07:37:46 +03:00
Egor 02eca28bc0 Merge pull request #2534 from BEDOLAGA-DEV/feat/version-notification-redesign
feat(notifications): redesign version update notification
2026-02-05 07:32:46 +03:00
Fringg 3f7ca7be3a feat(notifications): redesign version update notification
- Add GitHub Markdown to Telegram HTML converter utility
- Place release description in blockquote expandable
- Auto-truncate description to fit 4096 char message limit
- Clean compact layout with clickable version link
- Convert markdown headers, bold, italic, code, links, strikethrough
2026-02-05 07:29:55 +03:00
Egor f7abe03dba Merge pull request #2533 from BEDOLAGA-DEV/fix/autopay-notification-cooldown
fix(autopay): add 6h cooldown for insufficient balance notifications
2026-02-05 07:18:51 +03:00
Fringg 992a5cb97f fix(autopay): add 6h cooldown for insufficient balance notifications
- Use Redis key with 6h TTL to prevent notification spam on each monitoring cycle
- Fallback to sending notification if Redis is unavailable
- Key auto-expires when user tops up balance and autopay succeeds
2026-02-05 07:17:25 +03:00
Egor 3d94e63c3c Merge pull request #2532 from BEDOLAGA-DEV/fix/daily-tariff-autopay
fix(autopay): exclude daily subscriptions from global autopay
2026-02-05 07:12:03 +03:00
Egor 79569510d2 Merge pull request #2531 from BEDOLAGA-DEV/fix/broadcast-stability
fix(broadcast): stabilize mass broadcast for 100k+ users
2026-02-05 07:12:01 +03:00
Fringg b9352a5bd5 fix(autopay): exclude daily subscriptions from global autopay
- Skip daily tariff subscriptions in monitoring autopay cycle
- Filter daily subscriptions in get_subscriptions_for_autopay CRUD
- Block autopay menu and toggle for daily tariffs in bot handler
- Reject autopay enable for daily subscriptions in Cabinet API (HTTP 400)
- Reject autopay enable for daily subscriptions in MiniApp API (HTTP 400)
2026-02-05 07:10:52 +03:00
Fringg 13ebfdb5c4 fix(broadcast): stabilize mass broadcast for 100k+ users
- Add real-time progress bar with updates every 500 msgs / 5 sec
- Fix Telegram rate limiting: batch=25, delay=1.0s (~25 msg/sec)
- Add global flood_wait_until to prevent semaphore slot starvation
- Add parse_mode=HTML for web API broadcasts
- Separate error handling for FloodWait, Forbidden, BadRequest
- Convert ORM objects to scalars before long broadcast operations
- Add email recipients dataclass to prevent detached ORM state
2026-02-05 07:10:43 +03:00
Egor e8a413c3c3 Merge pull request #2530 from BEDOLAGA-DEV/fix/cabinet-promo-discounts
fix(cabinet): apply promo group discounts to addons and tariff switch
2026-02-05 06:30:00 +03:00
Fringg aa1d3289e1 fix(cabinet): apply promo group discounts to device/traffic purchase and tariff switch
- Add discount calculation for purchase_devices and get_device_price endpoints
- Fix traffic purchase discount to use period-aware calculation
- Apply period discount to tariff switch upgrade_cost
- Return discount info in API responses for frontend display
2026-02-05 06:26:17 +03:00
Egor 94a00ab269 Merge pull request #2529 from BEDOLAGA-DEV/fix/sqlalchemy-connection-closed
fix(broadcast): resolve SQLAlchemy connection closed errors
2026-02-05 05:49:11 +03:00
Fringg b8682adbbf fix(broadcast): resolve SQLAlchemy connection closed errors during long broadcasts
- Extract scalar values from ORM objects before long operations
- Create fresh DB sessions for persist operations with retry mechanism
- Replace ORM User objects with telegram_id integers in broadcast loops
- Update .gitignore to exclude Python cache, IDE files, and local configs

Fixes: InterfaceError "connection is closed" and MissingGreenlet errors
during mass message broadcasts
2026-02-05 05:42:31 +03:00
Egor cf10eeda53 Merge pull request #2528 from BEDOLAGA-DEV/main
Update docker-registry.yml
2026-02-05 05:04:32 +03:00
Egor 3cfac7e2dc Update docker-registry.yml 2026-02-05 05:04:00 +03:00
Egor 39e111c91b Merge pull request #2527 from BEDOLAGA-DEV/main
W
2026-02-05 04:55:04 +03:00
Egor ba42517808 Update README.md 2026-02-05 00:25:48 +03:00
Egor 13846d621a Update README.md 2026-02-05 00:25:07 +03:00
Egor e612e2f383 Merge pull request #2526 from BEDOLAGA-DEV/dev
Dev
2026-02-04 05:02:03 +03:00
Egor 1f524ccd80 Add files via upload 2026-02-04 04:51:35 +03:00
Egor 37bde2985e Add files via upload 2026-02-04 04:51:12 +03:00
Egor c6a5e0d4be Add files via upload 2026-02-04 04:50:38 +03:00
Egor 3985053636 Add files via upload 2026-02-04 04:49:37 +03:00
Egor 4cfb1dd38f Add files via upload 2026-02-04 04:49:14 +03:00
Egor 117a417ce0 Add files via upload 2026-02-04 04:48:49 +03:00
Egor a2e0474572 Add files via upload 2026-02-04 04:48:29 +03:00
Egor e992891691 Add files via upload 2026-02-04 04:48:01 +03:00
Egor 57cf8687d4 Add files via upload 2026-02-04 04:47:39 +03:00
Egor 27870bbdcb Update main.py 2026-02-04 04:47:24 +03:00
Egor 21d48078ed Merge pull request #2525 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-04 03:58:14 +03:00
Egor afb4f162d0 Update admin_notification_service.py 2026-02-04 03:55:59 +03:00
Egor 96d479780f Merge pull request #2524 from BEDOLAGA-DEV/dev
Update admin_promo_offers.py
2026-02-04 03:23:53 +03:00
Egor 2e0cd5d54c Update admin_promo_offers.py 2026-02-04 03:23:35 +03:00
Egor c4374ce483 Merge pull request #2523 from BEDOLAGA-DEV/dev
Dev
2026-02-04 03:05:46 +03:00
Egor bd1a0d4a4e Update wata.py 2026-02-04 03:05:22 +03:00
Egor 97f4cc0f7c Update subscription.py 2026-02-04 02:57:41 +03:00
Egor 3ebbb42096 Update inline.py 2026-02-04 02:57:12 +03:00
Egor 07ae7c2a7f Update traffic.py 2026-02-04 02:56:47 +03:00
Egor 5c3505aec9 Merge pull request #2522 from BEDOLAGA-DEV/dev
Update menu.py
2026-02-04 02:16:23 +03:00
Egor fffa231b7e Update menu.py 2026-02-04 02:15:45 +03:00
Egor f8db099d0f Merge pull request #2521 from BEDOLAGA-DEV/dev
Dev
2026-02-04 02:10:58 +03:00
Egor 9483517258 Add files via upload 2026-02-04 02:08:18 +03:00
Egor 0c0ab58236 Update promocode.py 2026-02-04 02:07:27 +03:00
Egor 92ec1219fa Add files via upload 2026-02-04 02:06:47 +03:00
Egor bf72e81d55 Update promocode_service.py 2026-02-04 02:06:13 +03:00
Egor 5a008e59a2 Merge pull request #2520 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:57:22 +03:00
Egor 61b8d586d3 Update subscription.py 2026-02-03 23:56:55 +03:00
Egor df62e2bd96 Merge pull request #2519 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:45:08 +03:00
Egor 07e8990ddb Update subscription.py 2026-02-03 23:44:15 +03:00
Egor 40fc4d7267 Merge pull request #2518 from BEDOLAGA-DEV/dev
Dev
2026-02-03 04:38:57 +03:00
Egor 5ffd3093ea Update users.py 2026-02-03 04:38:01 +03:00
Egor 01ac2c7ed0 Update admin_users.py 2026-02-03 04:37:25 +03:00
Egor 937a25aafd Update pyproject.toml 2026-02-03 04:29:27 +03:00
Egor aeee018a53 Merge pull request #2517 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 04:14:40 +03:00
Egor 5cef11f32b Update subscription.py 2026-02-03 04:14:15 +03:00
Egor 467f67907f Merge pull request #2516 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:58:59 +03:00
Egor 6d38531f42 Add files via upload 2026-02-03 03:58:31 +03:00
Egor 2dd057a911 Update subscription.py 2026-02-03 03:56:27 +03:00
Egor 4178e4b024 Add files via upload 2026-02-03 03:53:18 +03:00
Egor 21bcde26e5 Add files via upload 2026-02-03 03:52:56 +03:00
Egor 878606d745 Add files via upload 2026-02-03 03:52:29 +03:00
Egor 45a90876da Add files via upload 2026-02-03 03:52:08 +03:00
Egor bb4f496b21 Add files via upload 2026-02-03 03:50:33 +03:00
Egor 47c1de1cc8 Add files via upload 2026-02-03 03:50:05 +03:00
Egor f69156d7ee Update subscription.py 2026-02-03 03:43:51 +03:00
Egor c9d559e3f2 Update subscription.py 2026-02-03 03:41:18 +03:00
Egor e28a48853d Add files via upload 2026-02-03 03:40:08 +03:00
Egor b13da1f2e8 Add files via upload 2026-02-03 03:39:45 +03:00
Egor ba1bf677d6 Add files via upload 2026-02-03 03:39:21 +03:00
Egor ccdff05dca Add files via upload 2026-02-03 03:38:51 +03:00
Egor 03875b593e Add files via upload 2026-02-03 03:38:14 +03:00
Egor 06224d798d Add files via upload 2026-02-03 03:37:43 +03:00
Egor 966f436723 Merge pull request #2514 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:32:10 +03:00
Egor 4941fe9469 Update cloudpayments.py 2026-02-03 03:31:48 +03:00
Egor fe56078481 Merge pull request #2513 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:28:13 +03:00
Egor 39742499b8 Update cloudpayments.py 2026-02-03 03:27:53 +03:00
Egor d8207fa1f0 Merge pull request #2512 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:23:48 +03:00
Egor 7eb302aab0 Update cloudpayments.py 2026-02-03 03:23:23 +03:00
Egor 8cb5da4f74 Merge pull request #2511 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:17:18 +03:00
Egor cad786f6ee Add files via upload 2026-02-03 03:15:34 +03:00
Egor 0adc7145a1 Update user_service.py 2026-02-03 03:14:39 +03:00
Egor d35f54a4db Merge pull request #2510 from BEDOLAGA-DEV/main
w
2026-02-03 03:14:01 +03:00
c0mrade 000d670869 Merge pull request #2506 from BEDOLAGA-DEV/fix/ticket-settings-route-order
fix: move /settings routes before /{ticket_id} to fix route matching
2026-02-02 09:01:30 +03:00
c0mrade 0c9b69deb0 fix: move /settings routes before /{ticket_id} to fix route matching
Static routes must be defined before dynamic routes in FastAPI.
Previously /settings was matched as ticket_id parameter causing parsing error.
2026-02-02 08:58:07 +03:00
Egor 63e31e84fc Merge pull request #2505 from BEDOLAGA-DEV/dev
Dev
2026-02-02 05:27:02 +03:00
Egor 1bd301f21a Add files via upload 2026-02-02 05:26:41 +03:00
Egor 3dbaf99733 Update inline.py 2026-02-02 05:26:09 +03:00
Egor 4380611bee Merge pull request #2504 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 05:18:52 +03:00
Egor 5aca466d72 Update subscription.py 2026-02-02 05:18:11 +03:00
Egor 86970f0398 Update subscription.py 2026-02-02 05:16:58 +03:00
c0mrade 733be09658 Merge pull request #2503 from BEDOLAGA-DEV/fix/promo-groups-async
fix: add refresh before assigning promo_groups to avoid async lazy lo…
2026-02-02 04:58:00 +03:00
c0mrade 5e75210c8b fix: add refresh before assigning promo_groups to avoid async lazy load error 2026-02-02 04:55:27 +03:00
Egor cb0ccc9c77 Merge pull request #2502 from BEDOLAGA-DEV/dev
Dev
2026-02-02 03:37:47 +03:00
Egor 078eebfbb1 Update tariffs.py 2026-02-02 03:37:21 +03:00
Egor 4049e0d9ff Update decorators.py 2026-02-02 03:36:38 +03:00
Egor 7bf7b942ba Merge pull request #2501 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:19:08 +03:00
Egor 4f1c14fda0 Update blocked_users_service.py 2026-02-02 03:18:46 +03:00
Egor 7bd6ae3c26 Merge pull request #2500 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:10:50 +03:00
Egor caeafc7abd Update blocked_users_service.py 2026-02-02 03:10:27 +03:00
Egor 4d249a3bc9 Merge pull request #2499 from BEDOLAGA-DEV/dev
Update blocked_users.py
2026-02-02 03:02:20 +03:00
Egor 1f26b522b4 Update blocked_users.py 2026-02-02 03:01:56 +03:00
Egor d1ed6c1b18 Merge pull request #2498 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:59:40 +03:00
Egor d4d89ec20d Update blocked_users.py 2026-02-02 02:59:17 +03:00
Egor 6a2fd5a7de Add files via upload 2026-02-02 02:58:22 +03:00
Egor f41c5a10b6 Update blocked_users.py 2026-02-02 02:57:29 +03:00
Egor 3e873a05a4 Update blocked_users_service.py 2026-02-02 02:56:58 +03:00
Egor c22f41cbf2 Merge pull request #2497 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 02:53:39 +03:00
Egor f3851b9ecc Update blocked_users_service.py 2026-02-02 02:53:10 +03:00
Egor 9a258744f2 Merge pull request #2496 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:50:14 +03:00
Egor 8780ae9407 Update blocked_users_service.py 2026-02-02 02:49:23 +03:00
Egor 3f886df44f Update blocked_users.py 2026-02-02 02:48:57 +03:00
Egor e3c6d4a5a1 Add files via upload 2026-02-02 02:47:49 +03:00
Egor c3215a1e54 Update bot.py 2026-02-02 02:47:00 +03:00
Egor fdd5a8aa6e Update admin.py 2026-02-02 02:46:39 +03:00
Egor c1792f487a Add files via upload 2026-02-02 02:46:03 +03:00
Egor f27f0f8bca Merge pull request #2495 from BEDOLAGA-DEV/dev
Update startup_notification_service.py
2026-02-02 02:21:02 +03:00
Egor c70ddfe157 Update startup_notification_service.py 2026-02-02 02:20:41 +03:00
Egor 554d776b77 Merge pull request #2494 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:08:12 +03:00
Egor 34ac9eb6ed Update global_error.py 2026-02-02 02:05:50 +03:00
Egor 6cc48d2872 Update startup_notification_service.py 2026-02-02 02:05:28 +03:00
Egor 9fc31c25b2 Update startup_notification_service.py 2026-02-02 02:00:01 +03:00
Egor 73585ebc82 Update global_error.py 2026-02-02 01:59:25 +03:00
Egor e9d3e9a2be Merge pull request #2493 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:43:28 +03:00
Egor 3e6e2f577c Update global_error.py 2026-02-02 01:43:05 +03:00
Egor 39c644b505 Merge pull request #2492 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:36:39 +03:00
Egor 62500a6369 Update startup_notification_service.py 2026-02-02 01:34:40 +03:00
Egor 9e16b56d9a Update global_error.py 2026-02-02 01:34:06 +03:00
Egor 9dfadcda72 Merge pull request #2491 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:19:08 +03:00
Egor e2afdc28f3 Update global_error.py 2026-02-02 01:18:28 +03:00
Egor 86bd24edd4 Merge pull request #2490 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:07:29 +03:00
Egor 2bda556c4b Update main.py 2026-02-02 01:07:06 +03:00
Egor 60724a0354 Update startup_notification_service.py 2026-02-02 01:05:30 +03:00
Egor 250edec20e Merge pull request #2489 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:56:21 +03:00
Egor 6b1e78f990 Update maintenance_service.py 2026-02-02 00:55:56 +03:00
Egor 56f784c8bf Update maintenance_service.py 2026-02-02 00:54:10 +03:00
Egor ce822ead2b Update maintenance_service.py 2026-02-02 00:51:13 +03:00
Egor e606c1d4d5 Update startup_notification_service.py 2026-02-02 00:50:51 +03:00
Egor c204194b8b Update main.py 2026-02-02 00:50:01 +03:00
Egor 5a878239f3 Add files via upload 2026-02-02 00:49:29 +03:00
Egor 08aa8dabfd Merge pull request #2488 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 00:34:18 +03:00
Egor 20ed6071e2 Update subscription.py 2026-02-02 00:33:59 +03:00
Egor 8d34a4b3d2 Merge pull request #2487 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:28:21 +03:00
Egor 94a9528397 Update subscription.py 2026-02-02 00:27:51 +03:00
Egor 403052a840 Update tariff_purchase.py 2026-02-02 00:27:21 +03:00
Egor bb8e5bb6ca Update admin_notification_service.py 2026-02-02 00:26:53 +03:00
Egor 11ee8764a6 Merge pull request #2486 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-02 00:18:02 +03:00
Egor f9be0e6315 Update admin_notification_service.py 2026-02-02 00:17:20 +03:00
Egor 9e56c56528 Update admin_notification_service.py 2026-02-02 00:11:28 +03:00
Egor 85cf96b813 Merge pull request #2485 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:34:17 +03:00
Egor c16eee4ef2 Add files via upload 2026-02-01 18:33:12 +03:00
Egor b6bd2625c2 Update subscription.py 2026-02-01 18:32:29 +03:00
Egor 020343cdf8 Merge pull request #2484 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:11:23 +03:00
Egor c07fffd809 Update tariff_purchase.py 2026-02-01 18:10:59 +03:00
Egor afaeeaf7f1 Update subscription.py 2026-02-01 18:10:22 +03:00
Egor e946cc7354 Merge pull request #2483 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:28:42 +03:00
Egor bf6a966668 Update subscription.py 2026-02-01 17:28:21 +03:00
Egor 7f12fa7003 Update devices.py 2026-02-01 17:27:44 +03:00
Egor 5f6ef5993c Merge pull request #2482 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:22:07 +03:00
Egor f1ac67e511 Update devices.py 2026-02-01 17:18:00 +03:00
Egor 48f9f606aa Update subscription.py 2026-02-01 17:17:02 +03:00
Egor 7d9d1b0a6f Update inline.py 2026-02-01 17:16:28 +03:00
Egor afadf7160c Merge pull request #2481 from BEDOLAGA-DEV/main
w
2026-02-01 16:50:38 +03:00
Egor b546fbd1cc Merge pull request #2452 from Gy9vin/main
реф система и другое
2026-02-01 16:45:33 +03:00
Egor b8f1785783 Delete migrations/.DS_Store 2026-02-01 16:44:15 +03:00
gy9vin 7ee8c8ff4d Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 14:41:56 +03:00
gy9vin 551112d2d9 make fix 2026-02-01 14:41:45 +03:00
Mikhail e9dc9630b2 Merge branch 'BEDOLAGA-DEV:main' into main 2026-02-01 14:38:39 +03:00
Egor b068c1fb12 Merge pull request #2480 from BEDOLAGA-DEV/dev
Dev
2026-02-01 12:34:47 +03:00
Egor 9f66e176f7 Add files via upload 2026-02-01 12:34:24 +03:00
Egor c66bad99f0 Update devices.py 2026-02-01 12:33:57 +03:00
Egor f6a29760a9 Merge pull request #2479 from BEDOLAGA-DEV/dev
Dev
2026-02-01 11:29:14 +03:00
Egor d7e1b8fd5d Update user.py 2026-02-01 11:28:36 +03:00
Egor 54e9175bdf Update Dockerfile 2026-02-01 11:25:42 +03:00
gy9vin f581b10e19 fix 2026-02-01 11:23:42 +03:00
gy9vin 1ae6ea18b7 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 11:18:59 +03:00
gy9vin bea6c02d89 kassa ai 2026-02-01 11:18:54 +03:00
Mikhail 153083d791 Merge branch 'main' into main 2026-02-01 11:11:23 +03:00
Egor 80538b39ac Update Dockerfile 2026-02-01 01:10:51 +03:00
Egor 205f9e8e3c Update docker-registry.yml 2026-02-01 01:10:17 +03:00
Egor 97ccfd3af0 Update docker-hub.yml 2026-02-01 01:10:03 +03:00
Egor 6288d3fb7f Merge pull request #2478 from BEDOLAGA-DEV/dev
Dev
2026-02-01 00:54:44 +03:00
Egor d611eecece Update user.py 2026-02-01 00:54:17 +03:00
Egor 779cccffe6 Update tribute.py 2026-02-01 00:53:50 +03:00
Egor 04144dc7a5 Merge pull request #2477 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 21:28:51 +03:00
Egor b7bfdbb485 Update subscription.py 2026-01-31 21:28:35 +03:00
Egor 21b7c039b7 Merge pull request #2476 from BEDOLAGA-DEV/dev
Dev
2026-01-31 21:15:22 +03:00
Egor dc7d2eb02a Add files via upload 2026-01-31 21:13:54 +03:00
Egor 6c26d5c7f8 Update texts.py 2026-01-31 21:13:04 +03:00
Egor 06e99d5a43 Merge pull request #2475 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:49:48 +03:00
Egor a7712c7151 Add files via upload 2026-01-31 20:46:25 +03:00
Egor 418d329b75 Update subscription_auto_purchase_service.py 2026-01-31 20:45:55 +03:00
Egor a4d5b8067c Merge pull request #2474 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-31 20:34:27 +03:00
Egor f61ee0f64a Update subscription.py 2026-01-31 20:33:39 +03:00
Egor 841b1e4c52 Merge pull request #2473 from BEDOLAGA-DEV/dev
Dev
2026-01-31 20:16:36 +03:00
Egor 927ec91e0e Update admin_users.py 2026-01-31 20:12:58 +03:00
Egor 9cc2a285dc Update user.py 2026-01-31 20:12:25 +03:00
Egor f11173d5aa Merge pull request #2472 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:52:58 +03:00
Egor 7bd5d13cc8 Update websocket.py 2026-01-31 19:50:47 +03:00
Egor c30e22e1b1 Update subscription_auto_purchase_service.py 2026-01-31 19:50:03 +03:00
Egor ffff91466e Merge pull request #2471 from BEDOLAGA-DEV/dev
Dev
2026-01-31 19:42:07 +03:00
Egor a7669d0f35 Update devices.py 2026-01-31 19:41:16 +03:00
Egor 1371f21d17 Update subscription_auto_purchase_service.py 2026-01-31 19:40:18 +03:00
Egor ec553d3334 Update subscription.py 2026-01-31 19:39:28 +03:00
Egor 638644d1e9 Update subscription.py 2026-01-31 19:02:30 +03:00
Egor 8ae6ef5cb8 Merge pull request #2470 from BEDOLAGA-DEV/dev
Update universal_migration.py
2026-01-31 18:13:59 +03:00
Egor 1092301767 Update universal_migration.py 2026-01-31 18:13:42 +03:00
Egor 365f75447f Update universal_migration.py 2026-01-31 18:12:53 +03:00
Egor 2e3dbaa18c Merge pull request #2469 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:50:21 +03:00
Egor 68df186f72 Update admin_broadcasts.py 2026-01-31 17:50:00 +03:00
Egor 8d3bedefb0 Update broadcast_service.py 2026-01-31 17:49:30 +03:00
Egor 701a4d51de Update universal_migration.py 2026-01-31 17:49:02 +03:00
Egor fbb45c10c1 Update main.py 2026-01-31 17:47:43 +03:00
Egor e4f182ffc6 Update broadcasts.py 2026-01-31 17:47:00 +03:00
Egor fa94042284 Update admin_broadcasts.py 2026-01-31 17:46:37 +03:00
Egor b258715cc1 Update broadcast_service.py 2026-01-31 17:45:59 +03:00
Egor 4e9f01d439 Add files via upload 2026-01-31 17:45:19 +03:00
Egor 5dad41953a Merge pull request #2468 from BEDOLAGA-DEV/dev
Update channel_checker.py
2026-01-31 17:10:40 +03:00
Egor dd9b33af83 Update channel_checker.py 2026-01-31 17:10:04 +03:00
Egor fbf2325a42 Merge pull request #2467 from BEDOLAGA-DEV/dev
Dev
2026-01-31 17:06:05 +03:00
Egor 11eb27437b Update devices.py 2026-01-31 17:05:35 +03:00
Egor 1bb5ef85aa Update inline.py 2026-01-31 17:04:59 +03:00
Egor 35c5d78963 Merge pull request #2466 from BEDOLAGA-DEV/dev
Dev
2026-01-31 16:58:57 +03:00
Egor 38ff15e794 Update subscription.py 2026-01-31 16:58:32 +03:00
Egor 2992dfbada Update subscription_service.py 2026-01-31 16:58:01 +03:00
Egor a1e5a71ad3 Merge pull request #2465 from BEDOLAGA-DEV/dev
Update subscription_auto_purchase_service.py
2026-01-31 15:22:36 +03:00
Egor 28dbe3dca7 Update subscription_auto_purchase_service.py 2026-01-31 15:22:11 +03:00
gy9vin 4f77ece187 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 23:43:29 +03:00
gy9vin 56a69fa1ba правки 2026-01-30 23:43:26 +03:00
Mikhail 5c94bda60a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 23:41:34 +03:00
gy9vin b8d0e6eefb Новый фильтр и кричиеский баг
Теперь при подписке на канал:
  -  Обычные пользователи — подписка реактивируется
  - 🚫 Заблокированные — пропуск с логом, подписка НЕ активируется
2026-01-30 23:40:46 +03:00
Egor 49b48164fd Merge pull request #2464 from BEDOLAGA-DEV/dev
Update monitoring_service.py
2026-01-30 23:02:46 +03:00
Egor f688c74aee Update monitoring_service.py 2026-01-30 23:02:00 +03:00
Egor fe42548dd7 Merge pull request #2462 from BEDOLAGA-DEV/dev
Dev
2026-01-30 21:05:05 +03:00
Egor 7000cd5bc2 Update balance.py 2026-01-30 21:04:46 +03:00
Egor e3901c8d39 Add files via upload 2026-01-30 21:04:10 +03:00
Egor 23f72dc4ec Merge pull request #2461 from BEDOLAGA-DEV/dev
Dev
2026-01-30 20:44:07 +03:00
Egor 55d817bcad Update user_service.py 2026-01-30 20:43:45 +03:00
Egor 8a9994e539 Update user_service.py 2026-01-30 20:41:42 +03:00
Egor f050a62bc6 Merge pull request #2460 from BEDOLAGA-DEV/main
w
2026-01-30 20:40:52 +03:00
c0mrade e26464ddad Merge pull request #2459 from BEDOLAGA-DEV/feat/websocket-subscription-balance-notifications
feat/websocket subscription balance notifications
2026-01-30 20:12:45 +03:00
c0mrade 3263606702 fix: resolve circular import with lazy websocket imports
Move websocket notification imports inside functions to avoid
circular dependency when module is loaded.
2026-01-30 19:17:25 +03:00
c0mrade 86350424d5 feat(websocket): add real-time notifications for subscription and balance events
- Import and call notify_user_subscription_renewed in auto-extend flows
- Import and call notify_user_subscription_activated for new subscriptions
- Add WebSocket notifications to _auto_purchase_tariff and _auto_purchase_daily_tariff
- Add WebSocket notifications to auto_activate_subscription_after_topup
- Add notify_user_balance_topup call in payment common mixin
2026-01-30 19:04:44 +03:00
Egor d2ade1d9ed Merge pull request #2457 from BEDOLAGA-DEV/dev
Update subscription.py
2026-01-30 18:17:05 +03:00
Egor cc47cea268 Update subscription.py 2026-01-30 18:16:49 +03:00
Egor 6f420264f8 Merge pull request #2456 from BEDOLAGA-DEV/dev
Update user_cart_service.py
2026-01-30 17:47:39 +03:00
Egor 5949460572 Update user_cart_service.py 2026-01-30 17:46:55 +03:00
Egor 4330775d01 Merge pull request #2454 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:58:59 +03:00
Egor fa5c217dd0 Update heleket.py 2026-01-30 16:58:40 +03:00
Egor aa270c9ab4 Update subscription_auto_purchase_service.py 2026-01-30 16:58:12 +03:00
Mikhail b7af25644a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 16:40:25 +03:00
Egor 8b742082a4 Merge pull request #2453 from BEDOLAGA-DEV/dev
Dev
2026-01-30 16:09:42 +03:00
Egor 8078a4e64d Update remnawave_service.py 2026-01-30 16:09:13 +03:00
Egor 79f2cc0da5 Update .env.example 2026-01-30 16:07:47 +03:00
Egor 0c769ac16d Update config.py 2026-01-30 16:06:28 +03:00
Egor 25aba75413 Update user.py 2026-01-30 16:06:06 +03:00
Egor 9afe370a98 Add files via upload 2026-01-30 16:05:36 +03:00
Egor 6202801793 Update auth.py 2026-01-30 16:03:34 +03:00
Egor 4d48418b2c Update auth.py 2026-01-30 15:59:26 +03:00
Egor a21dfa75f0 Update email_service.py 2026-01-30 15:59:00 +03:00
Egor e09d9b6607 Update email_verification.py 2026-01-30 15:58:12 +03:00
Mikhail e312767247 Merge branch 'main' into main 2026-01-30 09:36:50 +03:00
gy9vin 1dfa243736 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 09:35:23 +03:00
gy9vin e0d667df28 fix реф системы! фишки конкурсной систем! проверка логов по рефералам и начисления бонусов 2026-01-30 09:35:17 +03:00
Egor 68a734051d Merge pull request #2451 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-29 02:03:15 +03:00
Egor 3f43371e60 Update remnawave_service.py 2026-01-29 02:02:59 +03:00
Egor cb8d233a16 Merge pull request #2450 from BEDOLAGA-DEV/dev
Dev
2026-01-29 01:29:57 +03:00
Egor d95aa7ca5c Rename SubscriptionResponse to SubscriptionData 2026-01-29 01:03:01 +03:00
Egor f7fc7d5cb0 Refactor subscription endpoint to return SubscriptionStatusResponse 2026-01-29 01:02:40 +03:00
Egor 5d6d3b962b Update remnawave_service.py 2026-01-29 01:02:18 +03:00
Egor 6bf8f85c80 Merge pull request #2449 from BEDOLAGA-DEV/dev
Improve payload management and subscription validation
2026-01-29 00:09:25 +03:00
Egor 0a254b1903 Improve payload management and subscription validation
Refactor payload handling and user subscription check logic.
2026-01-29 00:07:52 +03:00
Egor 9f33331618 Merge pull request #2448 from BEDOLAGA-DEV/dev
Dev
2026-01-28 20:17:18 +03:00
Egor 595ecff396 Update payment_method_config_service.py 2026-01-28 20:16:58 +03:00
Egor ad96e8951d Merge pull request #2447 from BEDOLAGA-DEV/main
ц
2026-01-28 20:16:17 +03:00
Mikhail 385e1b4287 Merge pull request #2446 from Gy9vin/main
fix
2026-01-28 13:55:37 +03:00
Mikhail 6551ac1fe9 Merge branch 'main' into main 2026-01-28 13:55:24 +03:00
Egor 327ba81d25 Merge pull request #2445 from BEDOLAGA-DEV/dev
Update remnawave_service.py
2026-01-28 12:48:51 +03:00
Egor bf65e16d4d Update remnawave_service.py 2026-01-28 12:48:29 +03:00
Egor 9cf24deb93 Update remnawave_service.py 2026-01-28 12:47:25 +03:00
Egor 3791f1db5a Merge pull request #2444 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:59:11 +03:00
Egor 0c3070a0cc Add Kassa AI payment method support 2026-01-28 11:56:02 +03:00
Egor a37ec7a308 Update payment_method_config_service.py 2026-01-28 11:55:29 +03:00
Egor b161a8604e Merge pull request #2443 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:46:47 +03:00
Egor 1e93f24f78 Add files via upload 2026-01-28 11:46:12 +03:00
Egor f8cd3076e9 Add files via upload 2026-01-28 11:43:22 +03:00
Egor e557504309 Implement panel_datetime_to_naive_utc function
Add function to convert panel datetime to naive UTC.
2026-01-28 11:42:45 +03:00
Egor 4602f72030 Update remnawave_service.py 2026-01-28 11:41:40 +03:00
Egor d811321808 Merge pull request #2442 from BEDOLAGA-DEV/dev
Dev
2026-01-28 11:07:08 +03:00
Egor dffb637e8d Update remnawave_service.py 2026-01-28 11:06:51 +03:00
Egor 32129bebc0 Merge pull request #2441 from BEDOLAGA-DEV/main
w
2026-01-28 11:06:01 +03:00
gy9vin dd5ee45ab5 Keep local customs over main 2026-01-27 23:51:07 +03:00
gy9vin 95b7152c05 касса и прочее 2026-01-27 23:47:39 +03:00
Egor aa093a074a Delete miniapp directory 2026-01-27 18:37:07 +03:00
191 changed files with 22016 additions and 28883 deletions
+57 -13
View File
@@ -51,6 +51,8 @@ TEST_EMAIL_PASSWORD=
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS=24
# Время жизни токена сброса пароля в часах
CABINET_PASSWORD_RESET_EXPIRE_HOURS=1
# Время жизни кода подтверждения смены email в минутах
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES=15
# ===== SMTP НАСТРОЙКИ (для email в личном кабинете) =====
# SMTP сервер (например: smtp.gmail.com, smtp.yandex.ru)
@@ -150,7 +152,7 @@ REMNAWAVE_API_KEY=your_api_key_here
# Тип авторизации: "api_key", "basic_auth", "caddy"
REMNAWAVE_AUTH_TYPE=api_key
REMNAWAVE_CADDY_TOKEN=YWRtaW46cGFzc3dvcmQ=
REMNAWAVE_CADDY_TOKEN=
# Для панелей с Basic Auth (опционально)
REMNAWAVE_USERNAME=
@@ -185,6 +187,42 @@ 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=
# ===== УВЕДОМЛЕНИЯ ОТ ВЕБХУКОВ (что получают пользователи) =====
# Глобальный переключатель уведомлений пользователям от вебхуков
WEBHOOK_NOTIFY_USER_ENABLED=true
# Отключение/активация подписки администратором
WEBHOOK_NOTIFY_SUB_STATUS=true
# Истечение подписки
WEBHOOK_NOTIFY_SUB_EXPIRED=true
# Предупреждения о скором истечении (72ч, 48ч, 24ч)
WEBHOOK_NOTIFY_SUB_EXPIRING=true
# Достижение лимита трафика
WEBHOOK_NOTIFY_SUB_LIMITED=true
# Сброс счётчика трафика
WEBHOOK_NOTIFY_TRAFFIC_RESET=true
# Удаление пользователя из панели
WEBHOOK_NOTIFY_SUB_DELETED=true
# Обновление ключей подписки (revoke)
WEBHOOK_NOTIFY_SUB_REVOKED=true
# Первое подключение к VPN
WEBHOOK_NOTIFY_FIRST_CONNECTED=true
# Напоминание о неподключении
WEBHOOK_NOTIFY_NOT_CONNECTED=true
# Предупреждение о приближении к лимиту трафика
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD=true
# Подключение и отключение устройств
WEBHOOK_NOTIFY_DEVICES=true
# Теги пользователей в Remnawave (A-Z, 0-9, _, макс. 16 символов)
# Тег для пробных пользователей (опционально)
# TRIAL_USER_TAG=TRIAL
@@ -534,6 +572,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
@@ -542,7 +582,6 @@ PAL24_SHOP_ID=
PAL24_SIGNATURE_TOKEN=
PAL24_BASE_URL=https://pal24.pro/api/v1/
PAL24_WEBHOOK_PATH=/pal24-webhook
PAL24_WEBHOOK_PORT=8084
PAL24_PAYMENT_DESCRIPTION="Пополнение баланса"
PAL24_MIN_AMOUNT_KOPEKS=10000
PAL24_MAX_AMOUNT_KOPEKS=100000000
@@ -658,8 +697,19 @@ CLOUDPAYMENTS_TEST_MODE=false
ENABLE_LOGO_MODE=true
LOGO_FILE=vpn_logo.png
# Режим главного меню (default - классический режим работы бота, text - режим работы с активным ЛК MiniApp, отключает покупку/управление подпиской в меню, заменяет все кнопками открытия в MiniApp ЛК)
# Режим главного меню:
# default - классический режим работы бота (все кнопки внутри Telegram)
# cabinet - режим Cabinet с активным ЛК MiniApp, кнопки ведут на конкретные
# разделы кабинета (/balance, /subscription, /referral и т.д.)
# Требует MINIAPP_CUSTOM_URL
# Алиасы для обратной совместимости: text, text_only, minimal
MAIN_MENU_MODE=default
# Стиль кнопок в режиме Cabinet (Bot API 9.4):
# primary - синий
# success - зелёный
# danger - красный
# (пустое) - цвета по умолчанию для каждой секции
CABINET_BUTTON_STYLE=
# Включить управление меню через API (позволяет динамически менять структуру кнопок)
MENU_LAYOUT_ENABLED=false
@@ -672,7 +722,7 @@ HIDE_SUBSCRIPTION_LINK=false
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - Открывает ссылку напрямую в браузере (режим 4)
# happ_cryptolink - Вывод cryptoLink ссылки на подписку Happ (режим 5)
CONNECT_BUTTON_MODE=guide
CONNECT_BUTTON_MODE=miniapp_subscription
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
MINIAPP_CUSTOM_URL=
@@ -739,7 +789,7 @@ MAINTENANCE_MESSAGE=Ведутся технические работы. Серв
# ===== ЛОКАЛИЗАЦИЯ =====
# Укажите язык из AVAILABLE_LANGUAGES. При некорректном значении используется ru.
DEFAULT_LANGUAGE=ru
AVAILABLE_LANGUAGES=ru,en,ua,zh
AVAILABLE_LANGUAGES=ru,en,ua,zh,fa
# Включить выбор языка при старте и отображение кнопки в меню
LANGUAGE_SELECTION_ENABLED=true
@@ -828,7 +878,7 @@ WEBHOOK_MAX_QUEUE_SIZE=1024
WEBHOOK_WORKERS=4
WEBHOOK_ENQUEUE_TIMEOUT=0.1
WEBHOOK_WORKER_SHUTDOWN_TIMEOUT=30.0
BOT_RUN_MODE=polling # polling, webhook или both
BOT_RUN_MODE=polling # polling или webhook
# ===== КОНКУРСНАЯ СИСТЕМА =====
CONTESTS_ENABLED=false
@@ -836,15 +886,9 @@ CONTESTS_BUTTON_VISIBLE=false
# Реферальные конкурсы (турниры среди рефералов)
REFERRAL_CONTESTS_ENABLED=false
# ===== АВТОАКТИВАЦИЯ ПОСЛЕ ПОПОЛНЕНИЯ =====
# ===== АВТОПОКУПКА ПОСЛЕ ПОПОЛНЕНИЯ =====
# Автоматическая покупка из сохранённой корзины после пополнения баланса
AUTO_PURCHASE_AFTER_TOPUP_ENABLED=false
# Умная автоактивация: система сама решает — продлить или создать подписку
# Работает даже без сохранённой корзины. Выбирает максимальный период <= баланса
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED=false
# Показывать предупреждение об активации подписки после пополнения баланса
# Если true - после пополнения показывает сообщение с кнопками: "Активировать", "Продлить", "Добавить устройства"
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP=false
# ===== КНОПКА АКТИВАЦИИ =====
ACTIVATE_BUTTON_VISIBLE=false
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+4 -4
View File
@@ -14,7 +14,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-dev-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.7.0-pr-$(git rev-parse --short HEAD)" # x-release-please-version
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+24
View File
@@ -0,0 +1,24 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+168
View File
@@ -0,0 +1,168 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
lint:
uses: ./.github/workflows/lint.yml
release:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
TAG="${{ github.ref_name }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
if [ -z "$PREV_TAG" ]; then
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
# Collect commits by category
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
# Collect unique contributors
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
# Stats
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
# Format function
format_section() {
local commits="$1"
if [ -n "$commits" ]; then
echo "$commits" | while IFS='|' read -r msg author hash; do
# Clean conventional commit prefix
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
done
fi
}
# Build changelog
{
echo "changelog<<CHANGELOG_EOF"
if [ -n "$FEATURES" ]; then
echo "### New Features"
echo ""
format_section "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### Bug Fixes"
echo ""
format_section "$FIXES"
echo ""
fi
if [ -n "$PERF" ]; then
echo "### Performance & Refactoring"
echo ""
format_section "$PERF"
echo ""
fi
if [ -n "$DOCS" ]; then
echo "### Documentation & Style"
echo ""
format_section "$DOCS"
echo ""
fi
if [ -n "$CHORE" ]; then
echo "### Maintenance"
echo ""
format_section "$CHORE"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### Other Changes"
echo ""
format_section "$OTHER"
echo ""
fi
echo "---"
echo ""
echo "### Contributors"
echo ""
if [ -n "$CONTRIBUTORS" ]; then
echo "$CONTRIBUTORS" | while read -r name; do
echo "- @${name}"
done
fi
echo ""
echo "### Stats"
echo ""
echo "- **Commits:** ${TOTAL_COMMITS}"
echo "- **Changes:** ${FILES_CHANGED}"
if [ -n "$PREV_TAG" ]; then
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
fi
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
---
### Docker
```bash
# Docker Hub
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
# GitHub Container Registry
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
### Update
```bash
# Docker Compose
docker compose pull && docker compose up -d
# Or with Make
make reload
```
draft: false
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
generate_release_notes: false
+85 -32
View File
@@ -1,49 +1,102 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
# ========== WHITELIST: разрешённые файлы ==========
# Конфигурация проекта
!.dockerignore
!.env.example
!install_bot.sh
!.gitignore
!.python-version
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docker-compose.yml
!docker-compose.local.yml
!Makefile
!pyproject.toml
!uv.lock
!.python-version
!docs/
!docs/**
!migrations/
!migrations/**
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
# Документация
!README.md
!LICENSE
!CONTRIBUTING.md
!SECURITY.md
# Скрипты
!install_bot.sh
!main.py
# Статические файлы
!vpn_logo.png
# ========== WHITELIST: разрешённые папки ==========
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Разрешаем .github/ (workflows, pre-commit и т.д.)
!migrations/
!migrations/**
!docs/
!docs/**
!assets/
!assets/**
!locales/
!locales/**
!.github/
!.github/**
# Разрешаем Makefile
!Makefile
# ========== BLACKLIST: игнорируемые внутри папок ==========
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
# Python
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Build/dist
build/
dist/
*.egg-info/
.eggs/
# Testing/coverage
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Local overrides (не коммитить!)
docker-compose.override.yml
.env
.env.local
.env.*.local
# Runtime data
logs/
data/
*.log
*.db
*.sqlite3
# OS files
.DS_Store
Thumbs.db
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.12.0"
}
+302
View File
@@ -0,0 +1,302 @@
# Changelog
## [3.12.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.11.0...v3.12.0) (2026-02-15)
### New Features
* add 'default' (no color) option for button styles ([10538e7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/10538e735149bf3f3f2029ff44b94d11d48c478e))
* add button style and emoji support for cabinet mode (Bot API 9.4) ([bf2b2f1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf2b2f1c5650e527fcac0fb3e72b4e6e19bef406))
* add per-button enable/disable toggle and custom labels per locale ([68773b7](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68773b7e77aa344d18b0f304fa561c91d7631c05))
* add per-section button style and emoji customization via admin API ([a968791](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9687912dfe756e7d772d96cc253f78f2e97185c))
* add web admin button for admins in cabinet mode ([9ac6da4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ac6da490dffa03ce823009c6b4e5014b7d2bdfb))
* rename MAIN_MENU_MODE=text to cabinet with deep-linking to frontend sections ([ad87c5f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad87c5fb5e1a4dd0ef7691f12764d3df1530f643))
### Bug Fixes
* daily tariff subscriptions stuck in expired/disabled with no resume path ([80914c1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/80914c1af739aa0ee1ea75b0e5871bf391b9020d))
* filter out traffic packages with zero price from purchase options ([64a684c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/64a684cd2ff51e663a1f70e61c07ca6b4f6bfc91))
* handle photo message in ticket creation flow ([e182280](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e1822800aba3ea5eee721846b1e0d8df0a9398d1))
* handle tariff_extend callback without period (back button crash) ([ba0a5e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ba0a5e9abd9bd582968d69a5c6e57f336094c782))
* pre-validate CABINET_BUTTON_STYLE to prevent invalid values from suppressing per-section defaults ([46c1a69](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46c1a69456036cb1be784b8d952f27110e9124eb))
* remove redundant trial inactivity monitoring checks ([d712ab8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d712ab830166cab61ce38dd32498a8a9e3e602b0))
* webhook notification 'My Subscription' button uses unregistered callback_data ([1e2a7e3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1e2a7e3096af11540184d60885b8c08d73506c4a))
## [3.11.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.3...v3.11.0) (2026-02-12)
### New Features
* add cabinet admin API for pinned messages management ([1a476c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1a476c49c19d1ec2ab2cda1c2ffb5fd242288bb6))
* add startup warnings for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE and MINIAPP_CUSTOM_URL ([476b89f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/476b89fe8e613c505acfc58a9554d31ccf92718a))
### Bug Fixes
* add passive_deletes to Subscription relationships to prevent NOT NULL violation on cascade delete ([bfd66c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfd66c42c1fba3763f41d641cea1bd101ec8c10c))
* add startup warning for missing HAPP_CRYPTOLINK_REDIRECT_TEMPLATE in guide mode ([1d43ae5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d43ae5e25ffcf0e4fe6fec13319d393717e1e50))
* flood control handling in pinned messages and XSS hardening in HTML sanitizer ([454b831](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/454b83138e4db8dc4f07171ee6fe262d2cd6d311))
* suppress expired callback query error in AuthMiddleware ([2de4384](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2de438426a647e2bcae9b4d99eef4093ff8b5429))
* ticket creation crash and webhook PendingRollbackError ([760c833](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/760c833b7402541d3c7cf2ed7fc0418119e75042))
## [3.10.3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.2...v3.10.3) (2026-02-12)
### Bug Fixes
* handle unique constraint conflicts during backup restore without clear_existing ([5893874](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/589387477624691e0026086800428e7e52e06128))
* harden backup create/restore against serialization and constraint errors ([fc42916](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fc42916b10bb698895eb75c0e2568747647555d3))
* resolve deadlock on server_squads counter updates and add webhook notification toggles ([57dc1ff](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/57dc1ff47f2f6183351db7594544a07ca6f27250))
## [3.10.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.1...v3.10.2) (2026-02-12)
### Bug Fixes
* allow email change for unverified emails ([93bb8e0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/93bb8e0eb492ca59e29da86594e84e9c486fea65))
* clean stale squad UUIDs from tariffs during server sync ([fcaa9df](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fcaa9dfb27350ceda3765c6980ad67f671477caf))
* delete subscription_servers before subscription to prevent FK violation ([7d9ced8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7d9ced8f4f71b43ed4ac798e6ff904a086e1ac4a))
* handle StaleDataError in webhook user.deleted server counter decrement ([c30c2fe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c30c2feee1db03f0a359b291117da88002dd0fe0))
* handle time/date types in backup JSON serialization ([27365b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/27365b3c7518c09229afcd928f505d0f3f66213f))
* HTML parse fallback, email change race condition, username length limit ([d05ff67](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d05ff678abfacaa7e55ad3e55f226d706d32a7b7))
* payment race conditions, balance atomicity, renewal rollback safety ([c5124b9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c5124b97b63eda59b52d2cbf9e2dcdaa6141ed6e))
* remove DisplayNameRestrictionMiddleware ([640da34](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/640da3473662cfdcceaa4346729467600ac3b14f))
* suppress bot-blocked-by-user error in AuthMiddleware ([fda9f3b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fda9f3beecbfcca4d7abc16cf661d5ad5e3b5141))
* UnboundLocalError for get_logo_media in required_sub_channel_check ([d3c14ac](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3c14ac30363839d1340129f279a7a7b4b021ed1))
* use traffic topup config and add WATA 429 retry ([b5998ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b5998ea9d22644ed2914b0e829b3a76a32a69ddf))
### Refactoring
* remove modem functionality from classic subscriptions ([ee2e79d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ee2e79db3114fe7a9852d2cd33c4b4fbbde311ea))
## [3.10.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.10.0...v3.10.1) (2026-02-11)
### 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)
### New Features
* add lite mode functionality with endpoints for retrieval and update ([7b0403a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7b0403a307702c24efefc5c14af8cb2fb7525671))
* add Persian (fa) locale with complete translations ([29a3b39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/29a3b395b6e67e4ce2437b75120b78c76b69ff4f))
* allow tariff deletion with active subscriptions ([ebd6bee](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ebd6bee05ed7d9187de9394c64dfd745bb06b65a))
* **localization:** add Persian (fa) locale support and wire it across app flows ([cc54a7a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc54a7ad2fb98fe6e662e1923027f4989ae72868))
### Bug Fixes
* nullify payment FK references before deleting transactions in user restoration ([0b86f37](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b86f379b4e55e499ca3d189137e2aed865774b5))
* prevent sync from overwriting end_date for non-ACTIVE panel users ([49871f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/49871f82f37d84979ea9ec91055e3f046d5854be))
* promo code max_uses=0 conversion and trial UX after promo activation ([1cae713](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1cae7130bc87493ab8c7691b3c22ead8189dab55))
* skip users with active subscriptions in admin inactive cleanup ([e79f598](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e79f598d17ffa76372e6f88d2a498accf8175c76))
* use selection.period.days instead of selection.period_days ([4541016](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/45410168afe683675003a1c41c17074a54ce04f1))
### Performance
* cache logo file_id to avoid re-uploading on every message ([142ff14](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/142ff14a502e629446be7d67fab880d12bee149d))
### Refactoring
* remove "both" mode from BOT_RUN_MODE, keep only polling and webhook ([efa3a5d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/efa3a5d4579f24dabeeba01a4f2e981144dd6022))
* remove Flask, use FastAPI exclusively for all webhooks ([119f463](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/119f463c36a95685c3bc6cdf704e746b0ba20d56))
* remove smart auto-activation & activation prompt, fix production bugs ([a3903a2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a3903a252efdd0db4b42ca3fd6771f1627050a7f))
## [3.8.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.2...v3.8.0) (2026-02-08)
### New Features
* add admin device management endpoints ([c57de10](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c57de1081a9e905ba191f64c37221c36713c82a6))
* add admin traffic packages and device limit management ([2f90f91](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f90f9134df58b8c0a329c20060efcf07d5d92f9))
* add admin updates endpoint for bot and cabinet releases ([11b8ab1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/11b8ab1959e83fafe405be0b76dfa3dd1580a68b))
* add endpoint for updating user referral commission percent ([da6f746](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/da6f746b093be8cdbf4e2889c50b35087fbc90de))
* add enrichment data to CSV export ([f2dbab6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f2dbab617155cdc41573d885f0e55222e5b9825b))
* add server-side sorting for enrichment columns ([15c7cc2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/15c7cc2a58e1f1935d10712a981466629db251d1))
* add system info endpoint for admin dashboard ([02c30f8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/02c30f8e7eb6ba90ed8983cfd82199a22b473bbf))
* add traffic usage enrichment endpoint with devices, spending, dates, last node ([5cf3f2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5cf3f2f76eb2cd93282f845ea0850f6707bfcc09))
* admin panel enhancements & bug fixes ([e6ebf81](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e6ebf81752499df8eb0a710072785e3d603dba33))
### Bug Fixes
* add debug logging for bulk device response structure ([46da31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/46da31d89c55c225dec9136d225f2db967cf8961))
* add email field to traffic table for OAuth/email users ([94fcf20](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/94fcf20d17c54efd67fa7bd47eff1afdd1507e08))
* add email/UUID fallback for OAuth user panel sync ([165965d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/165965d8ea60a002c061fd75f88b759f2da66d7d))
* add enrichment device mapping debug logs ([5be82f2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5be82f2d78aed9b54d74e86f261baa5655e5dcd9))
* include additional devices in tariff renewal price and display ([17e9259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17e9259eb1d41dbf1d313b6a7d500f6458359393))
* paginate bulk device endpoint to fetch all HWID devices ([4648a82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4648a82da959410603c92055bcde7f96131e0c29))
* read bot version from pyproject.toml when VERSION env is not set ([9828ff0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9828ff0845ec1d199a6fa63fe490ad3570cf9c8f))
* revert device pagination, add raw user data field discovery ([8f7fa76](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f7fa76e6ab34a3ad2f61f4e1f06026fd3fbf4e3))
* use bulk device endpoint instead of per-user calls ([5f219c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f219c33e6d49b0e3e4405a57f8344a4237f1002))
* use correct pagination params (start/size) for bulk HWID devices ([17af51c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/17af51ce0bdfa45197384988d56960a1918ab709))
* use per-user panel endpoints for reliable device counts and last node data ([9d39901](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9d39901f78ece55c740a5df2603601e5d0b1caca))
## [3.7.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.1...v3.7.2) (2026-02-08)
### Bug Fixes
* handle FK violation in create_yookassa_payment when user is deleted ([55d281b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/55d281b0e37a6e8977ceff792cccb8669560945b))
* remove dots from Remnawave username sanitization ([d6fa86b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d6fa86b870eccbf22327cd205539dd2084f0014e))
## [3.7.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.7.0...v3.7.1) (2026-02-08)
### Bug Fixes
* release-please config — remove blocked workflow files ([d88ca98](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d88ca980ec67e303e37f0094a2912471929b4cef))
* remove workflow files and pyproject.toml from release-please extra-files ([5070bb3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5070bb34e8a09b2641783f5e818bb624469ad610))
* resolve HWID reset and webhook FK violation ([5f3e426](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5f3e426750c2adcb097b92f1a9e7725b1c5c5eba))
* resolve HWID reset context manager bug and webhook FK violation ([a9eee19](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a9eee19c95efdc38ecf5fa28f7402a2bbba7dd07))
* resolve merge conflict in release-please config ([0ef4f55](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ef4f55304751571754f2027105af3e507f75dfd))
* resolve multiple production errors and performance issues ([071c23d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/071c23dd5297c20527442cb5d348d498ebf20af4))
## [3.7.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.6.0...v3.7.0) (2026-02-07)
### Features
* add admin traffic usage API ([aa1cd38](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/aa1cd3829c5c3671e220d49dd7ec2d83563e2cf9))
* add admin traffic usage API with per-node statistics ([6c2c25d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6c2c25d2ccb27446c822e4ed94d9351bfeaf4549))
* add node/status filters and custom date range to traffic page ([ad260d9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad260d9fe0b232c9d65176502476212902909660))
* add node/status filters, custom date range, connected devices to traffic page ([9ea533a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ea533a864e345647754f316bd27971fba1420af))
* add node/status filters, date range, devices to traffic page ([ad6522f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ad6522f547e68ef5965e70d395ca381b0a032093))
* add risk columns to traffic CSV export ([7c1a142](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7c1a1426537e43d14eff0a1c3faeca484611b58b))
* add tariff filter, fix traffic data aggregation ([fa01819](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fa01819674b2d2abb0d05b470559b09eb43abef8))
* node/status filters + custom date range for traffic page ([a161e2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a161e2f904732b459fef98a67abfaae1214ecfd4))
* tariff filter + fix traffic data aggregation ([1021c2c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1021c2cdcd07cf2194e59af7b59491108339e61f))
* traffic filters, date range & risk columns in CSV export ([4c40b5b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c40b5b370616a9ab40cbf0cccdbc0ac4a3f8278))
### Bug Fixes
* close unclosed HTML tags in version notification ([0b61c7f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0b61c7fe482e7bbfbb3421307a96d54addfd91ee))
* close unclosed HTML tags when truncating version notification ([b674550](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b6745508da861af9b2ff05d89b4ac9a3933da510))
* correct response parsing for non-legacy node-users endpoint ([a076dfb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a076dfb5503a349450b5aa8aac3c6f40070b715d))
* correct response parsing for non-legacy node-users endpoint ([91ac90c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91ac90c2aecfb990679b3d0c835314dde448886a))
* handle mixed types in traffic sort ([eeed2d6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eeed2d6369b07860505c59bcff391e7b17e0ffb7))
* handle mixed types in traffic sort for string fields ([a194be0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a194be0843856b3376167d9ba8a8ef737280998c))
* resolve 429 rate limiting on traffic page ([b12544d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b12544d3ea8f4bbd2d8c941f83ee3ac412157adb))
* resolve 429 rate limiting on traffic page ([924d6bc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/924d6bc09c815c1d188ea1d0e7974f7e803c1d3f))
* use legacy per-node endpoint for traffic aggregation ([cc1c8ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cc1c8bacb42a9089021b7ae0fecd1f2717953efb))
* use legacy per-node endpoint with correct response format ([b707b79](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b707b7995b90c6465910a35e9a4403e1408c6568))
* use PaymentService for cabinet YooKassa payments ([61bb8fc](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/61bb8fcafd94509568f134ccdba7769b66cc7d5d))
* use PaymentService for cabinet YooKassa payments to save local DB record ([ff5bba3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/ff5bba3fc5d1e1b08d008b64215e487a9eb70960))
## [3.6.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.5.0...v3.6.0) (2026-02-07)
### Features
* add OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([97be4af](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/97be4afbffd809fe2786a6d248fc4d3f770cb8cf))
* add panel info, node usage endpoints and campaign to user detail ([287a43b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287a43ba6527ff3464a527821d746a68e5371bbe))
* add panel info, node usage endpoints and campaign to user detail ([0703212](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/070321230bcb868e4bc7a39c287ed3431a4aef4a))
* add TRIAL_DISABLED_FOR setting to disable trial by user type ([c4794db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4794db1dd78f7c48b5da896bdb2f000e493e079))
* add user_id filter to admin tickets endpoint ([8886d0d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8886d0dea20aa5a31c6b6f0c3391b3c012b4b34d))
* add user_id filter to admin tickets endpoint ([d3819c4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d3819c492f88794e4466c2da986fd3a928d7f3df))
* block registration with disposable email addresses ([9ca24ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9ca24efe434278925c0c1f8d2f2d644a67985c89))
* block registration with disposable email addresses ([116c845](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/116c8453bb371b5eacf5c9d07f497eb449a355cc))
* disable trial by user type (email/telegram/all) ([4e7438b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e7438b9f9c01e30c48fcf2bbe191e9b11598185))
* migrate OAuth state storage from in-memory to Redis ([e9b98b8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e9b98b837a8552360ef4c41f6cd7a5779aa8b0a7))
* OAuth 2.0 authorization (Google, Yandex, Discord, VK) ([3cbb9ef](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/3cbb9ef024695352959ef9a82bf8b81f0ba1d940))
* return 30-day daily breakdown for node usage ([7102c50](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7102c50f52d583add863331e96f3a9de189f581a))
* return 30-day daily breakdown for node usage ([e4c65ca](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e4c65ca220994cf08ed3510f51d9e2808bb2d154))
### Bug Fixes
* increase OAuth HTTP timeout to 30s ([333a3c5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/333a3c590120a64f6b2963efab1edd861274840c))
* parse bandwidth stats series format for node usage ([557dbf3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/557dbf3ebe777d2137e0e28303dc2a803b15c1c6))
* parse bandwidth stats series format for node usage ([462f7a9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/462f7a99b9d5c0b7436dbc3d6ab5db6c6cfa3118))
* pass tariff object instead of tariff_id to set_tariff_promo_groups ([1ffb8a5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1ffb8a5b85455396006e1fcddd48f4c9a2ca2700))
* query per-node legacy endpoint for user traffic breakdown ([b94e3ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b94e3edf80e747077992c03882119c7559ad1c31))
* query per-node legacy endpoint for user traffic breakdown ([51ca3e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/51ca3e42b75c1870c76a1b25f667629855cfe886))
* reduce node usage to 2 API calls to avoid 429 rate limit ([c68c4e5](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c68c4e59846abba9c7c78ae91ec18e2e0e329e3c))
* reduce node usage to 2 API calls to avoid 429 rate limit ([f00a051](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f00a051bb323e5ba94a3c38939870986726ed58e))
* use accessible nodes API and fix date format for node usage ([943e9a8](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/943e9a86aaa449cd3154b0919cfdc52d2a35b509))
* use accessible nodes API and fix date format for node usage ([c4da591](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c4da59173155e2eeb69eca21416f816fcbd1fa9c))
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
### Features
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
### Bug Fixes
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
### Reverts
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.2.0"
ARG VERSION="v3.12.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+121 -6
View File
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
---
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
@@ -160,7 +160,6 @@ docker compose logs
| -------------- | --------------------------------------------------------------------------- | ------------------------------------------------ |
| `polling` | Бот опрашивает Telegram через long polling. HTTP-сервер можно не поднимать. | Локальная отладка или отсутствие внешнего HTTPS. |
| `webhook` | Aiogram получает апдейты только через вебхук. | Продакшн и серверы за HTTPS-прокси. |
| `both` | Одновременно работают polling и webhook. | Тестирование или повышенная отказоустойчивость. |
### 2. Минимальные настройки для webhook
@@ -611,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 "*"
@@ -819,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 "*";
@@ -1012,7 +1033,7 @@ curl -I https://miniapp.domain.com
| ---------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------ |
| 🤖 **BOT_TOKEN** | [@BotFather](https://t.me/BotFather) | `1234567890:AABBCCdd...` |
| 👑 **ADMIN_IDS** | Твой Telegram ID | `123456789,987654321` |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима. |
| **BOT_RUN_MODE** | определяет способ приёма обновлений: `polling` или `webhook`. |
[Полный список доступных параметров:](.env.example)
@@ -1022,7 +1043,7 @@ curl -I https://miniapp.domain.com
### 🤖 Режимы запуска бота
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling`, `webhook` или `both`, чтобы одновременно использовать оба режима.
- `BOT_RUN_MODE` — определяет способ приёма обновлений: `polling` или `webhook`.
- `WEBHOOK_SECRET_TOKEN` — секрет для проверки заголовка `X-Telegram-Bot-Api-Secret-Token` при работе через вебхуки.
- `WEBHOOK_DROP_PENDING_UPDATES` — управляет очисткой очереди сообщений при установке вебхука.
- `WEBHOOK_MAX_QUEUE_SIZE` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
@@ -1056,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 СБП и банковские карты.
@@ -1343,7 +1460,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 Автоплатёж с настройкой дня списания
- 🎁 Реферальные и промо-бонусы
-**Быстрое пополнение** с кнопками быстрых сумм
- 🔄 **Умная автоактивация** подписки после пополнения баланса
📱 **Управление подписками**
@@ -1530,7 +1646,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
+44 -5
View File
@@ -24,6 +24,7 @@ from app.handlers import (
from app.handlers.admin import (
backup as admin_backup,
blacklist as admin_blacklist,
blocked_users as admin_blocked_users,
bot_configuration as admin_bot_configuration,
bulk_ban as admin_bulk_ban,
campaigns as admin_campaigns,
@@ -60,8 +61,8 @@ from app.handlers.admin import (
)
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
@@ -118,10 +119,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
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)
blacklist_middleware = BlacklistMiddleware()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
dp.message.middleware(ThrottlingMiddleware())
dp.callback_query.middleware(ThrottlingMiddleware())
@@ -189,6 +190,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
@@ -208,6 +210,43 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
logger.info('Мониторинг техработ отключен настройками')
logger.info('🛡️ GlobalErrorMiddleware активирован - бот защищен от устаревших callback queries')
# Validate CONNECT_BUTTON_MODE dependencies
if not settings.get_happ_cryptolink_redirect_template():
if settings.CONNECT_BUTTON_MODE == 'happ_cryptolink':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=happ_cryptolink, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" не будет отображаться.'
)
elif settings.CONNECT_BUTTON_MODE == 'guide':
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=guide, но HAPP_CRYPTOLINK_REDIRECT_TEMPLATE не задан! '
'Кнопка "Подключиться" в гайдах не будет работать — Telegram не поддерживает '
'кастомные схемы (happ://, v2ray://) в inline-кнопках без HTTPS-редиректа.'
)
if settings.CONNECT_BUTTON_MODE == 'miniapp_custom' and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ CONNECT_BUTTON_MODE=miniapp_custom, но MINIAPP_CUSTOM_URL не задан! '
'Кнопка "Подключиться" не будет работать.'
)
if settings.is_cabinet_mode() and not settings.MINIAPP_CUSTOM_URL:
logger.warning(
'⚠️ MAIN_MENU_MODE=cabinet, но MINIAPP_CUSTOM_URL не задан! '
'Кнопки кабинета не смогут открывать разделы MiniApp. '
'Установите MINIAPP_CUSTOM_URL.'
)
elif settings.is_cabinet_mode():
logger.info(f'🏠 Режим Cabinet активен, базовый URL: {settings.MINIAPP_CUSTOM_URL}')
# Load per-section button styles cache
if settings.is_cabinet_mode():
try:
from app.utils.button_styles_cache import load_button_styles_cache
await load_button_styles_cache()
except Exception as e:
logger.warning(f'Failed to load button styles cache: {e}')
logger.info('Бот успешно настроен')
return bot, dp
+21
View File
@@ -6,6 +6,27 @@ from datetime import datetime, timedelta
from app.config import settings
def generate_email_change_code() -> str:
"""
Generate a 6-digit verification code for email change.
Returns:
6-digit numeric string
"""
return str(secrets.randbelow(900000) + 100000)
def get_email_change_expires_at() -> datetime:
"""
Get the expiration datetime for an email change code.
Returns:
Datetime when the email change code expires
"""
minutes = settings.get_cabinet_email_change_code_expire_minutes()
return datetime.utcnow() + timedelta(minutes=minutes)
def generate_verification_token() -> str:
"""
Generate a secure random verification token.
+429
View File
@@ -0,0 +1,429 @@
"""OAuth 2.0 provider implementations for cabinet authentication."""
import logging
import secrets
from abc import ABC, abstractmethod
from typing import Any, TypedDict
import httpx
from pydantic import BaseModel
from app.config import settings
from app.utils.cache import cache, cache_key
logger = logging.getLogger(__name__)
STATE_TTL_SECONDS = 600 # 10 minutes
# --- Typed dicts for provider API responses ---
class OAuthProviderConfig(TypedDict):
client_id: str
client_secret: str
enabled: bool
display_name: str
class OAuthTokenResponse(TypedDict, total=False):
access_token: str
token_type: str
expires_in: int
refresh_token: str
scope: str
# VK-specific: email and user_id come in token response
email: str
user_id: int
class GoogleUserInfoResponse(TypedDict, total=False):
sub: str
email: str
email_verified: bool
given_name: str
family_name: str
picture: str
name: str
class YandexUserInfoResponse(TypedDict, total=False):
id: str
login: str
default_email: str
emails: list[str]
first_name: str
last_name: str
default_avatar_id: str
class DiscordUserInfoResponse(TypedDict, total=False):
id: str
username: str
global_name: str
email: str
verified: bool
avatar: str
class VKUserInfoItem(TypedDict, total=False):
id: int
first_name: str
last_name: str
photo_200: str
class VKUserInfoResponse(TypedDict, total=False):
response: list[VKUserInfoItem]
# --- Models ---
class OAuthUserInfo(BaseModel):
"""Normalized user info from OAuth provider."""
provider: str
provider_id: str
email: str | None = None
email_verified: bool = False
first_name: str | None = None
last_name: str | None = None
username: str | None = None
avatar_url: str | None = None
# --- CSRF state management (Redis) ---
async def generate_oauth_state(provider: str) -> str:
"""Generate a CSRF state token for OAuth flow. Stored in Redis with TTL."""
state = secrets.token_urlsafe(32)
await cache.set(cache_key('oauth_state', state), provider, expire=STATE_TTL_SECONDS)
return state
async def validate_oauth_state(state: str, provider: str) -> bool:
"""Validate and consume a CSRF state token from Redis."""
key = cache_key('oauth_state', state)
stored_provider: str | None = await cache.get(key)
if stored_provider is None:
return False
await cache.delete(key)
if stored_provider != provider:
return False
return True
# --- Provider implementations ---
class OAuthProvider(ABC):
"""Base class for OAuth 2.0 providers."""
name: str
display_name: str
def __init__(self, client_id: str, client_secret: str, redirect_uri: str) -> None:
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
@abstractmethod
def get_authorization_url(self, state: str) -> str:
"""Build the authorization URL for the provider."""
@abstractmethod
async def exchange_code(self, code: str) -> OAuthTokenResponse:
"""Exchange authorization code for tokens."""
@abstractmethod
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
"""Fetch user info from the provider."""
class GoogleProvider(OAuthProvider):
name = 'google'
display_name = 'Google'
AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
TOKEN_URL = 'https://oauth2.googleapis.com/token'
USERINFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'openid email profile',
'state': state,
'access_type': 'offline',
'prompt': 'select_account',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
json={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: GoogleUserInfoResponse = response.json()
return OAuthUserInfo(
provider='google',
provider_id=str(data['sub']),
email=data.get('email'),
email_verified=data.get('email_verified', False),
first_name=data.get('given_name'),
last_name=data.get('family_name'),
avatar_url=data.get('picture'),
)
class YandexProvider(OAuthProvider):
name = 'yandex'
display_name = 'Yandex'
AUTHORIZE_URL = 'https://oauth.yandex.com/authorize'
TOKEN_URL = 'https://oauth.yandex.com/token'
USERINFO_URL = 'https://login.yandex.ru/info'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'login:info login:email',
'state': state,
'force_confirm': 'yes',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
params={'format': 'json'},
headers={'Authorization': f'OAuth {access_token}'},
)
response.raise_for_status()
data: YandexUserInfoResponse = response.json()
default_email = data.get('default_email')
emails = data.get('emails', [])
email = default_email or (emails[0] if emails else None)
return OAuthUserInfo(
provider='yandex',
provider_id=str(data['id']),
email=email,
email_verified=bool(email),
first_name=data.get('first_name'),
last_name=data.get('last_name'),
username=data.get('login'),
avatar_url=(
f'https://avatars.yandex.net/get-yapic/{data["default_avatar_id"]}/islands-200'
if data.get('default_avatar_id')
else None
),
)
class DiscordProvider(OAuthProvider):
name = 'discord'
display_name = 'Discord'
AUTHORIZE_URL = 'https://discord.com/api/oauth2/authorize'
TOKEN_URL = 'https://discord.com/api/oauth2/token'
USERINFO_URL = 'https://discord.com/api/v10/users/@me'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'identify email',
'state': state,
'prompt': 'consent',
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(
self.TOKEN_URL,
data={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'grant_type': 'authorization_code',
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
headers={'Authorization': f'Bearer {access_token}'},
)
response.raise_for_status()
data: DiscordUserInfoResponse = response.json()
avatar_url: str | None = None
if data.get('avatar'):
avatar_url = f'https://cdn.discordapp.com/avatars/{data["id"]}/{data["avatar"]}.png'
return OAuthUserInfo(
provider='discord',
provider_id=str(data['id']),
email=data.get('email'),
email_verified=data.get('verified', False),
first_name=data.get('global_name') or data.get('username'),
username=data.get('username'),
avatar_url=avatar_url,
)
class VKProvider(OAuthProvider):
name = 'vk'
display_name = 'VK'
AUTHORIZE_URL = 'https://oauth.vk.com/authorize'
TOKEN_URL = 'https://oauth.vk.com/access_token'
USERINFO_URL = 'https://api.vk.com/method/users.get'
API_VERSION = '5.131'
def get_authorization_url(self, state: str) -> str:
params: dict[str, str] = {
'client_id': self.client_id,
'redirect_uri': self.redirect_uri,
'response_type': 'code',
'scope': 'email',
'state': state,
'v': self.API_VERSION,
}
request = httpx.Request('GET', self.AUTHORIZE_URL, params=params)
return str(request.url)
async def exchange_code(self, code: str) -> OAuthTokenResponse:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.TOKEN_URL,
params={
'client_id': self.client_id,
'client_secret': self.client_secret,
'code': code,
'redirect_uri': self.redirect_uri,
},
)
response.raise_for_status()
data: OAuthTokenResponse = response.json()
return data
async def get_user_info(self, token_data: OAuthTokenResponse) -> OAuthUserInfo:
access_token = token_data['access_token']
user_id: int | None = token_data.get('user_id')
# VK returns email in token response, not in userinfo
email: str | None = token_data.get('email')
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(
self.USERINFO_URL,
params={
'access_token': access_token,
'fields': 'photo_200',
'v': self.API_VERSION,
},
)
response.raise_for_status()
data: VKUserInfoResponse = response.json()
users: list[Any] = data.get('response', [])
user_data: VKUserInfoItem = users[0] if users else {} # type: ignore[assignment]
return OAuthUserInfo(
provider='vk',
provider_id=str(user_id or user_data.get('id', '')),
email=email,
email_verified=bool(email),
first_name=user_data.get('first_name'),
last_name=user_data.get('last_name'),
avatar_url=user_data.get('photo_200'),
)
# --- Provider factory ---
_PROVIDERS: dict[str, type[OAuthProvider]] = {
'google': GoogleProvider,
'yandex': YandexProvider,
'discord': DiscordProvider,
'vk': VKProvider,
}
def get_provider(name: str) -> OAuthProvider | None:
"""Get an OAuth provider instance if enabled.
Returns None if the provider is not enabled or not found.
"""
providers_config: dict[str, OAuthProviderConfig] = settings.get_oauth_providers_config()
config = providers_config.get(name)
if not config or not config['enabled']:
return None
provider_class = _PROVIDERS.get(name)
if not provider_class:
return None
redirect_uri = f'{settings.CABINET_URL}/auth/oauth/callback'
return provider_class(
client_id=config['client_id'],
client_secret=config['client_secret'],
redirect_uri=redirect_uri,
)
+13
View File
@@ -12,6 +12,7 @@ from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'blacklisted',
'message': reason or 'Доступ запрещен',
},
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
# Проверяем админа по telegram_id ИЛИ email
+10
View File
@@ -5,10 +5,12 @@ from fastapi import APIRouter
from .admin_apps import router as admin_apps_router
from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_payment_methods import router as admin_payment_methods_router
from .admin_payments import router as admin_payments_router
from .admin_pinned_messages import router as admin_pinned_messages_router
from .admin_promo_offers import router as admin_promo_offers_router
from .admin_promocodes import promo_groups_router as admin_promo_groups_router, router as admin_promocodes_router
from .admin_remnawave import router as admin_remnawave_router
@@ -17,6 +19,8 @@ from .admin_settings import router as admin_settings_router
from .admin_stats import router as admin_stats_router
from .admin_tariffs import router as admin_tariffs_router
from .admin_tickets import router as admin_tickets_router
from .admin_traffic import router as admin_traffic_router
from .admin_updates import router as admin_updates_router
from .admin_users import router as admin_users_router
from .admin_wheel import router as admin_wheel_router
from .auth import router as auth_router
@@ -26,6 +30,7 @@ from .contests import router as contests_router
from .info import router as info_router
from .media import router as media_router
from .notifications import router as notifications_router
from .oauth import router as oauth_router
from .polls import router as polls_router
from .promo import router as promo_router
from .promocode import router as promocode_router
@@ -45,6 +50,7 @@ router = APIRouter(prefix='/cabinet', tags=['Cabinet'])
# Include all sub-routers
router.include_router(auth_router)
router.include_router(oauth_router)
router.include_router(subscription_router)
router.include_router(balance_router)
router.include_router(referral_router)
@@ -83,6 +89,10 @@ router.include_router(admin_payments_router)
router.include_router(admin_promo_offers_router)
router.include_router(admin_remnawave_router)
router.include_router(admin_email_templates_router)
router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
# WebSocket route
router.include_router(websocket_router)
+289 -3
View File
@@ -4,7 +4,7 @@ import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy import distinct, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import BroadcastHistory, Subscription, SubscriptionStatus, Tariff, User
@@ -13,7 +13,9 @@ from app.keyboards.admin import BROADCAST_BUTTONS, DEFAULT_BROADCAST_BUTTONS
from app.services.broadcast_service import (
BroadcastConfig,
BroadcastMediaConfig,
EmailBroadcastConfig,
broadcast_service,
email_broadcast_service,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
@@ -28,6 +30,11 @@ from ..schemas.broadcasts import (
BroadcastPreviewResponse,
BroadcastResponse,
BroadcastTariffsResponse,
CombinedBroadcastCreateRequest,
EmailFilterItem,
EmailFiltersResponse,
EmailPreviewRequest,
EmailPreviewResponse,
TariffFilter,
TariffForBroadcast,
)
@@ -87,6 +94,25 @@ CUSTOM_FILTER_GROUPS = {
}
# ============ Email Filter Labels ============
EMAIL_FILTER_LABELS = {
'all_email': 'Все с email',
'email_only': 'Только email-регистрация',
'telegram_with_email': 'Telegram с email',
'active_email': 'С активной подпиской',
'expired_email': 'С истекшей подпиской',
}
EMAIL_FILTER_GROUPS = {
'all_email': 'basic',
'email_only': 'auth_type',
'telegram_with_email': 'auth_type',
'active_email': 'subscription',
'expired_email': 'subscription',
}
# ============ Helper Functions ============
@@ -113,9 +139,73 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
)
async def _get_email_filter_count(db: AsyncSession, target: str) -> int:
"""Get count of email users matching the filter."""
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
query = select(func.count(User.id)).where(*base_conditions)
elif target == 'email_only':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
query = select(func.count(User.id)).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
query = (
select(func.count(distinct(User.id)))
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
return 0
result = await db.execute(query)
return result.scalar() or 0
def _validate_email_target(target: str) -> bool:
"""Validate email target filter."""
return target in EMAIL_FILTER_LABELS
async def _get_tariff_user_counts(db: AsyncSession) -> dict:
"""Get count of active users per tariff."""
result = await db.execute(
@@ -388,6 +478,194 @@ async def list_broadcasts(
)
# ============ Email Broadcast Endpoints ============
@router.get('/email-filters', response_model=EmailFiltersResponse)
async def get_email_filters(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailFiltersResponse:
"""Get all available email filters with user counts."""
filters = []
total_with_email = 0
for key, label in EMAIL_FILTER_LABELS.items():
try:
count = await _get_email_filter_count(db, key)
except Exception as e:
logger.warning(f'Failed to get count for email filter {key}: {e}')
count = 0
filters.append(
EmailFilterItem(
key=key,
label=label,
count=count,
group=EMAIL_FILTER_GROUPS.get(key),
)
)
# Track total with email (all_email filter)
if key == 'all_email':
total_with_email = count
return EmailFiltersResponse(
filters=filters,
total_with_email=total_with_email,
)
@router.post('/email-preview', response_model=EmailPreviewResponse)
async def preview_email_broadcast(
request: EmailPreviewRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> EmailPreviewResponse:
"""Preview email broadcast recipients count."""
if not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
try:
count = await _get_email_filter_count(db, request.target)
except Exception as e:
logger.error(f'Failed to get email count for target {request.target}: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to count email recipients',
)
return EmailPreviewResponse(target=request.target, count=count)
@router.post('/send', response_model=BroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_combined_broadcast(
request: CombinedBroadcastCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Create and start a combined broadcast (telegram/email/both)."""
# Get tariff IDs for target validation
result = await db.execute(select(Tariff.id))
tariff_ids = {row[0] for row in result.all()}
admin_name = admin.username or f'Admin #{admin.id}'
# Validate based on channel
if request.channel in ('telegram', 'both'):
# Validate telegram target
if not _validate_target(request.target, tariff_ids):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid target: {request.target}',
)
# Validate telegram message
if not request.message_text or not request.message_text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Message text is required for Telegram broadcast',
)
# Validate buttons
if not _validate_buttons(request.selected_buttons):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid button key',
)
if request.channel in ('email', 'both'):
# For email channel, target must be email filter or we use telegram target for 'both'
if request.channel == 'email' and not _validate_email_target(request.target):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid email target: {request.target}',
)
# Validate email fields
if not request.email_subject or not request.email_subject.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email subject is required for email broadcast',
)
if not request.email_html_content or not request.email_html_content.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Email HTML content is required for email broadcast',
)
media_payload = request.media
# Create broadcast record
broadcast = BroadcastHistory(
target_type=request.target,
message_text=request.message_text.strip() if request.message_text else None,
has_media=media_payload is not None,
media_type=media_payload.type if media_payload else None,
media_file_id=media_payload.file_id if media_payload else None,
media_caption=media_payload.caption if media_payload else None,
total_count=0,
sent_count=0,
failed_count=0,
status='queued',
admin_id=admin.id,
admin_name=admin_name,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
)
db.add(broadcast)
await db.commit()
await db.refresh(broadcast)
# Start broadcasts based on channel
if request.channel in ('telegram', 'both'):
# Prepare media config
media_config = None
if media_payload:
media_config = BroadcastMediaConfig(
type=media_payload.type,
file_id=media_payload.file_id,
caption=media_payload.caption or request.message_text,
)
# Create telegram broadcast config
telegram_config = BroadcastConfig(
target=request.target,
message_text=request.message_text.strip(),
selected_buttons=request.selected_buttons,
media=media_config,
initiator_name=admin_name,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
if request.channel in ('email', 'both'):
# For 'both' channel, we use 'all_email' as default email target
# since telegram target won't match email filters
email_target = request.target if request.channel == 'email' else 'all_email'
# Create email broadcast config
email_config = EmailBroadcastConfig(
target=email_target,
email_subject=request.email_subject.strip(),
email_html_content=request.email_html_content.strip(),
initiator_name=admin_name,
)
await email_broadcast_service.start_broadcast(broadcast.id, email_config)
await db.refresh(broadcast)
logger.info(f"Admin {admin.id} created {request.channel} broadcast {broadcast.id} for target '{request.target}'")
return _serialize_broadcast(broadcast)
@router.get('/{broadcast_id}', response_model=BroadcastResponse)
async def get_broadcast(
broadcast_id: int,
@@ -410,7 +688,7 @@ async def stop_broadcast(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> BroadcastResponse:
"""Stop a running broadcast."""
"""Stop a running broadcast (telegram or email)."""
broadcast = await db.get(BroadcastHistory, broadcast_id)
if not broadcast:
raise HTTPException(
@@ -424,7 +702,15 @@ async def stop_broadcast(
detail='Broadcast is not running',
)
is_running = await broadcast_service.request_stop(broadcast_id)
# Try to stop both telegram and email broadcasts (one or both may be running)
channel = getattr(broadcast, 'channel', 'telegram') or 'telegram'
is_running = False
if channel in ('telegram', 'both'):
is_running = await broadcast_service.request_stop(broadcast_id) or is_running
if channel in ('email', 'both'):
is_running = await email_broadcast_service.request_stop(broadcast_id) or is_running
if is_running:
broadcast.status = 'cancelling'
+253
View File
@@ -0,0 +1,253 @@
"""Admin routes for per-section cabinet button style configuration."""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.utils.button_styles_cache import (
ALLOWED_STYLE_VALUES,
BOT_LOCALES,
BUTTON_STYLES_KEY,
DEFAULT_BUTTON_STYLES,
SECTIONS,
load_button_styles_cache,
)
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/button-styles', tags=['Admin Button Styles'])
# ---- Schemas ---------------------------------------------------------------
class ButtonSectionConfig(BaseModel):
"""Configuration for a single button section."""
style: str = 'primary'
icon_custom_emoji_id: str = ''
enabled: bool = True
labels: dict[str, str] = {}
class ButtonStylesResponse(BaseModel):
"""Full button styles configuration (all 7 sections)."""
home: ButtonSectionConfig = ButtonSectionConfig()
subscription: ButtonSectionConfig = ButtonSectionConfig()
balance: ButtonSectionConfig = ButtonSectionConfig()
referral: ButtonSectionConfig = ButtonSectionConfig()
support: ButtonSectionConfig = ButtonSectionConfig()
info: ButtonSectionConfig = ButtonSectionConfig()
admin: ButtonSectionConfig = ButtonSectionConfig()
MAX_LABEL_LENGTH = 100
class ButtonSectionUpdate(BaseModel):
"""Partial update for a single section (None = keep current)."""
style: str | None = None
icon_custom_emoji_id: str | None = None
enabled: bool | None = None
labels: dict[str, str] | None = None
class ButtonStylesUpdate(BaseModel):
"""Partial update — only include sections you want to change."""
home: ButtonSectionUpdate | None = None
subscription: ButtonSectionUpdate | None = None
balance: ButtonSectionUpdate | None = None
referral: ButtonSectionUpdate | None = None
support: ButtonSectionUpdate | None = None
info: ButtonSectionUpdate | None = None
admin: ButtonSectionUpdate | None = None
# ---- Helpers ---------------------------------------------------------------
async def _get_setting_value(db: AsyncSession, key: str) -> str | None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
return setting.value if setting else None
async def _set_setting_value(db: AsyncSession, key: str, value: str) -> None:
from sqlalchemy import select
from app.database.models import SystemSetting
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
setting = result.scalar_one_or_none()
if setting:
setting.value = value
else:
setting = SystemSetting(key=key, value=value)
db.add(setting)
await db.commit()
def _build_response(styles: dict[str, dict]) -> ButtonStylesResponse:
return ButtonStylesResponse(
**{section: ButtonSectionConfig(**cfg) for section, cfg in styles.items() if section in SECTIONS},
)
# ---- Routes ----------------------------------------------------------------
@router.get('', response_model=ButtonStylesResponse)
async def get_button_styles(
_admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return current per-section button styles. Admin only."""
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
merged = {section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in merged and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
merged[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
merged[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
merged[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
merged[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
return _build_response(merged)
@router.patch('', response_model=ButtonStylesResponse)
async def update_button_styles(
payload: ButtonStylesUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Partially update per-section button styles. Admin only."""
# Load current state
raw = await _get_setting_value(db, BUTTON_STYLES_KEY)
current: dict[str, dict] = {
section: {**cfg, 'labels': dict(cfg.get('labels', {}))} for section, cfg in DEFAULT_BUTTON_STYLES.items()
}
if raw:
try:
db_data = json.loads(raw)
for section, overrides in db_data.items():
if section in current and isinstance(overrides, dict):
if overrides.get('style') in ALLOWED_STYLE_VALUES:
current[section]['style'] = overrides['style']
if isinstance(overrides.get('icon_custom_emoji_id'), str):
current[section]['icon_custom_emoji_id'] = overrides['icon_custom_emoji_id']
if isinstance(overrides.get('enabled'), bool):
current[section]['enabled'] = overrides['enabled']
if isinstance(overrides.get('labels'), dict):
current[section]['labels'] = {
k: v
for k, v in overrides['labels'].items()
if isinstance(k, str) and isinstance(v, str) and k in BOT_LOCALES
}
except (json.JSONDecodeError, TypeError):
pass
# Apply updates
update_data = payload.model_dump(exclude_none=True)
changed_sections: list[str] = []
for section, updates in update_data.items():
if section not in current or not isinstance(updates, dict):
continue
if 'style' in updates:
style_val = updates['style']
if style_val not in ALLOWED_STYLE_VALUES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid style "{style_val}" for section "{section}". '
f'Allowed: {", ".join(sorted(ALLOWED_STYLE_VALUES))}',
)
current[section]['style'] = style_val
if 'icon_custom_emoji_id' in updates:
emoji_val = (updates['icon_custom_emoji_id'] or '').strip()
current[section]['icon_custom_emoji_id'] = emoji_val
if 'enabled' in updates:
current[section]['enabled'] = updates['enabled']
if 'labels' in updates:
raw_labels = updates['labels'] or {}
sanitized: dict[str, str] = {}
for locale_key, label_val in raw_labels.items():
if locale_key not in BOT_LOCALES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid locale "{locale_key}" for section "{section}". '
f'Allowed: {", ".join(BOT_LOCALES)}',
)
if not isinstance(label_val, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label value for locale "{locale_key}" must be a string.',
)
stripped = label_val.strip()
if len(stripped) > MAX_LABEL_LENGTH:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Label for locale "{locale_key}" exceeds {MAX_LABEL_LENGTH} characters.',
)
# Empty string = remove custom label (use default)
if stripped:
sanitized[locale_key] = stripped
current[section]['labels'] = sanitized
changed_sections.append(section)
# Persist
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(current))
# Refresh in-process cache
await load_button_styles_cache()
logger.info('Admin %s updated button styles for sections: %s', admin.telegram_id, changed_sections)
return _build_response(current)
@router.post('/reset', response_model=ButtonStylesResponse)
async def reset_button_styles(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Reset all button styles to defaults. Admin only."""
await _set_setting_value(db, BUTTON_STYLES_KEY, json.dumps(DEFAULT_BUTTON_STYLES))
await load_button_styles_cache()
logger.info('Admin %s reset button styles to defaults', admin.telegram_id)
return _build_response(DEFAULT_BUTTON_STYLES)
+1 -1
View File
@@ -337,7 +337,7 @@ SAMPLE_CONTEXTS: dict[str, dict[str, Any]] = {
'password_reset': {'username': 'John', 'reset_url': 'https://example.com/reset?token=abc123', 'expire_hours': 1},
}
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua']
AVAILABLE_LANGUAGES = ['ru', 'en', 'zh', 'ua', 'fa']
# ============ Schemas ============
+397
View File
@@ -0,0 +1,397 @@
"""Admin routes for pinned messages in cabinet."""
import logging
import time
from datetime import datetime
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PinnedMessage, User
from app.services.pinned_message_service import (
broadcast_pinned_message,
deactivate_active_pinned_message,
get_active_pinned_message,
set_active_pinned_message,
unpin_active_pinned_message,
)
from app.utils.validators import sanitize_html, validate_html_tags
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.pinned_messages import (
PinnedMessageBroadcastResponse,
PinnedMessageCreateRequest,
PinnedMessageListResponse,
PinnedMessageResponse,
PinnedMessageSettingsRequest,
PinnedMessageUnpinResponse,
PinnedMessageUpdateRequest,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/pinned-messages', tags=['Cabinet Admin Pinned Messages'])
# Broadcast cooldown: min 60 seconds between mass operations
_BROADCAST_COOLDOWN_SECONDS = 60
_last_broadcast_time: float = 0.0
def _check_broadcast_cooldown() -> None:
global _last_broadcast_time
now = time.monotonic()
elapsed = now - _last_broadcast_time
if _last_broadcast_time > 0 and elapsed < _BROADCAST_COOLDOWN_SECONDS:
remaining = int(_BROADCAST_COOLDOWN_SECONDS - elapsed)
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
f'Broadcast cooldown active. Try again in {remaining} seconds.',
)
_last_broadcast_time = now
def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse:
return PinnedMessageResponse(
id=msg.id,
content=msg.content,
media_type=msg.media_type,
media_file_id=msg.media_file_id,
send_before_menu=msg.send_before_menu,
send_on_every_start=msg.send_on_every_start,
is_active=msg.is_active,
created_by=msg.created_by,
created_at=msg.created_at,
updated_at=msg.updated_at,
)
_cached_bot: Bot | None = None
def _get_bot() -> Bot:
global _cached_bot
if _cached_bot is None:
_cached_bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
return _cached_bot
# ============ List / Get Endpoints ============
@router.get('', response_model=PinnedMessageListResponse)
async def list_pinned_messages(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
active_only: bool = Query(False),
) -> PinnedMessageListResponse:
"""Get list of pinned messages with pagination."""
query = select(PinnedMessage).order_by(PinnedMessage.created_at.desc())
count_query = select(func.count(PinnedMessage.id))
if active_only:
query = query.where(PinnedMessage.is_active.is_(True))
count_query = count_query.where(PinnedMessage.is_active.is_(True))
total = await db.scalar(count_query) or 0
result = await db.execute(query.offset(offset).limit(limit))
items = result.scalars().all()
return PinnedMessageListResponse(
items=[_serialize_pinned_message(msg) for msg in items],
total=int(total),
limit=limit,
offset=offset,
)
@router.get('/active', response_model=PinnedMessageResponse | None)
async def get_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Get current active pinned message."""
msg = await get_active_pinned_message(db)
if not msg:
return None
return _serialize_pinned_message(msg)
@router.get('/{message_id}', response_model=PinnedMessageResponse)
async def get_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Get pinned message by ID."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
return _serialize_pinned_message(msg)
# ============ Create / Update Endpoints ============
@router.post('', response_model=PinnedMessageBroadcastResponse, status_code=status.HTTP_201_CREATED)
async def create_pinned_message(
payload: PinnedMessageCreateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Create a new pinned message.
Automatically deactivates previous active message.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if payload.broadcast:
_check_broadcast_cooldown()
content = payload.content.strip()
if not content and not payload.media:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Either content or media must be provided')
media_type = payload.media.type if payload.media else None
media_file_id = payload.media.file_id if payload.media else None
try:
msg = await set_active_pinned_message(
db=db,
content=content,
created_by=admin.id,
media_type=media_type,
media_file_id=media_file_id,
send_before_menu=payload.send_before_menu,
send_on_every_start=payload.send_on_every_start,
)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
sent_count = 0
failed_count = 0
if payload.broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} created pinned message #{msg.id} (broadcast={payload.broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.patch('/{message_id}', response_model=PinnedMessageResponse)
async def update_pinned_message(
message_id: int,
payload: PinnedMessageUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update a pinned message content, media, or settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.content is not None:
sanitized = sanitize_html(payload.content)
is_valid, error = validate_html_tags(sanitized)
if not is_valid:
raise HTTPException(status.HTTP_400_BAD_REQUEST, error)
msg.content = sanitized
if payload.media is not None:
msg.media_type = payload.media.type
msg.media_file_id = payload.media.file_id
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
logger.info(f'Admin {admin.id} updated pinned message #{message_id}')
return _serialize_pinned_message(msg)
@router.patch('/{message_id}/settings', response_model=PinnedMessageResponse)
async def update_pinned_message_settings(
message_id: int,
payload: PinnedMessageSettingsRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse:
"""Update only pinned message display settings."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if payload.send_before_menu is not None:
msg.send_before_menu = payload.send_before_menu
if payload.send_on_every_start is not None:
msg.send_on_every_start = payload.send_on_every_start
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
return _serialize_pinned_message(msg)
# ============ Active Message Actions (before /{message_id} POST routes) ============
@router.post('/active/deactivate', response_model=PinnedMessageResponse | None)
async def deactivate_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageResponse | None:
"""Deactivate the current active pinned message without unpinning from users."""
msg = await deactivate_active_pinned_message(db)
if not msg:
return None
logger.info(f'Admin {admin.id} deactivated pinned message #{msg.id}')
return _serialize_pinned_message(msg)
@router.post('/active/unpin', response_model=PinnedMessageUnpinResponse)
async def unpin_active_message(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageUnpinResponse:
"""Unpin messages from all users and deactivate the active pinned message."""
_check_broadcast_cooldown()
unpinned_count, failed_count, was_active = await unpin_active_pinned_message(_get_bot(), db)
if was_active:
logger.info(f'Admin {admin.id} unpinned active message: unpinned={unpinned_count}, failed={failed_count}')
return PinnedMessageUnpinResponse(
unpinned_count=unpinned_count,
failed_count=failed_count,
was_active=was_active,
)
# ============ Per-Message Actions ============
@router.post('/{message_id}/activate', response_model=PinnedMessageBroadcastResponse)
async def activate_pinned_message(
message_id: int,
broadcast: bool = Query(False),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""
Activate a pinned message.
Deactivates the current active message and activates the specified one.
If broadcast=true, sends to all active users immediately.
"""
# Проверяем cooldown ДО мутации в БД
if broadcast:
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
await db.execute(
update(PinnedMessage)
.where(PinnedMessage.is_active.is_(True))
.values(is_active=False, updated_at=datetime.utcnow())
)
msg.is_active = True
msg.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(msg)
sent_count = 0
failed_count = 0
if broadcast:
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} activated pinned message #{message_id} (broadcast={broadcast})')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.post('/{message_id}/broadcast', response_model=PinnedMessageBroadcastResponse)
async def broadcast_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PinnedMessageBroadcastResponse:
"""Broadcast a pinned message to all active users."""
_check_broadcast_cooldown()
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
sent_count, failed_count = await broadcast_pinned_message(_get_bot(), db, msg)
logger.info(f'Admin {admin.id} broadcast pinned message #{message_id}: sent={sent_count}, failed={failed_count}')
return PinnedMessageBroadcastResponse(
message=_serialize_pinned_message(msg),
sent_count=sent_count,
failed_count=failed_count,
)
@router.delete('/{message_id}', status_code=status.HTTP_204_NO_CONTENT, response_model=None)
async def delete_pinned_message(
message_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> None:
"""Delete a pinned message. Active messages must be deactivated first."""
result = await db.execute(select(PinnedMessage).where(PinnedMessage.id == message_id))
msg = result.scalar_one_or_none()
if not msg:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'Pinned message not found')
if msg.is_active:
raise HTTPException(
status.HTTP_409_CONFLICT,
'Cannot delete active pinned message. Deactivate it first.',
)
await db.delete(msg)
await db.commit()
logger.info(f'Admin {admin.id} deleted pinned message #{message_id}')
+16 -1
View File
@@ -575,9 +575,24 @@ async def broadcast_offer(
notifications_failed = 0
if payload.send_notification and offers_to_notify:
# Render placeholders in custom message text
rendered_message_text = payload.message_text
if rendered_message_text:
extra = payload.extra_data or {}
try:
rendered_message_text = rendered_message_text.format(
discount_percent=payload.discount_percent,
valid_hours=payload.valid_hours,
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
test_duration_hours=extra.get('test_duration_hours') or 0,
server_name=extra.get('server_name', ''),
)
except (KeyError, ValueError, IndexError):
logger.warning('Failed to render promo message placeholders')
notifications_sent, notifications_failed = await _send_promo_notifications(
offers_to_notify=offers_to_notify,
message_text=payload.message_text,
message_text=rendered_message_text,
button_text=payload.button_text,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
+60 -2
View File
@@ -363,13 +363,16 @@ async def create_promocode_endpoint(
if existing:
raise HTTPException(status.HTTP_400_BAD_REQUEST, 'Promo code with this code already exists')
# 0 means unlimited — convert to large number for is_valid check (current_uses < max_uses)
effective_max_uses = 999999 if payload.max_uses == 0 else payload.max_uses
promocode = await create_promocode(
db,
code=normalized_code,
type=payload.type,
balance_bonus_kopeks=payload.balance_bonus_kopeks,
subscription_days=payload.subscription_days,
max_uses=payload.max_uses,
max_uses=effective_max_uses,
valid_until=normalized_valid_until,
created_by=admin.id,
)
@@ -426,7 +429,7 @@ async def update_promocode_endpoint(
updates['subscription_days'] = payload.subscription_days
if payload.max_uses is not None:
updates['max_uses'] = payload.max_uses
updates['max_uses'] = 999999 if payload.max_uses == 0 else payload.max_uses
if payload.valid_from is not None:
updates['valid_from'] = _normalize_datetime(payload.valid_from)
@@ -472,6 +475,61 @@ async def delete_promocode_endpoint(
return Response(status_code=status.HTTP_204_NO_CONTENT)
class DeactivateDiscountResponse(BaseModel):
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
user_id: int
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
# ============== PromoGroup Endpoints ==============
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
+47
View File
@@ -1,6 +1,8 @@
"""Admin routes for statistics dashboard in cabinet."""
import logging
import sys
import time
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
@@ -22,12 +24,15 @@ from app.database.models import (
User,
)
from app.services.remnawave_service import RemnaWaveService
from app.services.version_service import version_service
from ..dependencies import get_cabinet_db, get_current_admin_user
logger = logging.getLogger(__name__)
_start_time = time.time()
router = APIRouter(prefix='/admin/stats', tags=['Cabinet Admin Stats'])
@@ -142,6 +147,16 @@ class DashboardStats(BaseModel):
tariff_stats: TariffStats | None = None
class SystemInfoResponse(BaseModel):
"""System information for admin dashboard."""
bot_version: str
python_version: str
uptime_seconds: int
users_total: int
subscriptions_active: int
# ============ Extended Stats Schemas ============
@@ -309,6 +324,38 @@ async def get_dashboard_stats(
)
@router.get('/system-info', response_model=SystemInfoResponse)
async def get_system_info(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get system information for admin dashboard."""
try:
users_total_result = await db.execute(select(func.count()).select_from(User))
users_total = users_total_result.scalar() or 0
subs_active_result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
subscriptions_active = subs_active_result.scalar() or 0
return SystemInfoResponse(
bot_version=version_service.current_version,
python_version=sys.version.split()[0],
uptime_seconds=int(time.time() - _start_time),
users_total=users_total,
subscriptions_active=subscriptions_active,
)
except Exception as e:
logger.error(f'Failed to get system info: {e}')
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to load system information',
)
@router.get('/nodes', response_model=NodesOverview)
async def get_nodes_status(
admin: User = Depends(get_current_admin_user),
+20 -10
View File
@@ -14,6 +14,7 @@ from app.database.crud.tariff import (
get_tariff_by_id,
get_tariff_subscriptions_count,
load_period_prices_from_db,
reorder_tariffs,
set_tariff_promo_groups,
update_tariff,
)
@@ -29,6 +30,7 @@ from ..schemas.tariffs import (
TariffDetailResponse,
TariffListItem,
TariffListResponse,
TariffSortOrderRequest,
TariffStatsResponse,
TariffToggleResponse,
TariffTrialResponse,
@@ -157,6 +159,21 @@ async def get_available_servers(
]
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
return {'message': 'Tariff order updated successfully'}
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
@@ -371,7 +388,7 @@ async def update_existing_tariff(
# Update promo groups separately
if request.promo_group_ids is not None:
await set_tariff_promo_groups(db, tariff_id, request.promo_group_ids)
await set_tariff_promo_groups(db, tariff, request.promo_group_ids)
logger.info(f'Admin {admin.id} updated tariff {tariff_id}')
@@ -395,21 +412,14 @@ async def delete_existing_tariff(
detail='Tariff not found',
)
# Check if tariff has subscriptions
subs_count = await get_tariff_subscriptions_count(db, tariff_id)
if subs_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Cannot delete tariff with {subs_count} active subscriptions',
)
await delete_tariff(db, tariff)
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name}')
logger.info(f'Admin {admin.id} deleted tariff {tariff_id}: {tariff.name} (affected subscriptions: {subs_count})')
# Перезагружаем периоды из БД для синхронизации с ботом
await load_period_prices_from_db(db)
return {'message': 'Tariff deleted successfully'}
return {'message': 'Tariff deleted successfully', 'affected_subscriptions': subs_count}
@router.post('/{tariff_id}/toggle', response_model=TariffToggleResponse)
+115 -110
View File
@@ -220,12 +220,123 @@ async def get_ticket_stats(
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.get('', response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description='Page number'),
per_page: int = Query(20, ge=1, le=100, description='Items per page'),
status_filter: str | None = Query(None, alias='status', description='Filter by status'),
priority_filter: str | None = Query(None, alias='priority', description='Filter by priority'),
user_id: int | None = Query(None, description='Filter by user ID'),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
@@ -245,6 +356,10 @@ async def get_all_tickets(
query = query.where(Ticket.priority == priority_filter)
count_query = count_query.where(Ticket.priority == priority_filter)
if user_id:
query = query.where(Ticket.user_id == user_id)
count_query = count_query.where(Ticket.user_id == user_id)
# Get total count
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
@@ -490,113 +605,3 @@ async def update_ticket_priority(
user=user_info,
messages=messages_response,
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
+694
View File
@@ -0,0 +1,694 @@
"""Admin routes for traffic usage statistics."""
import asyncio
import csv
import io
import logging
import time
from datetime import UTC, datetime, timedelta
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import Subscription, Transaction, TransactionType, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
TrafficEnrichmentResponse,
TrafficNodeInfo,
TrafficUsageResponse,
UserTrafficEnrichment,
UserTrafficItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/traffic', tags=['Admin Traffic'])
_ALLOWED_PERIODS = frozenset({1, 3, 7, 14, 30})
_CONCURRENCY_LIMIT = 5 # Max parallel API calls to avoid rate limiting
# In-memory cache: {(start_str, end_str): (timestamp, aggregated_data, nodes_info)}
_traffic_cache: dict[tuple[str, str], tuple[float, dict[str, dict[str, int]], list[TrafficNodeInfo]]] = {}
_CACHE_TTL = 300 # 5 minutes
_cache_lock = asyncio.Lock()
# Valid sort fields for the GET endpoint
_SORT_FIELDS = frozenset({'total_bytes', 'full_name', 'tariff_name', 'device_limit', 'traffic_limit_gb'})
_ENRICHMENT_SORT_FIELDS = frozenset({'connected', 'total_spent', 'sub_start', 'sub_end', 'last_node'})
def _get_status(sub) -> str | None:
"""Get subscription status via actual_status property."""
return sub.actual_status
def _validate_period(period: int) -> None:
if period not in _ALLOWED_PERIODS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Period must be one of: {sorted(_ALLOWED_PERIODS)}',
)
async def _aggregate_traffic(
start_str: str, end_str: str, user_uuids: list[str]
) -> tuple[dict[str, dict[str, int]], list[TrafficNodeInfo]]:
"""Aggregate per-user traffic across all nodes for a given date range.
Uses legacy per-node endpoint to fetch all users' traffic per node —
O(nodes) API calls instead of O(users). The legacy endpoint returns
{userUuid, nodeUuid, total} per entry (non-legacy only returns topUsers
without userUuid).
Returns (user_traffic, nodes_info) where:
user_traffic = {remnawave_uuid: {node_uuid: total_bytes, ...}}
nodes_info = [TrafficNodeInfo, ...]
"""
cache_key = (start_str, end_str)
# Quick check without lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
# Acquire lock for the slow path
async with _cache_lock:
# Re-check after acquiring lock
now = time.time()
cached = _traffic_cache.get(cache_key)
if cached and (now - cached[0]) < _CACHE_TTL:
return cached[1], cached[2]
service = RemnaWaveService()
if not service.is_configured:
return {}, []
user_uuids_set = set(user_uuids)
async with service.get_api_client() as api:
nodes = await api.get_all_nodes()
# Fetch per-node user stats — O(nodes) calls instead of O(users)
semaphore = asyncio.Semaphore(_CONCURRENCY_LIMIT)
async def fetch_node_users(node):
async with semaphore:
try:
stats = await api.get_bandwidth_stats_node_users_legacy(node.uuid, start_str, end_str)
return node.uuid, stats
except Exception:
logger.warning('Failed to get traffic for node %s', node.name, exc_info=True)
return node.uuid, None
results = await asyncio.gather(*(fetch_node_users(n) for n in nodes))
nodes_info: list[TrafficNodeInfo] = [
TrafficNodeInfo(node_uuid=node.uuid, node_name=node.name, country_code=node.country_code) for node in nodes
]
nodes_info.sort(key=lambda n: n.node_name)
# Legacy response: [{userUuid, username, nodeUuid, total, date}, ...]
user_traffic: dict[str, dict[str, int]] = {}
for node_uuid, entries in results:
if not isinstance(entries, list):
continue
for entry in entries:
uid = entry.get('userUuid', '')
total = int(entry.get('total', 0))
if uid and total > 0 and uid in user_uuids_set:
user_traffic.setdefault(uid, {})[node_uuid] = user_traffic.get(uid, {}).get(node_uuid, 0) + total
_traffic_cache[cache_key] = (now, user_traffic, nodes_info)
# Evict expired entries to prevent unbounded growth
expired = [k for k, (ts, _, _) in _traffic_cache.items() if (now - ts) >= _CACHE_TTL]
for k in expired:
del _traffic_cache[k]
return user_traffic, nodes_info
def _compute_date_range(period_days: int) -> tuple[str, str]:
"""Compute ISO date-time range from period days.
Truncates to 5-minute intervals for stable cache keys.
"""
end_dt = datetime.now(UTC).replace(second=0, microsecond=0)
end_dt = end_dt.replace(minute=(end_dt.minute // 5) * 5)
start_dt = end_dt - timedelta(days=period_days)
return start_dt.strftime('%Y-%m-%dT%H:%M:%SZ'), end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
async def _load_user_map(db: AsyncSession) -> dict[str, User]:
"""Load all users with remnawave_uuid, eagerly loading subscription + tariff."""
stmt = (
select(User)
.where(User.remnawave_uuid.isnot(None))
.options(selectinload(User.subscription).selectinload(Subscription.tariff))
)
result = await db.execute(stmt)
users = result.scalars().all()
return {u.remnawave_uuid: u for u in users if u.remnawave_uuid}
def _build_traffic_items(
user_traffic: dict[str, dict[str, int]],
user_map: dict[str, User],
nodes_info: list[TrafficNodeInfo],
search: str = '',
sort_by: str = 'total_bytes',
sort_desc: bool = True,
tariff_filter: set[str] | None = None,
status_filter: set[str] | None = None,
node_filter: set[str] | None = None,
) -> list[UserTrafficItem]:
"""Merge traffic data with user data, apply search/tariff/status/node filters, return sorted list."""
items: list[UserTrafficItem] = []
search_lower = search.lower().strip()
all_uuids = set(user_traffic.keys()) | set(user_map.keys())
for uuid in all_uuids:
user = user_map.get(uuid)
if not user:
continue
traffic = user_traffic.get(uuid, {})
full_name = user.full_name
username = user.username
email = user.email
if search_lower:
if (
search_lower not in (full_name or '').lower()
and search_lower not in (username or '').lower()
and search_lower not in (email or '').lower()
):
continue
sub = user.subscription
tariff_name = None
subscription_status = None
traffic_limit_gb = 0.0
device_limit = 1
if sub:
subscription_status = _get_status(sub)
traffic_limit_gb = float(sub.traffic_limit_gb or 0)
device_limit = sub.device_limit or 1
if sub.tariff:
tariff_name = sub.tariff.name
if tariff_filter is not None:
if (tariff_name or '') not in tariff_filter:
continue
if status_filter is not None:
if (subscription_status or '') not in status_filter:
continue
# Apply node filter: keep only selected nodes, recalculate total
if node_filter is not None:
traffic = {k: v for k, v in traffic.items() if k in node_filter}
total_bytes = sum(traffic.values())
items.append(
UserTrafficItem(
user_id=user.id,
telegram_id=user.telegram_id,
username=username,
email=email,
full_name=full_name,
tariff_name=tariff_name,
subscription_status=subscription_status,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
node_traffic=traffic,
total_bytes=total_bytes,
)
)
# Sort by the requested field; node columns use 'node_<uuid>' prefix
if sort_by.startswith('node_'):
node_uuid = sort_by[5:]
items.sort(key=lambda x: x.node_traffic.get(node_uuid, 0), reverse=sort_desc)
elif sort_by in ('full_name', 'tariff_name'):
items.sort(key=lambda x: (getattr(x, sort_by, None) or '').lower(), reverse=sort_desc)
else:
items.sort(key=lambda x: getattr(x, sort_by, 0) or 0, reverse=sort_desc)
return items
@router.get('', response_model=TrafficUsageResponse)
async def get_traffic_usage(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
period: int = Query(30, ge=1, le=30),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
search: str = Query('', max_length=100),
sort_by: str = Query('total_bytes', max_length=100),
sort_desc: bool = Query(True),
tariffs: str = Query('', max_length=500),
statuses: str = Query('', max_length=500),
nodes: str = Query('', max_length=2000),
start_date: str = Query('', max_length=10),
end_date: str = Query('', max_length=10),
):
"""Get paginated per-user traffic usage by node."""
# Determine date range: custom dates or period-based
if start_date.strip() and end_date.strip():
try:
start_dt = datetime.strptime(start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(end_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC, hour=23, minute=59, second=59)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
effective_period = (end_dt - start_dt).days or 1
else:
_validate_period(period)
start_str, end_str = _compute_date_range(period)
effective_period = period
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
# Collect all available tariff names (before filtering)
available_tariffs = sorted(
{
u.subscription.tariff.name
for u in user_map.values()
if u.subscription and u.subscription.tariff and u.subscription.tariff.name
}
)
# Collect all available statuses (before filtering)
available_statuses = sorted(
{_get_status(sub) for u in user_map.values() if (sub := u.subscription) and _get_status(sub)}
)
# Parse tariff filter
tariff_filter: set[str] | None = None
if tariffs.strip():
tariff_filter = {t.strip() for t in tariffs.split(',') if t.strip()}
# Parse status filter
status_filter: set[str] | None = None
if statuses.strip():
status_filter = {s.strip() for s in statuses.split(',') if s.strip()}
# Parse node filter
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if nodes.strip():
node_filter = {n.strip() for n in nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None # No valid nodes matched, treat as "all nodes"
# Validate sort_by: allow known fields + enrichment fields + 'node_<uuid>'
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
is_enrichment_sort = sort_by in _ENRICHMENT_SORT_FIELDS
if sort_by not in _SORT_FIELDS and not is_node_sort and not is_enrichment_sort:
sort_by = 'total_bytes'
# For enrichment sort, build items unsorted then sort by enrichment field
effective_sort = 'total_bytes' if is_enrichment_sort else sort_by
items = _build_traffic_items(
user_traffic, user_map, nodes_info, search, effective_sort, sort_desc, tariff_filter, status_filter, node_filter
)
if is_enrichment_sort:
enrichment_data = await _build_enrichment(db, user_map)
enr_key_map = {
'connected': lambda e: e.devices_connected,
'total_spent': lambda e: e.total_spent_kopeks,
'sub_start': lambda e: e.subscription_start_date or '',
'sub_end': lambda e: e.subscription_end_date or '',
'last_node': lambda e: e.last_node_name or '',
}
key_fn = enr_key_map[sort_by]
empty = UserTrafficEnrichment()
items.sort(key=lambda x: key_fn(enrichment_data.get(x.user_id, empty)), reverse=sort_desc)
total = len(items)
paginated = items[offset : offset + limit]
return TrafficUsageResponse(
items=paginated,
nodes=nodes_info,
total=total,
offset=offset,
limit=limit,
period_days=effective_period,
available_tariffs=available_tariffs,
available_statuses=available_statuses,
)
# ============== Enrichment endpoint ==============
_enrichment_cache: dict[str, tuple[float, dict[int, UserTrafficEnrichment]]] = {}
_ENRICHMENT_CACHE_TTL = 300 # 5 minutes
_enrichment_lock = asyncio.Lock()
async def _get_bulk_spending(db: AsyncSession, user_ids: list[int]) -> dict[int, int]:
"""Get total spent kopeks for multiple users in a single query."""
if not user_ids:
return {}
result = await db.execute(
select(Transaction.user_id, func.coalesce(func.sum(Transaction.amount_kopeks), 0))
.where(
and_(
Transaction.user_id.in_(user_ids),
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
)
)
.group_by(Transaction.user_id)
)
return {row[0]: int(row[1]) for row in result.all()}
async def _build_enrichment(db: AsyncSession, user_map: dict[str, User]) -> dict[int, UserTrafficEnrichment]:
"""Build enrichment data for all users: devices, spending, dates, last node."""
uuid_to_user_id: dict[str, int] = {}
for uuid, user in user_map.items():
uuid_to_user_id[uuid] = user.id
service = RemnaWaveService()
devices_by_user: dict[int, int] = {}
last_node_uuid_by_user: dict[int, str] = {}
node_uuid_to_name: dict[str, str] = {}
if service.is_configured:
async with service.get_api_client() as api:
# 3 bulk calls: nodes + users (paginated) + devices
try:
nodes_list = await api.get_all_nodes()
except Exception:
logger.warning('Failed to fetch nodes for enrichment', exc_info=True)
nodes_list = []
for node in nodes_list:
node_uuid_to_name[node.uuid] = node.name
# Fetch all panel users (paginated) for last connected node
panel_users = []
try:
first_page = await api.get_all_users(start=0, size=500)
panel_users.extend(first_page['users'])
total_panel = first_page['total']
if total_panel > 500:
remaining_tasks = [
api.get_all_users(start=offset, size=500) for offset in range(500, total_panel, 500)
]
pages = await asyncio.gather(*remaining_tasks, return_exceptions=True)
for page in pages:
if isinstance(page, dict):
panel_users.extend(page['users'])
except Exception:
logger.warning('Failed to fetch panel users for enrichment', exc_info=True)
for pu in panel_users:
uid = uuid_to_user_id.get(pu.uuid)
if uid is None:
continue
if pu.user_traffic and pu.user_traffic.last_connected_node_uuid:
last_node_uuid_by_user[uid] = pu.user_traffic.last_connected_node_uuid
# Bulk device fetch — single API call (paginated with start/size)
try:
devices_data = await api.get_all_hwid_devices()
for device in devices_data.get('devices', []):
user_uuid = device.get('userUuid', '')
uid = uuid_to_user_id.get(user_uuid)
if uid is not None:
devices_by_user[uid] = devices_by_user.get(uid, 0) + 1
except Exception:
logger.warning('Failed to fetch bulk devices for enrichment', exc_info=True)
# Bulk spending stats
all_user_ids = [u.id for u in user_map.values()]
spending_map = await _get_bulk_spending(db, all_user_ids)
# Build enrichment data
enrichment: dict[int, UserTrafficEnrichment] = {}
for uuid, user in user_map.items():
uid = user.id
sub = user.subscription
start_date = None
end_date = None
if sub:
if sub.start_date:
start_date = sub.start_date.isoformat()
if sub.end_date:
end_date = sub.end_date.isoformat()
last_node_name = None
last_uuid = last_node_uuid_by_user.get(uid)
if last_uuid:
last_node_name = node_uuid_to_name.get(last_uuid)
enrichment[uid] = UserTrafficEnrichment(
devices_connected=devices_by_user.get(uid, 0),
total_spent_kopeks=spending_map.get(uid, 0),
subscription_start_date=start_date,
subscription_end_date=end_date,
last_node_name=last_node_name,
)
return enrichment
@router.get('/enrichment', response_model=TrafficEnrichmentResponse)
async def get_traffic_enrichment(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Return enrichment data: device counts, spending, dates, last node."""
cache_key = 'enrichment'
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
async with _enrichment_lock:
now = time.time()
cached = _enrichment_cache.get(cache_key)
if cached and (now - cached[0]) < _ENRICHMENT_CACHE_TTL:
return TrafficEnrichmentResponse(data=cached[1])
user_map = await _load_user_map(db)
enrichment = await _build_enrichment(db, user_map)
_enrichment_cache[cache_key] = (now, enrichment)
# Evict expired
expired = [k for k, (ts, _) in _enrichment_cache.items() if (now - ts) >= _ENRICHMENT_CACHE_TTL]
for k in expired:
del _enrichment_cache[k]
return TrafficEnrichmentResponse(data=enrichment)
@router.post('/export-csv', response_model=ExportCsvResponse)
async def export_traffic_csv(
request: ExportCsvRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Generate CSV with traffic usage and send to admin's Telegram DM."""
if not admin.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Admin has no Telegram ID configured',
)
# Determine date range: custom dates or period-based
if request.start_date and request.end_date:
try:
start_dt = datetime.strptime(request.start_date.strip(), '%Y-%m-%d').replace(tzinfo=UTC)
end_dt = datetime.strptime(request.end_date.strip(), '%Y-%m-%d').replace(
tzinfo=UTC, hour=23, minute=59, second=59
)
except ValueError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid date format. Use YYYY-MM-DD.')
now = datetime.now(UTC)
end_dt = min(end_dt, now)
if start_dt > end_dt:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='start_date must be before end_date.')
if (end_dt - start_dt).days > 31:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Date range cannot exceed 31 days.')
start_str = start_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
end_str = end_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
period_label = f'{request.start_date}_{request.end_date}'
else:
_validate_period(request.period)
start_str, end_str = _compute_date_range(request.period)
period_label = f'{request.period}d'
user_map = await _load_user_map(db)
user_traffic, nodes_info = await _aggregate_traffic(start_str, end_str, list(user_map.keys()))
enrichment = await _build_enrichment(db, user_map)
# Parse filters
tariff_filter: set[str] | None = None
if request.tariffs and request.tariffs.strip():
tariff_filter = {t.strip() for t in request.tariffs.split(',') if t.strip()}
status_filter: set[str] | None = None
if request.statuses and request.statuses.strip():
status_filter = {s.strip() for s in request.statuses.split(',') if s.strip()}
node_filter: set[str] | None = None
all_node_uuids = {n.node_uuid for n in nodes_info}
if request.nodes and request.nodes.strip():
node_filter = {n.strip() for n in request.nodes.split(',') if n.strip()} & all_node_uuids
if not node_filter:
node_filter = None
items = _build_traffic_items(
user_traffic,
user_map,
nodes_info,
sort_by='total_bytes',
sort_desc=True,
tariff_filter=tariff_filter,
status_filter=status_filter,
node_filter=node_filter,
)
# Determine which nodes to include in CSV columns
csv_nodes = [n for n in nodes_info if n.node_uuid in node_filter] if node_filter else nodes_info
# Compute period days for risk calculation
if request.start_date and request.end_date:
period_days = max((end_dt - start_dt).days, 1)
else:
period_days = request.period
total_thr = request.total_threshold_gb or 0
node_thr = request.node_threshold_gb or 0
has_risk = total_thr > 0 or node_thr > 0
# Build CSV rows
rows: list[dict] = []
for item in items:
row: dict = {
'User ID': item.user_id,
'Telegram ID': item.telegram_id or '',
'Username': item.username or '',
'Email': item.email or '',
'Full Name': item.full_name,
'Tariff': item.tariff_name or '',
'Status': item.subscription_status or '',
'Traffic Limit (GB)': item.traffic_limit_gb,
'Device Limit': item.device_limit,
}
# Enrichment columns
enr = enrichment.get(item.user_id)
row['Connected Devices'] = enr.devices_connected if enr else 0
row['Total Spent (RUB)'] = round(enr.total_spent_kopeks / 100, 2) if enr else 0
row['Sub Start'] = enr.subscription_start_date or '' if enr else ''
row['Sub End'] = enr.subscription_end_date or '' if enr else ''
row['Last Node'] = enr.last_node_name or '' if enr else ''
for node in csv_nodes:
row[f'{node.node_name} (bytes)'] = item.node_traffic.get(node.node_uuid, 0)
row['Total (bytes)'] = item.total_bytes
row['Total (GB)'] = round(item.total_bytes / (1024**3), 2) if item.total_bytes else 0
if has_risk:
daily_total = item.total_bytes / period_days / (1024**3) if period_days > 0 else 0
row['Total GB/day'] = round(daily_total, 4)
total_ratio = daily_total / total_thr if total_thr > 0 else 0
max_node_ratio = 0.0
worst_node_daily = 0.0
for node_bytes in item.node_traffic.values():
if node_bytes > 0 and node_thr > 0:
daily_node = node_bytes / period_days / (1024**3) if period_days > 0 else 0
ratio = daily_node / node_thr
if ratio > max_node_ratio:
max_node_ratio = ratio
worst_node_daily = daily_node
ratio = max(total_ratio, max_node_ratio)
if ratio < 0.5:
risk_level = 'low'
elif ratio < 0.8:
risk_level = 'medium'
elif ratio < 1.2:
risk_level = 'high'
else:
risk_level = 'critical'
row['Risk Level'] = risk_level
row['Risk Ratio'] = round(ratio, 3)
row['Risk GB/day'] = round(daily_total if total_ratio >= max_node_ratio else worst_node_daily, 4)
rows.append(row)
# Generate CSV
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
csv_bytes = output.getvalue().encode('utf-8-sig')
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f'traffic_usage_{period_label}_{timestamp}.csv'
try:
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
async with bot:
await bot.send_document(
chat_id=admin.telegram_id,
document=BufferedInputFile(csv_bytes, filename=filename),
caption=f'Traffic usage report ({period_label})\nUsers: {len(rows)}',
)
except Exception:
logger.error('Failed to send CSV to admin %s', admin.telegram_id, exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to send CSV report. Please try again later.',
)
return ExportCsvResponse(success=True, message=f'CSV sent ({len(rows)} users)')
+139
View File
@@ -0,0 +1,139 @@
"""Admin routes for version and release information."""
import logging
from datetime import datetime, timedelta
import aiohttp
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from app.database.models import User
from app.services.version_service import version_service
from ..dependencies import get_current_admin_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/admin/updates', tags=['Cabinet Admin Updates'])
# ============ Schemas ============
class ReleaseItem(BaseModel):
tag_name: str
name: str
body: str
published_at: str
prerelease: bool
class ProjectReleasesInfo(BaseModel):
current_version: str
has_updates: bool
releases: list[ReleaseItem]
repo_url: str
class ReleasesResponse(BaseModel):
bot: ProjectReleasesInfo
cabinet: ProjectReleasesInfo
# ============ Cabinet releases cache ============
CABINET_REPO = 'BEDOLAGA-DEV/bedolaga-cabinet'
_cabinet_cache: dict = {}
_cabinet_last_check: datetime | None = None
_CACHE_TTL = 3600
async def _fetch_cabinet_releases(force: bool = False) -> list[dict]:
global _cabinet_last_check
if not force and _cabinet_cache.get('releases') and _cabinet_last_check:
if datetime.now() - _cabinet_last_check < timedelta(seconds=_CACHE_TTL):
return _cabinet_cache['releases']
url = f'https://api.github.com/repos/{CABINET_REPO}/releases'
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session, session.get(url) as response:
if response.status == 200:
data = await response.json()
releases = []
for item in data[:20]:
releases.append(
{
'tag_name': item['tag_name'],
'name': item.get('name') or item['tag_name'],
'body': item.get('body') or '',
'published_at': item['published_at'],
'prerelease': item.get('prerelease', False),
}
)
_cabinet_cache['releases'] = releases
_cabinet_last_check = datetime.now()
logger.info('Fetched %d cabinet releases from GitHub', len(releases))
return releases
logger.warning('GitHub API returned status %d for cabinet releases', response.status)
return _cabinet_cache.get('releases', [])
except TimeoutError:
logger.warning('Timeout fetching cabinet releases from GitHub')
return _cabinet_cache.get('releases', [])
except Exception as e:
logger.error('Error fetching cabinet releases: %s', e)
return _cabinet_cache.get('releases', [])
# ============ Routes ============
@router.get('/releases', response_model=ReleasesResponse)
async def get_releases(
current_user: User = Depends(get_current_admin_user),
) -> ReleasesResponse:
"""Get release information for bot and cabinet."""
# Bot releases
bot_releases_raw = await version_service._fetch_releases()
has_updates, _ = await version_service.check_for_updates()
bot_releases = [
ReleaseItem(
tag_name=r.tag_name,
name=r.name,
body=r.full_description,
published_at=r.published_at.isoformat(),
prerelease=r.prerelease,
)
for r in bot_releases_raw[:10]
]
bot_info = ProjectReleasesInfo(
current_version=version_service.current_version,
has_updates=has_updates,
releases=bot_releases,
repo_url=f'https://github.com/{version_service.repo}',
)
# Cabinet releases
cabinet_releases_raw = await _fetch_cabinet_releases()
cabinet_releases = [ReleaseItem(**r) for r in cabinet_releases_raw[:10]]
# Current version = latest non-prerelease tag
cabinet_current = ''
for r in cabinet_releases_raw:
if not r.get('prerelease', False):
cabinet_current = r['tag_name']
break
cabinet_info = ProjectReleasesInfo(
current_version=cabinet_current,
has_updates=False,
releases=cabinet_releases,
repo_url=f'https://github.com/{CABINET_REPO}',
)
return ReleasesResponse(bot=bot_info, cabinet=cabinet_info)
File diff suppressed because it is too large Load Diff
+252 -17
View File
@@ -7,18 +7,25 @@ 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
from app.database.crud.user import (
clear_email_change_pending,
create_user,
create_user_by_email,
get_user_by_id,
get_user_by_referral_code,
get_user_by_telegram_id,
is_email_taken,
set_email_change_pending,
verify_and_apply_email_change,
)
from app.database.models import CabinetRefreshToken, User
from app.services.disposable_email_service import disposable_email_service
from app.services.referral_service import process_referral_registration
from app.utils.timezone import panel_datetime_to_naive_utc
from ..auth import (
create_access_token,
@@ -30,8 +37,10 @@ from ..auth import (
verify_password,
)
from ..auth.email_verification import (
generate_email_change_code,
generate_password_reset_token,
generate_verification_token,
get_email_change_expires_at,
get_password_reset_expires_at,
get_verification_expires_at,
is_token_expired,
@@ -40,6 +49,9 @@ from ..auth.jwt_handler import get_refresh_token_expires_at
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.auth import (
AuthResponse,
EmailChangeRequest,
EmailChangeResponse,
EmailChangeVerifyRequest,
EmailLoginRequest,
EmailRegisterRequest,
EmailRegisterStandaloneRequest,
@@ -162,8 +174,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
existing_sub = await get_subscription_by_user_id(db, user.id)
# Parse panel data
expire_at = panel_user.expire_at
# Parse panel data — panel returns local time with misleading +00:00 offset
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
@@ -173,11 +185,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
# Device limit from panel
device_limit = panel_user.hwid_device_limit or 1
# Determine status - use timezone-aware datetime for comparison
current_time = datetime.now(UTC)
# Make expire_at timezone-aware if it's naive
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=UTC)
# Determine status — expire_at is now naive UTC
current_time = datetime.now(UTC).replace(tzinfo=None)
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
sub_status = SubscriptionStatus.ACTIVE
@@ -187,10 +196,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
sub_status = SubscriptionStatus.DISABLED
if existing_sub:
# Update existing subscription
# Convert to naive datetime for database storage
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
existing_sub.end_date = end_date_naive
# Update existing subscription (expire_at already naive UTC)
existing_sub.end_date = expire_at
existing_sub.traffic_limit_gb = traffic_limit_gb
existing_sub.traffic_used_gb = traffic_used_gb
existing_sub.status = sub_status.value
@@ -204,14 +211,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
)
else:
# Create new subscription
# Convert current_time to naive for database storage if needed
start_date_naive = current_time.replace(tzinfo=None)
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
# Create new subscription (expire_at and current_time already naive UTC)
new_sub = Subscription(
user_id=user.id,
start_date=start_date_naive,
end_date=end_date_naive,
start_date=current_time,
end_date=expire_at,
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
status=sub_status.value,
@@ -383,6 +387,13 @@ async def register_email(
Requires valid JWT token from Telegram authentication.
Sends verification email to the provided address.
"""
# Check for disposable email
if disposable_email_service.is_disposable(request.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Disposable email addresses are not allowed',
)
# Check if email already exists
existing_user = await db.execute(select(User).where(User.email == request.email))
if existing_user.scalar_one_or_none():
@@ -476,6 +487,13 @@ async def register_email_standalone(
)
logger.info(f'Test email registration: {request.email}')
# Check for disposable email
if disposable_email_service.is_disposable(request.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Disposable email addresses are not allowed',
)
# Проверить что email не занят
existing = await db.execute(select(User).where(User.email == request.email))
if existing.scalar_one_or_none():
@@ -941,3 +959,220 @@ async def check_is_admin(
"""Check if current user is an admin."""
is_admin = settings.is_admin(telegram_id=user.telegram_id, email=user.email if user.email_verified else None)
return {'is_admin': is_admin}
@router.post('/email/change', response_model=EmailChangeResponse)
async def request_email_change(
request: EmailChangeRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Request email change.
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.
"""
if not user.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No email address to change',
)
# Check if new email is the same as current
if request.new_email.lower() == user.email.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='New email is the same as current email',
)
# Check for disposable email
if disposable_email_service.is_disposable(request.new_email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Disposable email addresses are not allowed',
)
# Check if new email is already taken
if await is_email_taken(db, request.new_email, exclude_user_id=user.id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
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()
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
# Save pending email change
await set_email_change_pending(db, user, request.new_email, code, expires_at)
# Send verification email to new address
if email_service.is_configured():
lang = user.language or 'ru'
# Check for admin template override
override = await get_rendered_override(
'email_change_code',
lang,
context={
'username': user.first_name or '',
'code': code,
'expire_minutes': str(expire_minutes),
},
db=db,
)
custom_subject, custom_body = override if override else (None, None)
await asyncio.to_thread(
email_service.send_email_change_code,
to_email=request.new_email,
code=code,
username=user.first_name,
language=lang,
custom_subject=custom_subject,
custom_body_html=custom_body,
)
else:
# Clear pending change if email service is not configured
await clear_email_change_pending(db, user)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Email service is not configured',
)
logger.info(f'Email change requested for user {user.id}: {user.email} -> {request.new_email}')
return EmailChangeResponse(
message='Verification code sent to new email',
new_email=request.new_email,
expires_in_minutes=expire_minutes,
)
@router.post('/email/change/verify')
async def verify_email_change(
request: EmailChangeVerifyRequest,
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Verify email change with code.
Completes the email change process if the code is valid.
"""
success, message = await verify_and_apply_email_change(db, user, request.code)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message,
)
return {
'message': message,
'new_email': user.email,
}
@router.post('/email/change/cancel')
async def cancel_email_change(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Cancel pending email change.
"""
if not user.email_change_new:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='No pending email change',
)
await clear_email_change_pending(db, user)
return {'message': 'Email change cancelled'}
@router.get('/email/change/status')
async def get_email_change_status(
user: User = Depends(get_current_cabinet_user),
):
"""
Get pending email change status.
"""
if not user.email_change_new:
return {
'pending': False,
'new_email': None,
'expires_at': None,
}
return {
'pending': True,
'new_email': user.email_change_new,
'expires_at': user.email_change_expires.isoformat() if user.email_change_expires else None,
}
+156 -154
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,7 @@ 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 (
SUPPORTED_MANUAL_CHECK_METHODS,
@@ -22,7 +23,7 @@ from app.services.payment_verification_service import (
method_display_name,
run_manual_check,
)
from app.services.yookassa_service import YooKassaService
from app.utils.currency_converter import currency_converter
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.balance import (
@@ -127,134 +128,82 @@ async def get_transactions(
)
async def _get_available_payment_methods(
db: AsyncSession,
user: User,
) -> list[PaymentMethodResponse]:
"""Get available payment methods filtered by DB config and user context.
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
async def get_payment_methods(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available payment methods for the current user.
Combines env-var availability with DB-based admin config (ordering, display conditions).
Uses PaymentMethodConfig from database for:
- Sort order (sort_order)
- Enabled/disabled status (is_enabled)
- Display names (display_name with fallback to env)
- Min/max amounts (with fallback to env defaults)
- Sub-options filtering (sub_options)
- User filters (user_type_filter, first_topup_filter, promo_group_filter)
"""
from app.services.payment_method_config_service import (
_get_method_defaults,
get_all_configs,
# Check if this is user's first topup
from sqlalchemy import exists
has_completed_topup = await db.execute(
select(
exists().where(
Transaction.user_id == user.id,
Transaction.type == 'deposit',
Transaction.is_completed == True,
)
)
)
is_first_topup = not has_completed_topup.scalar()
configs = await get_all_configs(db)
defaults = _get_method_defaults()
# Get enabled methods from database config
enabled_methods = await get_enabled_methods_for_user(db, user=user, is_first_topup=is_first_topup)
# Provider availability checks from env vars
provider_enabled = {
'telegram_stars': settings.TELEGRAM_STARS_ENABLED,
'tribute': settings.TRIBUTE_ENABLED and bool(getattr(settings, 'TRIBUTE_DONATE_LINK', '')),
'cryptobot': settings.is_cryptobot_enabled(),
'heleket': settings.is_heleket_enabled(),
'yookassa': settings.is_yookassa_enabled(),
'mulenpay': settings.is_mulenpay_enabled(),
'pal24': settings.is_pal24_enabled(),
'platega': settings.is_platega_enabled(),
'wata': settings.is_wata_enabled(),
'freekassa': settings.is_freekassa_enabled(),
'cloudpayments': settings.is_cloudpayments_enabled(),
}
# Build response with additional options formatting
methods = []
for method_data in enabled_methods:
method_id = method_data['id']
# Default options builder (for methods with sub-options)
def _build_options(method_id: str, config_sub_options: dict | None) -> list[dict] | None:
if method_id == 'yookassa':
all_opts = [
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей (QR)'},
]
elif method_id == 'pal24':
all_opts = [
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
]
elif method_id == 'platega':
platega_methods = settings.get_platega_active_methods()
definitions = settings.get_platega_method_definitions()
all_opts = []
for method_code in platega_methods:
info = definitions.get(method_code, {})
all_opts.append(
# Format options with descriptions for specific methods
options = method_data.get('options')
if options:
formatted_options = []
for opt in options:
opt_id = opt['id']
opt_name = opt.get('name', opt_id)
description = ''
# Add descriptions based on method and option
if method_id in ('yookassa', 'pal24', 'cloudpayments', 'freekassa'):
if opt_id == 'card':
opt_name = f'💳 {opt_name}'
description = 'Банковская карта'
elif opt_id == 'sbp':
opt_name = f'🏦 {opt_name}'
description = 'Система быстрых платежей'
elif method_id == 'platega':
# Platega options already have descriptions from config
definitions = settings.get_platega_method_definitions()
info = definitions.get(int(opt_id), {}) if opt_id.isdigit() else {}
description = info.get('description') or info.get('name') or ''
formatted_options.append(
{
'id': str(method_code),
'name': info.get('title') or info.get('name') or f'Platega {method_code}',
'description': info.get('description') or info.get('name') or '',
'id': opt_id,
'name': opt_name,
'description': description,
}
)
elif method_id == 'freekassa':
all_opts = [
{'id': 'sbp', 'name': '🏦 NSPK СБП', 'description': 'Система быстрых платежей'},
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
]
elif method_id == 'cloudpayments':
all_opts = [
{'id': 'card', 'name': '💳 Карта', 'description': 'Банковская карта'},
{'id': 'sbp', 'name': '🏦 СБП', 'description': 'Система быстрых платежей'},
]
else:
return None
if not all_opts:
return None
# Filter by sub_options config from DB
if config_sub_options:
all_opts = [o for o in all_opts if config_sub_options.get(o['id'], True)]
return all_opts if all_opts else None
# User promo group IDs for filtering
user_promo_group_ids: set[int] = set()
if hasattr(user, 'user_promo_groups') and user.user_promo_groups:
for upg in user.user_promo_groups:
user_promo_group_ids.add(upg.promo_group_id)
if hasattr(user, 'promo_group_id') and user.promo_group_id:
user_promo_group_ids.add(user.promo_group_id)
methods = []
for config in configs:
mid = config.method_id
# 1. Check env-var provider availability AND DB admin toggle
if not provider_enabled.get(mid, False):
continue
if not config.is_enabled:
continue
# 2. Check user type filter
if config.user_type_filter == 'telegram' and user.auth_type != 'telegram':
continue
if config.user_type_filter == 'email' and user.auth_type != 'email':
continue
# 3. Check first topup filter
if config.first_topup_filter == 'yes' and not user.has_made_first_topup:
continue
if config.first_topup_filter == 'no' and user.has_made_first_topup:
continue
# 4. Check promo group filter
if config.promo_group_filter_mode == 'selected' and config.allowed_promo_groups:
allowed_ids = {pg.id for pg in config.allowed_promo_groups}
if not user_promo_group_ids.intersection(allowed_ids):
continue
# Build the response
method_def = defaults.get(mid, {})
display_name = config.display_name or method_def.get('default_display_name', mid)
min_amount = config.min_amount_kopeks or method_def.get('default_min', 1000)
max_amount = config.max_amount_kopeks or method_def.get('default_max', 10000000)
options = _build_options(mid, config.sub_options)
options = formatted_options if formatted_options else None
methods.append(
PaymentMethodResponse(
id=mid,
name=display_name,
id=method_id,
name=method_data['name'],
description=None,
min_amount_kopeks=min_amount,
max_amount_kopeks=max_amount,
min_amount_kopeks=method_data['min_amount_kopeks'],
max_amount_kopeks=method_data['max_amount_kopeks'],
is_available=True,
options=options,
)
@@ -263,15 +212,6 @@ async def _get_available_payment_methods(
return methods
@router.get('/payment-methods', response_model=list[PaymentMethodResponse])
async def get_payment_methods(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get available payment methods."""
return await _get_available_payment_methods(db, user)
@router.post('/stars-invoice', response_model=StarsInvoiceResponse)
async def create_stars_invoice(
request: StarsInvoiceRequest,
@@ -373,7 +313,7 @@ async def create_topup(
):
"""Create payment for balance top-up."""
# Validate payment method
methods = await _get_available_payment_methods(db, user)
methods = await get_payment_methods(user=user, db=db)
method = next((m for m in methods if m.id == request.payment_method), None)
if not method or not method.is_available:
@@ -401,13 +341,11 @@ async def create_topup(
try:
if request.payment_method == 'yookassa':
yookassa_service = YooKassaService()
payment_service = PaymentService()
yookassa_metadata = {
'user_id': str(user.id),
'user_telegram_id': str(user.telegram_id) if user.telegram_id else '',
'user_username': user.username or '',
'amount_kopeks': str(request.amount_kopeks),
'type': 'balance_topup',
'purpose': 'balance_topup',
'source': 'cabinet',
}
@@ -418,25 +356,25 @@ async def create_topup(
request.amount_kopeks, telegram_user_id=user.telegram_id
)
if option == 'sbp':
# Create SBP payment with QR code
result = await yookassa_service.create_sbp_payment(
amount=amount_rubles,
currency='RUB',
result = await payment_service.create_yookassa_sbp_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
)
else:
# Default: card payment
result = await yookassa_service.create_payment(
amount=amount_rubles,
currency='RUB',
result = await payment_service.create_yookassa_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=description,
metadata=yookassa_metadata,
)
if result and not result.get('error'):
if result:
payment_url = result.get('confirmation_url')
payment_id = result.get('id')
payment_id = result.get('yookassa_payment_id')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -444,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,
@@ -700,6 +663,32 @@ async def create_topup(
detail='Failed to create FreeKassa payment',
)
elif request.payment_method == 'kassa_ai':
if not settings.is_kassa_ai_enabled():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='KassaAI payment method is unavailable',
)
payment_service = PaymentService()
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=user.id,
amount_kopeks=request.amount_kopeks,
description=settings.get_balance_payment_description(request.amount_kopeks),
email=getattr(user, 'email', None),
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
)
if result and result.get('payment_url'):
payment_url = result.get('payment_url')
payment_id = str(result.get('local_payment_id') or result.get('order_id') or 'pending')
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to create KassaAI payment',
)
elif request.payment_method == 'tribute':
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
raise HTTPException(
@@ -840,6 +829,17 @@ def _get_status_info(record: PendingPayment) -> tuple[str, str]:
}
return mapping.get(status, ('', 'Неизвестно'))
if record.method == PaymentMethod.KASSA_AI:
mapping = {
'pending': ('', 'Ожидает оплаты'),
'success': ('', 'Оплачено'),
'paid': ('', 'Оплачено'),
'canceled': ('', 'Отменено'),
'failed': ('', 'Ошибка'),
'expired': ('', 'Истёк'),
}
return mapping.get(status, ('', 'Неизвестно'))
return '', 'Неизвестно'
@@ -868,6 +868,8 @@ def _is_checkable(record: PendingPayment) -> bool:
return status in {'pending', 'authorized'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', 'created', 'processing'}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing'}
return False
@@ -891,7 +893,7 @@ def _get_payment_url(record: PendingPayment) -> str | None:
)
elif record.method == PaymentMethod.PLATEGA:
payment_url = getattr(payment, 'redirect_url', None) or payment_url
elif record.method == PaymentMethod.CLOUDPAYMENTS or record.method == PaymentMethod.FREEKASSA:
elif record.method in (PaymentMethod.CLOUDPAYMENTS, PaymentMethod.FREEKASSA, PaymentMethod.KASSA_AI):
payment_url = getattr(payment, 'payment_url', None) or payment_url
return payment_url
+49
View File
@@ -36,6 +36,7 @@ EMAIL_AUTH_ENABLED_KEY = 'CABINET_EMAIL_AUTH_ENABLED' # Stores "true" or "false
YANDEX_METRIKA_ID_KEY = 'CABINET_YANDEX_METRIKA_ID' # Stores counter ID (numeric string)
GOOGLE_ADS_ID_KEY = 'CABINET_GOOGLE_ADS_ID' # Stores conversion ID (e.g. "AW-123456789")
GOOGLE_ADS_LABEL_KEY = 'CABINET_GOOGLE_ADS_LABEL' # Stores conversion label (alphanumeric)
LITE_MODE_ENABLED_KEY = 'CABINET_LITE_MODE_ENABLED' # Stores "true" or "false"
# Allowed image types
ALLOWED_CONTENT_TYPES = {'image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'image/svg+xml'}
@@ -144,6 +145,18 @@ class EmailAuthEnabledUpdate(BaseModel):
enabled: bool
class LiteModeEnabledResponse(BaseModel):
"""Lite mode enabled setting."""
enabled: bool = False
class LiteModeEnabledUpdate(BaseModel):
"""Request to update lite mode setting."""
enabled: bool
class AnalyticsCountersResponse(BaseModel):
"""Analytics counter settings."""
@@ -718,3 +731,39 @@ async def update_analytics_counters(
google_ads_id=google_id,
google_ads_label=google_label,
)
# ============ Lite Mode Routes ============
@router.get('/lite-mode', response_model=LiteModeEnabledResponse)
async def get_lite_mode_enabled(
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Get lite mode enabled setting.
This is a public endpoint - no authentication required.
When enabled, shows simplified dashboard with minimal features.
"""
lite_mode_value = await get_setting_value(db, LITE_MODE_ENABLED_KEY)
if lite_mode_value is not None:
enabled = lite_mode_value.lower() == 'true'
return LiteModeEnabledResponse(enabled=enabled)
# Default: disabled
return LiteModeEnabledResponse(enabled=False)
@router.patch('/lite-mode', response_model=LiteModeEnabledResponse)
async def update_lite_mode_enabled(
payload: LiteModeEnabledUpdate,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update lite mode enabled setting. Admin only."""
await set_setting_value(db, LITE_MODE_ENABLED_KEY, str(payload.enabled).lower())
logger.info(f'Admin {admin.telegram_id} set lite mode enabled: {payload.enabled}')
return LiteModeEnabledResponse(enabled=payload.enabled)
+39 -9
View File
@@ -20,6 +20,30 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix='/info', tags=['Cabinet Info'])
_LANGUAGE_META: dict[str, tuple[str, str]] = {
'ru': ('Русский', '🇷🇺'),
'en': ('English', '🇬🇧'),
'ua': ('Українська', '🇺🇦'),
'zh': ('中文', '🇨🇳'),
'fa': ('فارسی', '🇮🇷'),
}
def _normalize_language_code(value: str | None) -> str:
return (value or '').strip().lower().split('-', 1)[0]
def _get_available_language_codes() -> list[str]:
codes: list[str] = []
seen: set[str] = set()
for code in settings.get_available_languages():
normalized = _normalize_language_code(code)
if not normalized or normalized in seen:
continue
seen.add(normalized)
codes.append(normalized)
return codes
# ============ Schemas ============
@@ -212,12 +236,19 @@ async def get_service_info():
@router.get('/languages')
async def get_available_languages():
"""Get list of available languages."""
codes = _get_available_language_codes()
default_language = _normalize_language_code(getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru')
return {
'languages': [
{'code': 'ru', 'name': 'Русский', 'flag': '🇷🇺'},
{'code': 'en', 'name': 'English', 'flag': '🇬🇧'},
{
'code': code,
'name': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[0],
'flag': _LANGUAGE_META.get(code, (code.upper(), '🌐'))[1],
}
for code in codes
],
'default': getattr(settings, 'DEFAULT_LANGUAGE', 'ru') or 'ru',
'default': default_language,
}
@@ -236,16 +267,15 @@ async def update_user_language(
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update user's language preference."""
language = request.get('language', 'ru')
valid_languages = ['ru', 'en']
if language not in valid_languages:
requested_language = _normalize_language_code(request.get('language', 'ru'))
available_languages = _get_available_language_codes()
if requested_language not in available_languages:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid language. Supported: {", ".join(valid_languages)}',
detail=f'Invalid language. Supported: {", ".join(available_languages)}',
)
user.language = language
user.language = requested_language
await db.commit()
await db.refresh(user)
+165
View File
@@ -0,0 +1,165 @@
"""OAuth 2.0 authentication routes for cabinet."""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import (
create_user_by_oauth,
get_user_by_email,
get_user_by_oauth_provider,
set_user_oauth_provider_id,
)
from app.database.models import User
from ..auth.oauth_providers import (
OAuthUserInfo,
generate_oauth_state,
get_provider,
validate_oauth_state,
)
from ..dependencies import get_cabinet_db
from ..schemas.auth import AuthResponse
from .auth import _create_auth_response, _store_refresh_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix='/auth/oauth', tags=['Cabinet OAuth'])
async def _finalize_oauth_login(db: AsyncSession, user: User, provider: str) -> AuthResponse:
"""Update last login, create tokens, store refresh token."""
user.cabinet_last_login = datetime.now(UTC).replace(tzinfo=None)
await db.commit()
auth_response = _create_auth_response(user)
await _store_refresh_token(db, user.id, auth_response.refresh_token, device_info=f'oauth:{provider}')
return auth_response
# --- Schemas ---
class OAuthProviderInfo(BaseModel):
name: str
display_name: str
class OAuthProvidersResponse(BaseModel):
providers: list[OAuthProviderInfo]
class OAuthAuthorizeResponse(BaseModel):
authorize_url: str
state: str
class OAuthCallbackRequest(BaseModel):
code: str = Field(..., description='Authorization code from provider')
state: str = Field(..., description='CSRF state token')
# --- Endpoints ---
@router.get('/providers', response_model=OAuthProvidersResponse)
async def get_oauth_providers():
"""Get list of enabled OAuth providers."""
providers_config = settings.get_oauth_providers_config()
providers = [
OAuthProviderInfo(name=name, display_name=cfg['display_name'])
for name, cfg in providers_config.items()
if cfg['enabled']
]
return OAuthProvidersResponse(providers=providers)
@router.get('/{provider}/authorize', response_model=OAuthAuthorizeResponse)
async def get_oauth_authorize_url(provider: str):
"""Get authorization URL for an OAuth provider."""
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
state = await generate_oauth_state(provider)
authorize_url = oauth_provider.get_authorization_url(state)
return OAuthAuthorizeResponse(authorize_url=authorize_url, state=state)
@router.post('/{provider}/callback', response_model=AuthResponse)
async def oauth_callback(
provider: str,
request: OAuthCallbackRequest,
db: AsyncSession = Depends(get_cabinet_db),
):
"""Handle OAuth callback: exchange code, find/create user, return JWT."""
# 1. Validate CSRF state
if not await validate_oauth_state(request.state, provider):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid or expired OAuth state',
)
# 2. Get provider instance
oauth_provider = get_provider(provider)
if not oauth_provider:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'OAuth provider "{provider}" is not enabled',
)
# 3. Exchange code for tokens
try:
token_data = await oauth_provider.exchange_code(request.code)
except Exception as exc:
logger.error('OAuth code exchange failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to exchange authorization code',
) from exc
# 4. Fetch user info from provider
try:
user_info: OAuthUserInfo = await oauth_provider.get_user_info(token_data)
except Exception as exc:
logger.error('OAuth user info fetch failed for %s: %s', provider, exc)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Failed to fetch user information from provider',
) from exc
# 5. Find user by provider ID
user = await get_user_by_oauth_provider(db, provider, user_info.provider_id)
if user:
logger.info('OAuth login via %s for existing user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 6. Find user by email (if verified) and link provider
if user_info.email and user_info.email_verified:
user = await get_user_by_email(db, user_info.email)
if user:
await set_user_oauth_provider_id(db, user, provider, user_info.provider_id)
logger.info('OAuth login via %s linked to existing email user %s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
# 7. Create new user
user = await create_user_by_oauth(
db=db,
provider=provider,
provider_id=user_info.provider_id,
email=user_info.email if user_info.email_verified else None,
email_verified=user_info.email_verified,
first_name=user_info.first_name,
last_name=user_info.last_name,
username=user_info.username,
)
logger.info('OAuth new user created via %s with id=%s', provider, user.id)
return await _finalize_oauth_login(db, user, provider)
+49
View File
@@ -33,6 +33,15 @@ class PromocodeActivateResponse(BaseModel):
bonus_description: str | None = None
class PromocodeDeactivateResponse(BaseModel):
"""Response after deactivating a discount promo code."""
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
@router.post('/activate', response_model=PromocodeActivateResponse)
async def activate_promocode(
request: PromocodeActivateRequest,
@@ -62,6 +71,8 @@ async def activate_promocode(
'expired': 'Promo code has expired',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
'not_first_purchase': 'This promo code is only available for first purchase',
'user_not_found': 'User not found',
'server_error': 'Server error occurred',
}
@@ -73,3 +84,41 @@ async def activate_promocode(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
async def deactivate_discount_promocode(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromocodeDeactivateResponse:
"""Deactivate the currently active discount promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.deactivate_discount_promocode(
db=db,
user_id=user.id,
admin_initiated=False,
)
if result['success']:
return PromocodeDeactivateResponse(
success=True,
message='Discount promo code deactivated successfully',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'No active discount promo code found',
'discount_already_expired': 'Discount has already expired',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
File diff suppressed because it is too large Load Diff
+38
View File
@@ -374,6 +374,44 @@ async def notify_user_subscription_renewed(
)
async def notify_user_devices_purchased(
user_id: int,
devices_added: int,
new_device_limit: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке устройств."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.devices_purchased',
'devices_added': devices_added,
'new_device_limit': new_device_limit,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
async def notify_user_traffic_purchased(
user_id: int,
traffic_gb_added: int,
new_traffic_limit_gb: int,
amount_kopeks: int,
) -> None:
"""Уведомить пользователя о покупке трафика."""
await cabinet_ws_manager.send_to_user(
user_id,
{
'type': 'subscription.traffic_purchased',
'traffic_gb_added': traffic_gb_added,
'new_traffic_limit_gb': new_traffic_limit_gb,
'amount_kopeks': amount_kopeks,
'amount_rubles': amount_kopeks / 100,
},
)
# ============================================================================
# Уведомления об автопродлении
# ============================================================================
+20
View File
@@ -118,3 +118,23 @@ class RegisterResponse(BaseModel):
message: str = Field(..., description='Success message')
email: str = Field(..., description='Email address to verify')
requires_verification: bool = Field(True, description='Whether email verification is required')
class EmailChangeRequest(BaseModel):
"""Request to initiate email change."""
new_email: EmailStr = Field(..., description='New email address')
class EmailChangeVerifyRequest(BaseModel):
"""Request to verify email change with code."""
code: str = Field(..., min_length=6, max_length=6, description='6-digit verification code')
class EmailChangeResponse(BaseModel):
"""Response for email change initiation."""
message: str = Field(..., description='Success message')
new_email: str = Field(..., description='New email address pending verification')
expires_in_minutes: int = Field(..., description='Code expiration time in minutes')
+66 -1
View File
@@ -1,10 +1,16 @@
"""Pydantic schemas for cabinet broadcasts."""
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
# ============ Channel Types ============
BroadcastChannel = Literal['telegram', 'email', 'both']
# ============ Filters ============
@@ -100,7 +106,7 @@ class BroadcastResponse(BaseModel):
id: int
target_type: str
message_text: str
message_text: str | None = None
has_media: bool
media_type: str | None = None
media_file_id: str | None = None
@@ -115,6 +121,11 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
email_html_content: str | None = None
class Config:
from_attributes = True
@@ -142,3 +153,57 @@ class BroadcastPreviewResponse(BaseModel):
target: str
count: int
# ============ Email Filters ============
class EmailFilterItem(BaseModel):
"""Single email filter with count."""
key: str
label: str
count: int
group: str | None = None
class EmailFiltersResponse(BaseModel):
"""Response with all email filters and their counts."""
filters: list[EmailFilterItem]
total_with_email: int
# ============ Combined Broadcast ============
class CombinedBroadcastCreateRequest(BaseModel):
"""Request to create a combined (telegram/email/both) broadcast."""
channel: BroadcastChannel
target: str
# Telegram-specific fields
message_text: str | None = Field(default=None, max_length=4000)
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
media: BroadcastMediaRequest | None = None
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
# ============ Email Preview ============
class EmailPreviewRequest(BaseModel):
"""Request to preview email broadcast recipients."""
target: str
class EmailPreviewResponse(BaseModel):
"""Preview response for email broadcast."""
target: str
count: int
+64
View File
@@ -0,0 +1,64 @@
"""Pydantic schemas for cabinet pinned messages."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class PinnedMessageMedia(BaseModel):
type: str = Field(pattern=r'^(photo|video)$')
file_id: str = Field(..., min_length=1, max_length=255)
class PinnedMessageCreateRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=4000)
media: PinnedMessageMedia | None = None
send_before_menu: bool = True
send_on_every_start: bool = True
broadcast: bool = False
class PinnedMessageUpdateRequest(BaseModel):
content: str | None = Field(None, max_length=4000)
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
media: PinnedMessageMedia | None = None
class PinnedMessageSettingsRequest(BaseModel):
send_before_menu: bool | None = None
send_on_every_start: bool | None = None
class PinnedMessageResponse(BaseModel):
id: int
content: str | None
media_type: str | None = None
media_file_id: str | None = None
send_before_menu: bool
send_on_every_start: bool
is_active: bool
created_by: int | None = None
created_at: datetime
updated_at: datetime | None = None
class PinnedMessageBroadcastResponse(BaseModel):
message: PinnedMessageResponse
sent_count: int
failed_count: int
class PinnedMessageUnpinResponse(BaseModel):
unpinned_count: int
failed_count: int
was_active: bool
class PinnedMessageListResponse(BaseModel):
items: list[PinnedMessageResponse]
total: int
limit: int
offset: int
+12 -1
View File
@@ -24,7 +24,7 @@ class TrafficPurchaseInfo(BaseModel):
progress_percent: float
class SubscriptionResponse(BaseModel):
class SubscriptionData(BaseModel):
"""User subscription data."""
id: int
@@ -61,6 +61,17 @@ class SubscriptionResponse(BaseModel):
from_attributes = True
# Backward compatibility alias
SubscriptionResponse = SubscriptionData
class SubscriptionStatusResponse(BaseModel):
"""Response for subscription status endpoint - handles users with and without subscription."""
has_subscription: bool
subscription: SubscriptionData | None = None
class RenewalOptionResponse(BaseModel):
"""Available subscription renewal option."""
+6
View File
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffSortOrderRequest(BaseModel):
"""Request to reorder tariffs."""
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
class TariffToggleResponse(BaseModel):
"""Response after toggling tariff."""
+62
View File
@@ -0,0 +1,62 @@
"""Schemas for admin traffic usage."""
from pydantic import BaseModel, Field
class TrafficNodeInfo(BaseModel):
node_uuid: str
node_name: str
country_code: str
class UserTrafficItem(BaseModel):
user_id: int
telegram_id: int | None
username: str | None
email: str | None
full_name: str
tariff_name: str | None
subscription_status: str | None
traffic_limit_gb: float
device_limit: int
node_traffic: dict[str, int] # {node_uuid: total_bytes}
total_bytes: int
class TrafficUsageResponse(BaseModel):
items: list[UserTrafficItem]
nodes: list[TrafficNodeInfo]
total: int
offset: int
limit: int
period_days: int
available_tariffs: list[str]
available_statuses: list[str]
class UserTrafficEnrichment(BaseModel):
devices_connected: int = 0
total_spent_kopeks: int = 0
subscription_start_date: str | None = None
subscription_end_date: str | None = None
last_node_name: str | None = None
class TrafficEnrichmentResponse(BaseModel):
data: dict[int, UserTrafficEnrichment]
class ExportCsvRequest(BaseModel):
period: int = Field(30, ge=1, le=30)
start_date: str | None = None
end_date: str | None = None
tariffs: str | None = None
statuses: str | None = None
nodes: str | None = None
total_threshold_gb: float | None = Field(None, ge=0, description='Total GB/day threshold for risk column')
node_threshold_gb: float | None = Field(None, ge=0, description='Per-node GB/day threshold for risk column')
class ExportCsvResponse(BaseModel):
success: bool
message: str
+196
View File
@@ -39,6 +39,17 @@ class SortByEnum(str, Enum):
# === User Subscription Info ===
class TrafficPurchaseItem(BaseModel):
"""Individual traffic purchase record."""
id: int
traffic_gb: int
expires_at: datetime
created_at: datetime
days_remaining: int
is_expired: bool
class UserSubscriptionInfo(BaseModel):
"""User subscription information."""
@@ -55,6 +66,8 @@ class UserSubscriptionInfo(BaseModel):
autopay_enabled: bool = False
is_active: bool = False
days_remaining: int = 0
purchased_traffic_gb: int = 0
traffic_purchases: list[TrafficPurchaseItem] = []
class UserPromoGroupInfo(BaseModel):
@@ -189,9 +202,58 @@ class UserDetailResponse(BaseModel):
promo_offer_discount_source: str | None = None
promo_offer_discount_expires_at: datetime | None = None
# Campaign
campaign_name: str | None = None
campaign_id: int | None = None
# Recent transactions
recent_transactions: list[UserTransactionItem] = []
# Remnawave UUID
remnawave_uuid: str | None = None
# === Panel Info ===
class UserPanelInfoResponse(BaseModel):
"""Panel info for user from Remnawave."""
found: bool = False
trojan_password: str | None = None
vless_uuid: str | None = None
ss_password: str | None = None
subscription_url: str | None = None
happ_link: str | None = None
used_traffic_bytes: int = 0
lifetime_used_traffic_bytes: int = 0
traffic_limit_bytes: int = 0
first_connected_at: datetime | None = None
online_at: datetime | None = None
last_connected_node_uuid: str | None = None
last_connected_node_name: str | None = None
# === Node Usage ===
class UserNodeUsageItem(BaseModel):
"""Per-node traffic usage item."""
node_uuid: str
node_name: str
country_code: str = ''
total_bytes: int
daily_bytes: list[int] = []
class UserNodeUsageResponse(BaseModel):
"""Node usage response with 30-day daily breakdown."""
items: list[UserNodeUsageItem]
categories: list[str] = []
period_days: int = 30
# === User Actions ===
@@ -236,6 +298,12 @@ class UpdateSubscriptionRequest(BaseModel):
# For toggle_autopay
autopay_enabled: bool | None = Field(None, description='Enable/disable autopay')
# For add_traffic action
traffic_gb: int | None = Field(None, ge=1, description='Traffic GB to add')
# For remove_traffic action
traffic_purchase_id: int | None = Field(None, description='Traffic purchase ID to remove')
# For create new subscription
is_trial: bool | None = Field(None, description='Is trial subscription')
device_limit: int | None = Field(None, ge=1, description='Device limit')
@@ -299,6 +367,56 @@ class UpdatePromoGroupResponse(BaseModel):
message: str
class UpdateReferralCommissionRequest(BaseModel):
"""Request to update user referral commission percent."""
commission_percent: int | None = Field(
None, ge=0, le=100, description='Referral commission percent (null for default)'
)
class UpdateReferralCommissionResponse(BaseModel):
"""Response after referral commission update."""
success: bool
old_commission_percent: int | None = None
new_commission_percent: int | None = None
message: str
class DeviceInfo(BaseModel):
"""Individual device info."""
hwid: str
platform: str = ''
device_model: str = ''
created_at: str | None = None
class UserDevicesResponse(BaseModel):
"""User devices from panel."""
devices: list[DeviceInfo] = []
total: int = 0
device_limit: int = 0
class DeleteDeviceResponse(BaseModel):
"""Response after device deletion."""
success: bool
message: str
deleted_hwid: str | None = None
class ResetDevicesResponse(BaseModel):
"""Response after resetting all devices."""
success: bool
message: str
deleted_count: int = 0
class DeleteUserRequest(BaseModel):
"""Request to delete user."""
@@ -392,6 +510,15 @@ class UserAvailableTariffItem(BaseModel):
min_days: int = 1
max_days: int = 365
# Device limits
device_price_kopeks: int | None = None
max_device_limit: int | None = None
# Traffic topup
traffic_topup_enabled: bool = False
traffic_topup_packages: dict[str, int] = {}
max_topup_traffic_gb: int = 0
# Access info
is_available: bool = True # Available for this user's promo group
requires_promo_group: bool = False # Requires specific promo group
@@ -498,3 +625,72 @@ class PanelSyncStatusResponse(BaseModel):
# Differences
has_differences: bool = False
differences: list[str] = []
# === Admin User Management Actions ===
class FullDeleteUserRequest(BaseModel):
"""Request for full user deletion (bot + panel)."""
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class FullDeleteUserResponse(BaseModel):
"""Response after full user deletion."""
success: bool
message: str
deleted_from_bot: bool = False
deleted_from_panel: bool = False
panel_error: str | None = None
class ResetTrialRequest(BaseModel):
"""Request to reset user trial."""
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
class ResetTrialResponse(BaseModel):
"""Response after trial reset."""
success: bool
message: str
subscription_deleted: bool = False
has_used_trial_reset: bool = False
class ResetSubscriptionRequest(BaseModel):
"""Request to reset user subscription."""
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
class ResetSubscriptionResponse(BaseModel):
"""Response after subscription reset."""
success: bool
message: str
subscription_deleted: bool = False
panel_deactivated: bool = False
panel_error: str | None = None
class DisableUserRequest(BaseModel):
"""Request to disable user."""
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
class DisableUserResponse(BaseModel):
"""Response after user disable."""
success: bool
message: str
subscription_deactivated: bool = False
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
+147 -2
View File
@@ -119,7 +119,7 @@ class EmailService:
verification_token: Verification token
verification_url: Base URL for verification (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
@@ -174,6 +174,16 @@ class EmailService:
'ignore': 'Якщо ви не створювали акаунт, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'تایید آدرس ایمیل',
'intro': 'از ثبت‌نام شما سپاسگزاریم! لطفاً با کلیک روی دکمه زیر ایمیل خود را تایید کنید:',
'button': 'تایید ایمیل',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'ignore': 'اگر شما این حساب را ایجاد نکرده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
@@ -236,7 +246,7 @@ class EmailService:
reset_token: Password reset token
reset_url: Base URL for password reset (token will be appended)
username: User's name for personalization
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template (already wrapped in base template)
@@ -291,6 +301,16 @@ class EmailService:
'warning': "Якщо ви не запитували скидання пароля, проігноруйте цей лист або зв'яжіться з підтримкою.",
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'بازنشانی رمز عبور',
'intro': 'درخواستی برای بازنشانی رمز عبور شما دریافت شد. برای تعیین رمز جدید روی دکمه زیر بزنید:',
'button': 'بازنشانی رمز عبور',
'or_copy': 'یا این لینک را در مرورگر خود کپی و باز کنید:',
'expires': f'این لینک تا {expire_hours} ساعت معتبر است.',
'warning': 'اگر شما درخواست بازنشانی رمز عبور نداده‌اید، این ایمیل را نادیده بگیرید یا با پشتیبانی تماس بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
@@ -336,6 +356,131 @@ class EmailService:
return self.send_email(to_email, subject, body_html)
def send_email_change_code(
self,
to_email: str,
code: str,
username: str | None = None,
language: str = 'ru',
custom_subject: str | None = None,
custom_body_html: str | None = None,
) -> bool:
"""
Send email change verification code.
Args:
to_email: New email address
code: 6-digit verification code
username: User's name for personalization
language: Language code (ru, en, zh, ua, fa)
custom_subject: Override subject from admin template
custom_body_html: Override body HTML from admin template
Returns:
True if email was sent successfully, False otherwise
"""
if custom_subject and custom_body_html:
return self.send_email(to_email, custom_subject, custom_body_html)
expire_minutes = settings.get_cabinet_email_change_code_expire_minutes()
texts = {
'ru': {
'greeting': f'Здравствуйте{", " + username if username else ""}!',
'subject': 'Код подтверждения для смены email',
'intro': 'Вы запросили смену email адреса. Используйте код ниже для подтверждения:',
'code_label': 'Ваш код подтверждения:',
'expires': f'Код действителен в течение {expire_minutes} минут.',
'ignore': 'Если вы не запрашивали смену email, просто проигнорируйте это письмо.',
'regards': 'С уважением,',
},
'en': {
'greeting': f'Hello{", " + username if username else ""}!',
'subject': 'Email change verification code',
'intro': 'You requested to change your email address. Use the code below to confirm:',
'code_label': 'Your verification code:',
'expires': f'This code will expire in {expire_minutes} minutes.',
'ignore': "If you didn't request an email change, you can safely ignore this email.",
'regards': 'Best regards,',
},
'zh': {
'greeting': f'您好{", " + username if username else ""}!',
'subject': '邮箱更换验证码',
'intro': '您请求更换邮箱地址。请使用以下验证码确认:',
'code_label': '您的验证码:',
'expires': f'此验证码将在 {expire_minutes} 分钟后过期。',
'ignore': '如果您没有请求更换邮箱,请忽略此邮件。',
'regards': '此致,',
},
'ua': {
'greeting': f'Вітаємо{", " + username if username else ""}!',
'subject': 'Код підтвердження для зміни email',
'intro': 'Ви запросили зміну email адреси. Використовуйте код нижче для підтвердження:',
'code_label': 'Ваш код підтвердження:',
'expires': f'Код дійсний протягом {expire_minutes} хвилин.',
'ignore': 'Якщо ви не запитували зміну email, просто проігноруйте цей лист.',
'regards': 'З повагою,',
},
'fa': {
'greeting': f'سلام{", " + username if username else ""}!',
'subject': 'کد تایید تغییر ایمیل',
'intro': 'شما درخواست تغییر ایمیل داده‌اید. برای تایید از کد زیر استفاده کنید:',
'code_label': 'کد تایید شما:',
'expires': f'این کد تا {expire_minutes} دقیقه معتبر است.',
'ignore': 'اگر شما درخواست تغییر ایمیل نداده‌اید، این ایمیل را نادیده بگیرید.',
'regards': 'با احترام،',
},
}
t = texts.get(language, texts['ru'])
subject = t['subject']
body_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.code-box {{
background-color: #f8f9fa;
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
text-align: center;
margin: 20px 0;
}}
.code {{
font-size: 32px;
font-weight: bold;
letter-spacing: 8px;
color: #007bff;
font-family: monospace;
}}
.footer {{ margin-top: 30px; font-size: 12px; color: #666; }}
</style>
</head>
<body>
<div class="container">
<h2>{t['greeting']}</h2>
<p>{t['intro']}</p>
<div class="code-box">
<p>{t['code_label']}</p>
<p class="code">{code}</p>
</div>
<p>{t['expires']}</p>
<p>{t['ignore']}</p>
<div class="footer">
<p>{t['regards']}<br>{self.from_name}</p>
</div>
</div>
</body>
</html>
"""
return self.send_email(to_email, subject, body_html)
# Singleton instance
email_service = EmailService()
+4 -2
View File
@@ -1,7 +1,7 @@
"""
Email notification templates for different notification types.
Supports multiple languages: ru, en, zh, ua
Supports multiple languages: ru, en, zh, ua, fa
"""
from typing import Any
@@ -27,7 +27,7 @@ class EmailNotificationTemplates:
Args:
notification_type: Type of notification
language: Language code (ru, en, zh, ua)
language: Language code (ru, en, zh, ua, fa)
context: Context data for template rendering
Returns:
@@ -72,6 +72,7 @@ class EmailNotificationTemplates:
'en': 'This is an automated message. Please do not reply to this email.',
'zh': '这是一封自动发送的邮件,请勿回复。',
'ua': 'Це автоматичне повідомлення. Будь ласка, не відповідайте на цей лист.',
'fa': 'این یک پیام خودکار است. لطفاً به این ایمیل پاسخ ندهید.',
}
footer_text = footer_texts.get(language, footer_texts['ru'])
@@ -182,6 +183,7 @@ class EmailNotificationTemplates:
'en': 'Open Dashboard',
'zh': '打开控制面板',
'ua': 'Відкрити особистий кабінет',
'fa': 'باز کردن پنل کاربری',
}
text = texts.get(language, texts['en'])
+121 -98
View File
@@ -105,6 +105,25 @@ 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)
# Webhook user notification toggles (what Telegram messages users receive from webhook events)
WEBHOOK_NOTIFY_USER_ENABLED: bool = True
WEBHOOK_NOTIFY_SUB_STATUS: bool = True
WEBHOOK_NOTIFY_SUB_EXPIRED: bool = True
WEBHOOK_NOTIFY_SUB_EXPIRING: bool = True
WEBHOOK_NOTIFY_SUB_LIMITED: bool = True
WEBHOOK_NOTIFY_TRAFFIC_RESET: bool = True
WEBHOOK_NOTIFY_SUB_DELETED: bool = True
WEBHOOK_NOTIFY_SUB_REVOKED: bool = True
WEBHOOK_NOTIFY_FIRST_CONNECTED: bool = True
WEBHOOK_NOTIFY_NOT_CONNECTED: bool = True
WEBHOOK_NOTIFY_BANDWIDTH_THRESHOLD: bool = True
WEBHOOK_NOTIFY_DEVICES: bool = True
TRIAL_DURATION_DAYS: int = 3
TRIAL_TRAFFIC_LIMIT_GB: int = 10
TRIAL_DEVICE_LIMIT: int = 2
@@ -112,6 +131,7 @@ class Settings(BaseSettings):
TRIAL_PAYMENT_ENABLED: bool = False
TRIAL_ACTIVATION_PRICE: int = 0
TRIAL_USER_TAG: str | None = None
TRIAL_DISABLED_FOR: str = 'none' # none, email, telegram, all
DEFAULT_TRAFFIC_LIMIT_GB: int = 100
DEFAULT_DEVICE_LIMIT: int = 1
DEFAULT_TRAFFIC_RESET_STRATEGY: str = 'MONTH'
@@ -161,11 +181,6 @@ class Settings(BaseSettings):
DEVICES_SELECTION_ENABLED: bool = True
DEVICES_SELECTION_DISABLED_AMOUNT: int | None = None
# Настройки модема
MODEM_ENABLED: bool = False
MODEM_PRICE_PER_MONTH: int = 10000 # Цена модема в копейках за месяц
MODEM_PERIOD_DISCOUNTS: str = '' # Скидки на модем: "месяцев:процент,месяцев:процент" (напр. "3:10,6:15,12:20")
BASE_PROMO_GROUP_PERIOD_DISCOUNTS_ENABLED: bool = False
BASE_PROMO_GROUP_PERIOD_DISCOUNTS: str = ''
@@ -180,7 +195,7 @@ class Settings(BaseSettings):
# Режим продаж подписок:
# - classic: классический режим (выбор серверов, трафика, устройств, периода отдельно)
# - tariffs: режим тарифов (готовые пакеты с фиксированными параметрами)
SALES_MODE: str = 'classic'
SALES_MODE: str = 'tariffs'
# ID тарифа для триала в режиме тарифов (0 = использовать стандартные настройки триала)
# Если указан ID тарифа, параметры триала берутся из тарифа (traffic_limit_gb, device_limit, allowed_squads)
@@ -236,6 +251,8 @@ class Settings(BaseSettings):
BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24
BLACKLIST_IGNORE_ADMINS: bool = True
DISPOSABLE_EMAIL_CHECK_ENABLED: bool = True
# Настройки простой покупки
SIMPLE_SUBSCRIPTION_ENABLED: bool = False
SIMPLE_SUBSCRIPTION_PERIOD_DAYS: int = 30
@@ -336,12 +353,6 @@ class Settings(BaseSettings):
NALOGO_STORAGE_PATH: str = './nalogo_tokens.json'
AUTO_PURCHASE_AFTER_TOPUP_ENABLED: bool = False
AUTO_ACTIVATE_AFTER_TOPUP_ENABLED: bool = False
# Показывать предупреждение об активации подписки после пополнения баланса
# Если True - после пополнения показывает большое сообщение с кнопками:
# "Активировать", "Продлить", "Добавить устройства"
SHOW_ACTIVATION_PROMPT_AFTER_TOPUP: bool = False
# Отключение превью ссылок в сообщениях бота
DISABLE_WEB_PAGE_PREVIEW: bool = False
@@ -398,6 +409,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'
@@ -406,7 +418,6 @@ class Settings(BaseSettings):
PAL24_SIGNATURE_TOKEN: str | None = None
PAL24_BASE_URL: str = 'https://pal24.pro/api/v1/'
PAL24_WEBHOOK_PATH: str = '/pal24-webhook'
PAL24_WEBHOOK_PORT: int = 8084
PAL24_PAYMENT_DESCRIPTION: str = 'Пополнение баланса'
PAL24_MIN_AMOUNT_KOPEKS: int = 10000
PAL24_MAX_AMOUNT_KOPEKS: int = 100000000
@@ -505,8 +516,10 @@ class Settings(BaseSettings):
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = 'default'
CONNECT_BUTTON_MODE: str = 'guide'
MAIN_MENU_MODE: str = 'default' # 'default' | 'cabinet'
# Стиль кнопок Cabinet: primary (синий), success (зелёный), danger (красный), '' (по умолчанию для каждой секции)
CABINET_BUTTON_STYLE: str = ''
CONNECT_BUTTON_MODE: str = 'miniapp_subscription'
MINIAPP_CUSTOM_URL: str = ''
MINIAPP_STATIC_PATH: str = 'miniapp'
MINIAPP_PURCHASE_URL: str = ''
@@ -528,7 +541,7 @@ class Settings(BaseSettings):
SKIP_REFERRAL_CODE: bool = False
DEFAULT_LANGUAGE: str = 'ru'
AVAILABLE_LANGUAGES: str = 'ru,en'
AVAILABLE_LANGUAGES: str = 'ru,en,ua,zh,fa'
LANGUAGE_SELECTION_ENABLED: bool = True
# Округление цен при отображении (≤50 коп вниз, >50 коп вверх)
@@ -691,9 +704,27 @@ class Settings(BaseSettings):
CABINET_EMAIL_VERIFICATION_ENABLED: bool = True
CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS: int = 24
CABINET_PASSWORD_RESET_EXPIRE_HOURS: int = 1
CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES: int = 15 # Email change verification code expiration
CABINET_EMAIL_AUTH_ENABLED: bool = True # Enable email registration/login in cabinet
CABINET_URL: str = 'https://example.com/cabinet' # Base URL for cabinet (used in verification emails)
# OAuth 2.0 provider settings for cabinet
OAUTH_GOOGLE_CLIENT_ID: str = ''
OAUTH_GOOGLE_CLIENT_SECRET: str = ''
OAUTH_GOOGLE_ENABLED: bool = False
OAUTH_YANDEX_CLIENT_ID: str = ''
OAUTH_YANDEX_CLIENT_SECRET: str = ''
OAUTH_YANDEX_ENABLED: bool = False
OAUTH_DISCORD_CLIENT_ID: str = ''
OAUTH_DISCORD_CLIENT_SECRET: str = ''
OAUTH_DISCORD_ENABLED: bool = False
OAUTH_VK_CLIENT_ID: str = ''
OAUTH_VK_CLIENT_SECRET: str = ''
OAUTH_VK_ENABLED: bool = False
# SMTP settings for cabinet email
SMTP_HOST: str | None = None
SMTP_PORT: int = 587
@@ -721,15 +752,16 @@ class Settings(BaseSettings):
'default': 'default',
'full': 'default',
'standard': 'default',
'text': 'text',
'text_only': 'text',
'textual': 'text',
'minimal': 'text',
'cabinet': 'cabinet',
'text': 'cabinet',
'text_only': 'cabinet',
'textual': 'cabinet',
'minimal': 'cabinet',
}
mode = aliases.get(normalized, normalized)
if mode not in {'default', 'text'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, text')
if mode not in {'default', 'cabinet'}:
raise ValueError('MAIN_MENU_MODE must be one of: default, cabinet')
return mode
@field_validator('SERVER_STATUS_MODE', mode='before')
@@ -1030,13 +1062,14 @@ class Settings(BaseSettings):
)
raw_username = template.format_map(values).strip()
sanitized_username = re.sub(r'[^0-9A-Za-z._-]+', '_', raw_username)
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('._-')
# Remnawave разрешает только буквы, цифры, подчёркивания и дефисы
sanitized_username = re.sub(r'[^0-9A-Za-z_-]+', '_', raw_username)
sanitized_username = re.sub(r'_+', '_', sanitized_username).strip('_-')
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]:
@@ -1074,6 +1107,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:
@@ -1161,22 +1201,12 @@ class Settings(BaseSettings):
return bool(value)
def is_auto_activate_after_topup_enabled(self) -> bool:
"""Умная автоактивация после пополнения баланса (без корзины)."""
value = getattr(self, 'AUTO_ACTIVATE_AFTER_TOPUP_ENABLED', False)
if isinstance(value, str):
normalized = value.strip().lower()
return normalized in {'1', 'true', 'yes', 'on'}
return bool(value)
def is_quick_amount_buttons_enabled(self) -> bool:
"""Показывать ли кнопки быстрого выбора суммы пополнения."""
return self.YOOKASSA_QUICK_AMOUNT_SELECTION_ENABLED and not self.DISABLE_TOPUP_BUTTONS
def get_available_languages(self) -> list[str]:
defaults = ['ru', 'en', 'ua', 'zh']
defaults = ['ru', 'en', 'ua', 'zh', 'fa']
try:
langs = self.AVAILABLE_LANGUAGES
@@ -1308,6 +1338,17 @@ class Settings(BaseSettings):
def get_trial_user_tag(self) -> str | None:
return self._normalize_user_tag(self.TRIAL_USER_TAG, 'TRIAL_USER_TAG')
def is_trial_disabled_for_user(self, auth_type: str | None) -> bool:
disabled_for = self.TRIAL_DISABLED_FOR
if disabled_for == 'all':
return True
# 'email' means all non-Telegram users (email, google, yandex, discord, vk, etc.)
if disabled_for == 'email' and auth_type not in (None, 'telegram'):
return True
if disabled_for == 'telegram' and (auth_type is None or auth_type == 'telegram'):
return True
return False
def get_paid_subscription_user_tag(self) -> str | None:
return self._normalize_user_tag(
self.PAID_SUBSCRIPTION_USER_TAG,
@@ -1327,8 +1368,12 @@ class Settings(BaseSettings):
def get_main_menu_mode(self) -> str:
return getattr(self, 'MAIN_MENU_MODE', 'default')
def is_cabinet_mode(self) -> bool:
return self.get_main_menu_mode() == 'cabinet'
def is_text_main_menu_mode(self) -> bool:
return self.get_main_menu_mode() == 'text'
"""Backward-compatible alias for :meth:`is_cabinet_mode`."""
return self.is_cabinet_mode()
def get_main_menu_miniapp_url(self) -> str | None:
for candidate in [self.MINIAPP_CUSTOM_URL, self.MINIAPP_PURCHASE_URL]:
@@ -1500,9 +1545,6 @@ class Settings(BaseSettings):
def get_disabled_mode_device_limit(self) -> int | None:
return self.get_devices_selection_disabled_amount()
def is_modem_enabled(self) -> bool:
return bool(self.MODEM_ENABLED)
def is_tariffs_mode(self) -> bool:
"""Проверяет, включен ли режим продаж 'Тарифы'."""
return self.SALES_MODE == 'tariffs'
@@ -1513,68 +1555,12 @@ class Settings(BaseSettings):
def get_sales_mode(self) -> str:
"""Возвращает текущий режим продаж."""
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'classic'
return self.SALES_MODE if self.SALES_MODE in ('classic', 'tariffs') else 'tariffs'
def get_trial_tariff_id(self) -> int:
"""Возвращает ID тарифа для триала (0 = использовать стандартные настройки)."""
return max(0, self.TRIAL_TARIFF_ID)
def get_modem_price_per_month(self) -> int:
try:
value = int(self.MODEM_PRICE_PER_MONTH)
except (TypeError, ValueError):
logger.warning(
'Некорректное значение MODEM_PRICE_PER_MONTH: %s',
self.MODEM_PRICE_PER_MONTH,
)
return 10000
return max(0, value)
def get_modem_period_discounts(self) -> dict[int, int]:
"""Возвращает скидки на модем по количеству месяцев: {месяцев: процент_скидки}"""
try:
config_str = (self.MODEM_PERIOD_DISCOUNTS or '').strip()
if not config_str:
return {}
discounts: dict[int, int] = {}
for part in config_str.split(','):
part = part.strip()
if not part:
continue
months_and_discount = part.split(':')
if len(months_and_discount) != 2:
continue
months_str, discount_str = months_and_discount
try:
months = int(months_str.strip())
discount_percent = int(discount_str.strip())
except ValueError:
continue
discounts[months] = max(0, min(100, discount_percent))
return discounts
except Exception:
return {}
def get_modem_period_discount(self, months: int) -> int:
"""Возвращает процент скидки для указанного количества месяцев"""
if months <= 0:
return 0
discounts = self.get_modem_period_discounts()
# Ищем точное совпадение или ближайшее меньшее
applicable_discount = 0
for discount_months, discount_percent in sorted(discounts.items()):
if months >= discount_months:
applicable_discount = discount_percent
return applicable_discount
def is_trial_paid_activation_enabled(self) -> bool:
# TRIAL_PAYMENT_ENABLED - главный переключатель платной активации
# Если выключен - триал бесплатный, независимо от цены
@@ -2416,7 +2402,7 @@ class Settings(BaseSettings):
def get_bot_run_mode(self) -> str:
mode = (self.BOT_RUN_MODE or 'polling').strip().lower()
if mode not in {'polling', 'webhook', 'both'}:
if mode not in {'polling', 'webhook'}:
return 'polling'
return mode
@@ -2498,6 +2484,9 @@ class Settings(BaseSettings):
def get_cabinet_password_reset_expire_hours(self) -> int:
return max(1, self.CABINET_PASSWORD_RESET_EXPIRE_HOURS)
def get_cabinet_email_change_code_expire_minutes(self) -> int:
return max(1, self.CABINET_EMAIL_CHANGE_CODE_EXPIRE_MINUTES)
def is_cabinet_email_auth_enabled(self) -> bool:
return bool(self.CABINET_EMAIL_AUTH_ENABLED)
@@ -2511,6 +2500,40 @@ class Settings(BaseSettings):
return self.SMTP_FROM_EMAIL
return self.SMTP_USER
# OAuth helpers
def get_oauth_providers_config(self) -> dict[str, dict[str, str | bool]]:
"""Return config for all OAuth providers (enabled or not)."""
return {
'google': {
'client_id': self.OAUTH_GOOGLE_CLIENT_ID,
'client_secret': self.OAUTH_GOOGLE_CLIENT_SECRET,
'enabled': self.OAUTH_GOOGLE_ENABLED,
'display_name': 'Google',
},
'yandex': {
'client_id': self.OAUTH_YANDEX_CLIENT_ID,
'client_secret': self.OAUTH_YANDEX_CLIENT_SECRET,
'enabled': self.OAUTH_YANDEX_ENABLED,
'display_name': 'Yandex',
},
'discord': {
'client_id': self.OAUTH_DISCORD_CLIENT_ID,
'client_secret': self.OAUTH_DISCORD_CLIENT_SECRET,
'enabled': self.OAUTH_DISCORD_ENABLED,
'display_name': 'Discord',
},
'vk': {
'client_id': self.OAUTH_VK_CLIENT_ID,
'client_secret': self.OAUTH_VK_CLIENT_SECRET,
'enabled': self.OAUTH_VK_ENABLED,
'display_name': 'VK',
},
}
def get_enabled_oauth_provider_names(self) -> list[str]:
"""Return list of enabled OAuth provider names."""
return [name for name, cfg in self.get_oauth_providers_config().items() if cfg['enabled']]
# Ban System helpers
def is_ban_system_enabled(self) -> bool:
return bool(self.BAN_SYSTEM_ENABLED)
+41
View File
@@ -131,6 +131,17 @@ async def get_promocode_use_by_user_and_code(db: AsyncSession, user_id: int, pro
return result.scalar_one_or_none()
async def count_user_recent_activations(db: AsyncSession, user_id: int, hours: int = 24) -> int:
"""Подсчитывает количество активаций промокодов пользователем за последние N часов."""
from datetime import timedelta
cutoff = datetime.utcnow() - timedelta(hours=hours)
result = await db.execute(
select(func.count(PromoCodeUse.id)).where(and_(PromoCodeUse.user_id == user_id, PromoCodeUse.used_at >= cutoff))
)
return result.scalar() or 0
async def get_user_promocodes(db: AsyncSession, user_id: int) -> list[PromoCodeUse]:
result = await db.execute(
select(PromoCodeUse).where(PromoCodeUse.user_id == user_id).order_by(PromoCodeUse.used_at.desc())
@@ -192,6 +203,36 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
return False
async def get_active_discount_promocode_for_user(
db: AsyncSession, user_id: int
) -> tuple[PromoCode | None, PromoCodeUse | None]:
"""
Находит активный промокод на скидку, который сейчас действует у пользователя.
Ищет по source-полю пользователя (формат 'promocode:CODE').
Returns:
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
"""
from app.database.models import User as UserModel
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
return None, None
source = getattr(user, 'promo_offer_discount_source', None)
if not source or not source.startswith('promocode:'):
return None, None
code = source.split(':', 1)[1]
promocode = await get_promocode_by_code(db, code)
if not promocode:
return None, None
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
return promocode, use
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
total_uses_result = await db.execute(
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
+94
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import selectinload
from app.database.models import (
ReferralContest,
ReferralContestEvent,
ReferralContestVirtualParticipant,
Transaction,
TransactionType,
User,
@@ -918,3 +919,96 @@ async def cleanup_invalid_contest_events(
'contest_start': contest_start.isoformat(),
'contest_end': contest_end.isoformat(),
}
# ── Виртуальные участники ──────────────────────────────────────────────
async def add_virtual_participant(
db: AsyncSession,
contest_id: int,
display_name: str,
referral_count: int,
total_amount_kopeks: int = 0,
) -> ReferralContestVirtualParticipant:
vp = ReferralContestVirtualParticipant(
contest_id=contest_id,
display_name=display_name,
referral_count=referral_count,
total_amount_kopeks=total_amount_kopeks,
)
db.add(vp)
await db.commit()
await db.refresh(vp)
return vp
async def list_virtual_participants(
db: AsyncSession,
contest_id: int,
) -> Sequence[ReferralContestVirtualParticipant]:
result = await db.execute(
select(ReferralContestVirtualParticipant)
.where(ReferralContestVirtualParticipant.contest_id == contest_id)
.order_by(ReferralContestVirtualParticipant.referral_count.desc())
)
return result.scalars().all()
async def delete_virtual_participant(
db: AsyncSession,
participant_id: int,
) -> bool:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return False
await db.delete(vp)
await db.commit()
return True
async def update_virtual_participant_count(
db: AsyncSession,
participant_id: int,
referral_count: int,
) -> ReferralContestVirtualParticipant | None:
result = await db.execute(
select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == participant_id)
)
vp = result.scalar_one_or_none()
if not vp:
return None
vp.referral_count = referral_count
await db.commit()
await db.refresh(vp)
return vp
async def get_contest_leaderboard_with_virtual(
db: AsyncSession,
contest_id: int,
*,
limit: int | None = None,
) -> list[tuple[str, int, int, bool]]:
"""Лидерборд с виртуальными участниками.
Возвращает список кортежей (display_name, referral_count, total_amount, is_virtual).
"""
real = await get_contest_leaderboard(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
merged: list[tuple[str, int, int, bool]] = []
for user, score, amount in real:
merged.append((user.full_name, score, amount, False))
for vp in virtual:
merged.append((vp.display_name, vp.referral_count, vp.total_amount_kopeks, True))
merged.sort(key=lambda x: (-x[1], -x[2]))
if limit:
merged = merged[:limit]
return merged
+84 -8
View File
@@ -23,6 +23,7 @@ from app.database.models import (
Subscription,
SubscriptionServer,
SubscriptionStatus,
Tariff,
User,
)
@@ -362,6 +363,25 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
subscription.updated_at = datetime.utcnow()
cleaned_subscriptions += 1
# Clean up stale UUIDs from tariff allowed_squads
cleaned_tariffs = 0
tariffs_result = await db.execute(select(Tariff))
for tariff in tariffs_result.scalars().all():
current = list(tariff.allowed_squads or [])
if not current:
continue
filtered = [u for u in current if u not in removed_uuids]
if len(filtered) != len(current):
tariff.allowed_squads = filtered
tariff.updated_at = datetime.utcnow()
cleaned_tariffs += 1
logger.info(
'🧹 Тариф "%s" (ID: %s): удалены несуществующие сквады %s',
tariff.name,
tariff.id,
[u for u in current if u in removed_uuids],
)
await db.execute(delete(ServerSquad).where(ServerSquad.id.in_(removed_ids)))
removed = len(removed_servers)
@@ -371,6 +391,12 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
cleaned_subscriptions,
)
if cleaned_tariffs:
logger.info(
'🧹 Обновлены тарифы после удаления серверов: %s',
cleaned_tariffs,
)
await db.commit()
logger.info(f'🔄 Синхронизация завершена: +{created} ~{updated} -{removed}')
@@ -733,40 +759,90 @@ async def count_active_users_for_squad(db: AsyncSession, squad_uuid: str) -> int
async def add_user_to_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
try:
for server_id in server_squad_ids:
for server_id in sorted(server_squad_ids):
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=ServerSquad.current_users + 1)
)
await db.commit()
await db.flush()
logger.info(f'✅ Увеличен счетчик пользователей для серверов: {server_squad_ids}')
return True
except Exception as e:
logger.error(f'Ошибка увеличения счетчика пользователей: {e}')
await db.rollback()
return False
raise
async def remove_user_from_servers(db: AsyncSession, server_squad_ids: list[int]) -> bool:
try:
for server_id in server_squad_ids:
for server_id in sorted(server_squad_ids):
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.commit()
await db.flush()
logger.info(f'✅ Уменьшен счетчик пользователей для серверов: {server_squad_ids}')
return True
except Exception as e:
logger.error(f'Ошибка уменьшения счетчика пользователей: {e}')
await db.rollback()
return False
raise
async def update_server_user_counts(
db: AsyncSession,
add_ids: list[int] | None = None,
remove_ids: list[int] | None = None,
) -> None:
"""Increment and decrement server user counters in a single sorted pass.
Prevents deadlocks by acquiring row locks in consistent ID order
across both add and remove operations within one transaction.
"""
try:
add_set = set(add_ids) if add_ids else set()
remove_set = set(remove_ids) if remove_ids else set()
if not add_set and not remove_set:
return
# IDs in both sets cancel out — skip them
overlap = add_set & remove_set
if overlap:
add_set -= overlap
remove_set -= overlap
all_ids = sorted(add_set | remove_set)
if not all_ids:
return
for server_id in all_ids:
if server_id in add_set:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=ServerSquad.current_users + 1)
)
if server_id in remove_set:
await db.execute(
update(ServerSquad)
.where(ServerSquad.id == server_id)
.values(current_users=func.greatest(ServerSquad.current_users - 1, 0))
)
await db.flush()
if add_set:
logger.info('✅ Увеличен счетчик пользователей для серверов: %s', sorted(add_set))
if remove_set:
logger.info('✅ Уменьшен счетчик пользователей для серверов: %s', sorted(remove_set))
except Exception as e:
logger.error('Ошибка обновления счетчиков серверов: %s', e)
raise
async def get_server_ids_by_uuids(db: AsyncSession, squad_uuids: list[str]) -> list[int]:
+94 -31
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(
@@ -95,6 +106,25 @@ async def create_trial_subscription(
end_date = datetime.utcnow() + timedelta(days=duration_days)
# Check for existing PENDING trial subscription (retry after failed payment)
existing = await get_subscription_by_user_id(db, user_id)
if existing and existing.is_trial and existing.status == SubscriptionStatus.PENDING.value:
existing.status = SubscriptionStatus.ACTIVE.value
existing.start_date = datetime.utcnow()
existing.end_date = end_date
existing.traffic_limit_gb = traffic_limit_gb
existing.device_limit = device_limit
existing.connected_squads = final_squads
existing.tariff_id = tariff_id
await db.commit()
await db.refresh(existing)
logger.info(
'🎁 Обновлена PENDING триальная подписка %s для пользователя %s',
existing.id,
user_id,
)
return existing
subscription = Subscription(
user_id=user_id,
status=SubscriptionStatus.ACTIVE.value,
@@ -258,26 +288,29 @@ async def replace_subscription(
await db.commit()
await db.refresh(subscription)
# Очищаем старые записи об отправленных уведомлениях при замене подписки
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
await clear_notifications(db, subscription.id)
if update_server_counters:
try:
from app.database.crud.server_squad import (
add_user_to_servers,
get_server_ids_by_uuids,
remove_user_from_servers,
update_server_user_counts,
)
squads_to_remove = old_squads - new_squads
squads_to_add = new_squads - old_squads
if squads_to_remove:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_remove))
if server_ids:
await remove_user_from_servers(db, sorted(server_ids))
remove_ids = await get_server_ids_by_uuids(db, list(squads_to_remove)) if squads_to_remove else []
add_ids = await get_server_ids_by_uuids(db, list(squads_to_add)) if squads_to_add else []
if squads_to_add:
server_ids = await get_server_ids_by_uuids(db, list(squads_to_add))
if server_ids:
await add_user_to_servers(db, sorted(server_ids))
if remove_ids or add_ids:
await update_server_user_counts(
db,
add_ids=add_ids or None,
remove_ids=remove_ids or None,
)
logger.info(
'♻️ Обновлены параметры подписки %s: удалено сквадов %s, добавлено %s',
@@ -324,9 +357,8 @@ async def extend_subscription(
)
# Определяем, происходит ли СМЕНА тарифа (а не продление того же)
is_tariff_change = (
tariff_id is not None and subscription.tariff_id is not None and tariff_id != subscription.tariff_id
)
# Включает переход из классического режима (tariff_id=None) в тарифный
is_tariff_change = tariff_id is not None and (subscription.tariff_id is None or tariff_id != subscription.tariff_id)
if is_tariff_change:
logger.info(f'🔄 Обнаружена СМЕНА тарифа: {subscription.tariff_id}{tariff_id}')
@@ -407,17 +439,28 @@ async def extend_subscription(
if traffic_limit_gb is not None:
old_traffic = subscription.traffic_limit_gb
subscription.traffic_limit_gb = traffic_limit_gb
subscription.traffic_used_gb = 0.0
# Сбрасываем все докупки трафика при смене тарифа
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
if is_tariff_change:
# При СМЕНЕ тарифа сбрасываем все докупки трафика
subscription.traffic_limit_gb = traffic_limit_gb
from sqlalchemy import delete as sql_delete
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None # Сбрасываем дату сброса трафика
logger.info(f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (все докупки сброшены)')
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
subscription.purchased_traffic_gb = 0
subscription.traffic_reset_at = None
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb} ГБ (смена тарифа, докупки сброшены)'
)
else:
# При ПРОДЛЕНИИ того же тарифа — сохраняем докупленный трафик
purchased = subscription.purchased_traffic_gb or 0
subscription.traffic_limit_gb = traffic_limit_gb + purchased
logger.info(
f'📊 Обновлен лимит трафика: {old_traffic} ГБ → {traffic_limit_gb + purchased} ГБ (докупки сохранены: {purchased} ГБ)'
)
elif settings.RESET_TRAFFIC_ON_PAYMENT:
subscription.traffic_used_gb = 0.0
# В режиме тарифов сохраняем докупленный трафик при продлении
@@ -582,6 +625,9 @@ async def decrement_subscription_server_counts(
if not subscription:
return
# Save ID before any DB operations that might invalidate the ORM object
sub_id = subscription.id
server_ids: set[int] = set()
if subscription_servers is not None:
@@ -590,12 +636,12 @@ async def decrement_subscription_server_counts(
server_ids.add(sub_server.server_squad_id)
else:
try:
ids_from_links = await get_subscription_server_ids(db, subscription.id)
ids_from_links = await get_subscription_server_ids(db, sub_id)
server_ids.update(ids_from_links)
except Exception as error:
logger.error(
'⚠️ Не удалось получить серверы подписки %s для уменьшения счетчика: %s',
subscription.id,
sub_id,
error,
)
@@ -609,7 +655,7 @@ async def decrement_subscription_server_counts(
except Exception as error:
logger.error(
'⚠️ Не удалось сопоставить сквады подписки %s с серверами: %s',
subscription.id,
sub_id,
error,
)
@@ -619,12 +665,20 @@ async def decrement_subscription_server_counts(
try:
from app.database.crud.server_squad import remove_user_from_servers
await remove_user_from_servers(db, sorted(server_ids))
# Use savepoint so StaleDataError rollback doesn't affect the parent transaction
async with db.begin_nested():
await remove_user_from_servers(db, list(server_ids))
except StaleDataError:
logger.warning(
'⚠️ Подписка %s уже удалена (StaleDataError), пропускаем декремент серверов %s',
sub_id,
list(server_ids),
)
except Exception as error:
logger.error(
'⚠️ Ошибка уменьшения счетчика пользователей серверов %s для подписки %s: %s',
list(server_ids),
subscription.id,
sub_id,
error,
)
@@ -710,7 +764,10 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
@@ -723,6 +780,11 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
ready_for_autopay = []
for subscription in all_autopay_subscriptions:
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay на них не распространяется
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
continue
days_until_expiry = (subscription.end_date - current_time).days
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
@@ -1920,13 +1982,14 @@ async def resume_daily_subscription(
subscription.is_daily_paused = False
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
if subscription.status == SubscriptionStatus.DISABLED.value:
# Восстанавливаем статус ACTIVE если подписка была DISABLED/EXPIRED
if subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
previous_status = subscription.status
subscription.status = SubscriptionStatus.ACTIVE.value
# Обновляем время последнего списания для корректного расчёта следующего
subscription.last_daily_charge_at = datetime.utcnow()
subscription.end_date = datetime.utcnow() + timedelta(days=1)
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из DISABLED в ACTIVE')
logger.info(f'✅ Суточная подписка {subscription.id} восстановлена из {previous_status} в ACTIVE')
await db.commit()
await db.refresh(subscription)
+28 -25
View File
@@ -233,6 +233,8 @@ async def create_tariff(
if promo_group_ids:
promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)))
promo_groups = promo_groups_result.scalars().all()
# Refresh чтобы избежать lazy load в async контексте
await db.refresh(tariff, ['allowed_promo_groups'])
tariff.allowed_promo_groups = list(promo_groups)
await db.commit()
@@ -484,16 +486,14 @@ async def reorder_tariffs(
for order, tariff_id in enumerate(tariff_order):
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
await db.commit()
logger.info('Изменен порядок тарифов: %s', tariff_order)
async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
"""
Синхронизирует дефолтный тариф из конфига (.env) в БД.
Создаёт тариф "Стандартный" если в БД нет тарифов.
Обновляет цены существующего тарифа если он есть.
Создаёт тариф "Стандартный" только если в БД нет тарифов.
Существующий тариф НЕ перезаписывается админ управляет им через кабинет.
Returns:
Tariff или None если не требуется синхронизация
@@ -519,13 +519,11 @@ async def sync_default_tariff_from_config(db: AsyncSession) -> Tariff | None:
existing_tariff = result.scalar_one_or_none()
if existing_tariff:
# Обновляем цены существующего тарифа
existing_tariff.period_prices = period_prices
existing_tariff.traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
existing_tariff.device_limit = settings.DEFAULT_DEVICE_LIMIT
await db.commit()
await db.refresh(existing_tariff)
logger.info("Обновлён дефолтный тариф 'Стандартный' из конфига")
# Тариф уже существует — НЕ перезаписываем настройки из конфига.
# Админ управляет тарифом через кабинет, синхронизация не нужна.
logger.info(
"Дефолтный тариф 'Стандартный' (id=%s) уже существует, пропускаем sync из конфига", existing_tariff.id
)
return existing_tariff
if tariff_count == 0:
@@ -571,21 +569,26 @@ async def load_period_prices_from_db(db: AsyncSession) -> None:
)
tariff = result.scalar_one_or_none()
if tariff and tariff.period_prices:
# Преобразуем строковые ключи в int
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
if period_prices:
set_period_prices_from_db(period_prices)
logger.info(
"Загружены периоды из тарифа '%s': %s",
tariff.name,
{f'{d}д': f'{p // 100}' for d, p in period_prices.items()},
)
else:
logger.warning("Тариф '%s' не имеет активных периодов", tariff.name)
else:
if not tariff:
logger.info('Активные тарифы не найдены, используются цены из .env')
return
if not tariff.period_prices:
logger.warning("Тариф '%s' (id=%s) найден, но period_prices пуст", tariff.name, tariff.id)
return
# Преобразуем строковые ключи в int
period_prices = {int(days): int(price) for days, price in tariff.period_prices.items() if int(price) > 0}
if period_prices:
set_period_prices_from_db(period_prices)
logger.info(
"Загружены периоды из тарифа '%s': %s",
tariff.name,
{f'{d}д': f'{p // 100}' for d, p in period_prices.items()},
)
else:
logger.warning("Тариф '%s' не имеет активных периодов (все цены = 0)", tariff.name)
except Exception as e:
logger.error('Ошибка загрузки периодов из БД: %s', e)
+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)
+279 -26
View File
@@ -1,7 +1,7 @@
import logging
import secrets
import string
from datetime import datetime, timedelta
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, case, func, nullslast, or_, select, text
from sqlalchemy.exc import IntegrityError
@@ -28,6 +28,13 @@ from app.utils.validators import sanitize_telegram_name
logger = logging.getLogger(__name__)
def _normalize_language_code(language: str | None, fallback: str = 'ru') -> str:
normalized = (language or '').strip().lower()
if '-' in normalized:
normalized = normalized.split('-', 1)[0]
return normalized or fallback
def _build_spending_stats_select():
"""
Возвращает базовый SELECT для статистики трат пользователей.
@@ -232,6 +239,7 @@ async def create_user_no_commit(
if not referral_code:
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
promo_group_id = default_group.id
@@ -243,7 +251,7 @@ async def create_user_no_commit(
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -277,6 +285,7 @@ async def create_user(
) -> User:
if not referral_code:
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
attempts = 3
@@ -291,7 +300,7 @@ async def create_user(
username=username,
first_name=safe_first,
last_name=safe_last,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -360,6 +369,8 @@ async def update_user(db: AsyncSession, user: User, **kwargs) -> User:
for field, value in kwargs.items():
if field in ('first_name', 'last_name'):
value = sanitize_telegram_name(value)
if field == 'language':
value = _normalize_language_code(value)
if hasattr(user, field):
setattr(user, field, value)
@@ -407,30 +418,35 @@ async def add_user_balance(
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import resume_daily_subscription
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
subscription = user.subscription
# Загружаем подписку явно, чтобы избежать lazy loading
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff:
daily_price = getattr(subscription.tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
f'после пополнения баланса (user_id={user.id})'
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
if is_daily and subscription.tariff_id:
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
# Если баланс достаточный для суточной оплаты - возобновляем
if daily_price > 0 and user.balance_kopeks >= daily_price:
await resume_daily_subscription(db, subscription)
logger.info(
f'✅ Автоматически возобновлена суточная подписка {subscription.id} '
f'после пополнения баланса (user_id={user.id})'
)
# Синхронизируем с RemnaWave
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
except Exception as sync_err:
logger.warning(f'Не удалось синхронизировать с RemnaWave: {sync_err}')
except Exception as resume_err:
logger.warning(f'Ошибка при попытке возобновить суточную подписку: {resume_err}')
@@ -487,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:
@@ -538,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,
@@ -554,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:
@@ -686,6 +709,7 @@ async def get_users_list(
offset: int = 0,
limit: int = 50,
search: str | None = None,
email: str | None = None,
status: UserStatus | None = None,
order_by_balance: bool = False,
order_by_traffic: bool = False,
@@ -722,6 +746,9 @@ async def get_users_list(
query = query.where(or_(*conditions))
if email:
query = query.where(User.email.ilike(f'%{email}%'))
sort_flags = [
order_by_balance,
order_by_traffic,
@@ -777,7 +804,9 @@ async def get_users_list(
return users
async def get_users_count(db: AsyncSession, status: UserStatus | None = None, search: str | None = None) -> int:
async def get_users_count(
db: AsyncSession, status: UserStatus | None = None, search: str | None = None, email: str | None = None
) -> int:
query = select(func.count(User.id))
if status:
@@ -803,6 +832,9 @@ async def get_users_count(db: AsyncSession, status: UserStatus | None = None, se
query = query.where(or_(*conditions))
if email:
query = query.where(User.email.ilike(f'%{email}%'))
result = await db.execute(query)
return result.scalar()
@@ -1046,6 +1078,7 @@ async def create_user_by_email(
Created User object
"""
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
user = User(
@@ -1057,7 +1090,7 @@ async def create_user_by_email(
username=None,
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=None,
language=language,
language=normalized_language,
referred_by_id=referred_by_id,
referral_code=referral_code,
balance_kopeks=0,
@@ -1099,3 +1132,223 @@ async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
"""Get user by email address."""
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def is_email_taken(db: AsyncSession, email: str, exclude_user_id: int | None = None) -> bool:
"""
Check if email is already taken by another user.
Args:
db: Database session
email: Email to check
exclude_user_id: User ID to exclude from check (for current user)
Returns:
True if email is taken, False otherwise
"""
query = select(User.id).where(User.email == email)
if exclude_user_id:
query = query.where(User.id != exclude_user_id)
result = await db.execute(query)
return result.scalar_one_or_none() is not None
async def set_email_change_pending(
db: AsyncSession,
user: User,
new_email: str,
code: str,
expires_at: datetime,
) -> User:
"""
Set pending email change for user.
Args:
db: Database session
user: User object
new_email: New email address
code: 6-digit verification code
expires_at: Code expiration datetime
Returns:
Updated User object
"""
user.email_change_new = new_email
user.email_change_code = code
user.email_change_expires = expires_at
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f'Email change pending for user {user.id}: {user.email} -> {new_email}')
return user
async def verify_and_apply_email_change(db: AsyncSession, user: User, code: str) -> tuple[bool, str]:
"""
Verify email change code and apply the change.
Args:
db: Database session
user: User object
code: Verification code from user
Returns:
Tuple of (success: bool, message: str)
"""
if not user.email_change_new or not user.email_change_code:
return False, 'No pending email change'
if user.email_change_expires and datetime.utcnow() > user.email_change_expires:
# Clear expired data
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
await db.commit()
return False, 'Verification code has expired'
if user.email_change_code != code:
return False, 'Invalid verification code'
# Check if new email is still available
existing = await get_user_by_email(db, user.email_change_new)
if existing and existing.id != user.id:
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
await db.commit()
return False, 'This email is already taken'
old_email = user.email
new_email = user.email_change_new
# Apply the change
user.email = new_email
user.email_verified = True
user.email_verified_at = datetime.utcnow()
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
logger.info(f'Email changed for user {user.id}: {old_email} -> {new_email}')
return True, 'Email changed successfully'
async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
"""
Clear pending email change data.
Args:
db: Database session
user: User object
"""
user.email_change_new = None
user.email_change_code = None
user.email_change_expires = None
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f'Email change cancelled for user {user.id}')
# --- OAuth provider functions ---
_OAUTH_PROVIDER_COLUMNS = {
'google': 'google_id',
'yandex': 'yandex_id',
'discord': 'discord_id',
'vk': 'vk_id',
}
async def get_user_by_oauth_provider(db: AsyncSession, provider: str, provider_id: str) -> User | None:
"""Find a user by OAuth provider ID."""
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
if not column_name:
return None
column = getattr(User, column_name)
# VK uses BigInteger, so convert
value: str | int = int(provider_id) if provider == 'vk' else provider_id
result = await db.execute(select(User).where(column == value))
return result.scalar_one_or_none()
async def set_user_oauth_provider_id(db: AsyncSession, user: User, provider: str, provider_id: str) -> None:
"""Link an OAuth provider ID to an existing user."""
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
if not column_name:
return
value: str | int = int(provider_id) if provider == 'vk' else provider_id
setattr(user, column_name, value)
user.updated_at = datetime.now(UTC).replace(tzinfo=None)
logger.info(f'Linked {provider} (id={provider_id}) to user {user.id}')
async def create_user_by_oauth(
db: AsyncSession,
provider: str,
provider_id: str,
email: str | None = None,
email_verified: bool = False,
first_name: str | None = None,
last_name: str | None = None,
username: str | None = None,
language: str = 'ru',
) -> User:
"""Create a new user via OAuth provider."""
referral_code = await create_unique_referral_code(db)
normalized_language = _normalize_language_code(language)
default_group = await _get_or_create_default_promo_group(db)
column_name = _OAUTH_PROVIDER_COLUMNS.get(provider)
provider_value: str | int = int(provider_id) if provider == 'vk' else provider_id
user = User(
telegram_id=None,
auth_type=provider,
email=email,
email_verified=email_verified,
password_hash=None,
username=sanitize_telegram_name(username) if username else None,
first_name=sanitize_telegram_name(first_name) if first_name else None,
last_name=sanitize_telegram_name(last_name) if last_name else None,
language=normalized_language,
referral_code=referral_code,
balance_kopeks=0,
has_had_paid_subscription=False,
has_made_first_topup=False,
promo_group_id=default_group.id,
)
if column_name:
setattr(user, column_name, provider_value)
db.add(user)
await db.flush()
await db.refresh(user)
user.promo_group = default_group
logger.info(f'Created OAuth user via {provider} (provider_id={provider_id}) with id={user.id}')
try:
from app.services.event_emitter import event_emitter
await event_emitter.emit(
'user.created',
{
'user_id': user.id,
'email': user.email,
'auth_type': provider,
'first_name': user.first_name,
'referral_code': user.referral_code,
},
db=db,
)
except Exception as error:
logger.warning('Failed to emit user.created event: %s', error)
return user
+13 -2
View File
@@ -2,6 +2,7 @@ import logging
from datetime import datetime
from sqlalchemy import and_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -24,7 +25,7 @@ async def create_yookassa_payment(
payment_method_type: str | None = None,
yookassa_created_at: datetime | None = None,
test_mode: bool = False,
) -> YooKassaPayment:
) -> YooKassaPayment | None:
payment = YooKassaPayment(
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
@@ -40,7 +41,17 @@ async def create_yookassa_payment(
)
db.add(payment)
await db.commit()
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
logger.error(
'FK violation при создании платежа YooKassa %s: user_id=%s не существует в БД: %s',
yookassa_payment_id,
user_id,
e,
)
return None
await db.refresh(payment)
logger.info(f'Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks / 100}₽ для пользователя {user_id}')
+69 -16
View File
@@ -19,7 +19,7 @@ from sqlalchemy import (
UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
from sqlalchemy.sql import func
@@ -991,6 +991,15 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
cabinet_last_login = Column(DateTime, nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
email_change_expires = Column(DateTime, nullable=True) # Code expiration
# OAuth provider IDs
google_id = Column(String(255), unique=True, nullable=True, index=True)
yandex_id = Column(String(255), unique=True, nullable=True, index=True)
discord_id = Column(String(255), unique=True, nullable=True, index=True)
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
broadcasts = relationship('BroadcastHistory', back_populates='admin')
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
subscription = relationship('Subscription', back_populates='user', uselist=False)
@@ -1051,6 +1060,11 @@ class User(Base):
"""Пользователь зарегистрирован через email (без Telegram)."""
return self.auth_type == 'email' and self.telegram_id is None
@property
def is_web_user(self) -> bool:
"""Пользователь без Telegram (email, OAuth и т.д.)."""
return self.telegram_id is None
def get_primary_promo_group(self):
"""Возвращает промогруппу с максимальным приоритетом."""
if not self.user_promo_groups:
@@ -1123,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)
# Тариф (для режима продаж "Тарифы")
@@ -1137,23 +1153,35 @@ class Subscription(Base):
user = relationship('User', back_populates='subscription')
tariff = relationship('Tariff', back_populates='subscriptions')
discount_offers = relationship('DiscountOffer', back_populates='subscription')
temporary_accesses = relationship('SubscriptionTemporaryAccess', back_populates='subscription')
traffic_purchases = relationship('TrafficPurchase', back_populates='subscription', cascade='all, delete-orphan')
temporary_accesses = relationship(
'SubscriptionTemporaryAccess', back_populates='subscription', passive_deletes=True
)
traffic_purchases = relationship(
'TrafficPurchase', back_populates='subscription', passive_deletes=True, cascade='all, delete-orphan'
)
@property
def is_active(self) -> bool:
current_time = datetime.utcnow()
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date > current_time
return (
self.status == SubscriptionStatus.ACTIVE.value
and self.end_date is not None
and self.end_date > current_time
)
@property
def is_expired(self) -> bool:
"""Проверяет, истёк ли срок подписки"""
return self.end_date <= datetime.utcnow()
return self.end_date is not None and self.end_date <= datetime.utcnow()
@property
def should_be_expired(self) -> bool:
current_time = datetime.utcnow()
return self.status == SubscriptionStatus.ACTIVE.value and self.end_date <= current_time
return (
self.status == SubscriptionStatus.ACTIVE.value
and self.end_date is not None
and self.end_date <= current_time
)
@property
def actual_status(self) -> str:
@@ -1166,12 +1194,12 @@ class Subscription(Base):
return 'disabled'
if self.status == SubscriptionStatus.ACTIVE.value:
if self.end_date <= current_time:
if self.end_date is None or self.end_date <= current_time:
return 'expired'
return 'active'
if self.status == SubscriptionStatus.TRIAL.value:
if self.end_date <= current_time:
if self.end_date is None or self.end_date <= current_time:
return 'expired'
return 'trial'
@@ -1214,6 +1242,8 @@ class Subscription(Base):
@property
def days_left(self) -> int:
if self.end_date is None:
return 0
current_time = datetime.utcnow()
if self.end_date <= current_time:
return 0
@@ -1239,11 +1269,10 @@ class Subscription(Base):
@property
def traffic_used_percent(self) -> float:
if self.traffic_limit_gb == 0:
if not self.traffic_limit_gb:
return 0.0
if self.traffic_limit_gb > 0:
return min((self.traffic_used_gb / self.traffic_limit_gb) * 100, 100.0)
return 0.0
used = self.traffic_used_gb or 0.0
return min((used / self.traffic_limit_gb) * 100, 100.0)
def extend_subscription(self, days: int):
if self.end_date > datetime.utcnow():
@@ -1548,6 +1577,24 @@ class ReferralContestEvent(Base):
)
class ReferralContestVirtualParticipant(Base):
__tablename__ = 'referral_contest_virtual_participants'
id = Column(Integer, primary_key=True, index=True)
contest_id = Column(Integer, ForeignKey('referral_contests.id', ondelete='CASCADE'), nullable=False)
display_name = Column(String(255), nullable=False)
referral_count = Column(Integer, nullable=False, default=0)
total_amount_kopeks = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, default=func.now())
contest = relationship('ReferralContest')
def __repr__(self):
return (
f"<ReferralContestVirtualParticipant id={self.id} name='{self.display_name}' count={self.referral_count}>"
)
class ContestTemplate(Base):
__tablename__ = 'contest_templates'
@@ -1736,7 +1783,7 @@ class SentNotification(Base):
created_at = Column(DateTime, default=func.now())
user = relationship('User', backref='sent_notifications')
subscription = relationship('Subscription', backref='sent_notifications')
subscription = relationship('Subscription', backref=backref('sent_notifications', passive_deletes=True))
class SubscriptionEvent(Base):
@@ -1844,7 +1891,7 @@ class BroadcastHistory(Base):
id = Column(Integer, primary_key=True, index=True)
target_type = Column(String(100), nullable=False)
message_text = Column(Text, nullable=False)
message_text = Column(Text, nullable=True) # Nullable for email-only broadcasts
has_media = Column(Boolean, default=False)
media_type = Column(String(20), nullable=True)
media_file_id = Column(String(255), nullable=True)
@@ -1857,6 +1904,12 @@ class BroadcastHistory(Base):
admin_name = Column(String(255))
created_at = Column(DateTime(timezone=True), server_default=func.now())
completed_at = Column(DateTime(timezone=True), nullable=True)
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
email_html_content = Column(Text, nullable=True)
admin = relationship('User', back_populates='broadcasts')
@@ -2020,7 +2073,7 @@ class SubscriptionServer(Base):
paid_price_kopeks = Column(Integer, default=0)
subscription = relationship('Subscription', backref='subscription_servers')
subscription = relationship('Subscription', backref=backref('subscription_servers', passive_deletes=True))
server_squad = relationship('ServerSquad', backref='subscription_servers')
@@ -2360,7 +2413,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__ = (
+240
View File
@@ -1731,6 +1731,65 @@ async def create_referral_contest_events_table() -> bool:
return False
async def create_referral_contest_virtual_participants_table() -> bool:
table_exists = await check_table_exists('referral_contest_virtual_participants')
if table_exists:
logger.info('Таблица referral_contest_virtual_participants уже существует')
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contest_id INTEGER NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
elif db_type == 'postgresql':
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id SERIAL PRIMARY KEY,
contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE,
display_name VARCHAR(255) NOT NULL,
referral_count INTEGER NOT NULL DEFAULT 0,
total_amount_kopeks INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
)
else:
await conn.execute(
text("""
CREATE TABLE referral_contest_virtual_participants (
id INT AUTO_INCREMENT PRIMARY KEY,
contest_id INT NOT NULL,
display_name VARCHAR(255) NOT NULL,
referral_count INT NOT NULL DEFAULT 0,
total_amount_kopeks INT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE
)
""")
)
logger.info('✅ Таблица referral_contest_virtual_participants создана')
return True
except Exception as error:
logger.error(f'Ошибка создания таблицы referral_contest_virtual_participants: {error}')
return False
async def ensure_referral_contest_summary_columns() -> bool:
ok = True
for column in ['daily_summary_times', 'last_daily_summary_at']:
@@ -3298,6 +3357,54 @@ async def add_media_fields_to_broadcast_history():
return False
async def add_email_fields_to_broadcast_history():
"""Добавление полей для email-рассылки в broadcast_history."""
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ EMAIL В BROADCAST_HISTORY ===')
email_fields = {
'channel': "VARCHAR(20) DEFAULT 'telegram'",
'email_subject': 'VARCHAR(255)',
'email_html_content': 'TEXT',
}
try:
async with engine.begin() as conn:
db_type = await get_database_type()
# Добавление новых полей
for field_name, field_type in email_fields.items():
field_exists = await check_column_exists('broadcast_history', field_name)
if not field_exists:
logger.info(f'Добавление поля {field_name} в таблицу broadcast_history')
alter_sql = f'ALTER TABLE broadcast_history ADD COLUMN {field_name} {field_type}'
await conn.execute(text(alter_sql))
logger.info(f'✅ Поле {field_name} успешно добавлено')
else:
logger.info(f'Поле {field_name} уже существует в broadcast_history')
# Сделать message_text nullable для email-only рассылок
try:
if db_type == 'postgresql':
await conn.execute(text('ALTER TABLE broadcast_history ALTER COLUMN message_text DROP NOT NULL'))
logger.info('✅ Колонка message_text теперь nullable')
elif db_type == 'mysql':
await conn.execute(text('ALTER TABLE broadcast_history MODIFY COLUMN message_text TEXT NULL'))
logger.info('✅ Колонка message_text теперь nullable')
# SQLite не поддерживает ALTER COLUMN, но там по умолчанию nullable
except Exception as e:
# Игнорируем если уже nullable или другая ошибка
logger.debug(f'message_text nullable: {e}')
logger.info('✅ Все поля email в broadcast_history готовы')
return True
except Exception as e:
logger.error(f'Ошибка при добавлении полей email в broadcast_history: {e}')
return False
async def add_ticket_reply_block_columns():
try:
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
@@ -3434,6 +3541,10 @@ async def add_user_cabinet_columns() -> bool:
('password_reset_token', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
('password_reset_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
('cabinet_last_login', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
# Email change fields
('email_change_new', 'VARCHAR(255)', 'VARCHAR(255)', 'VARCHAR(255)'),
('email_change_code', 'VARCHAR(6)', 'VARCHAR(6)', 'VARCHAR(6)'),
('email_change_expires', 'DATETIME', 'TIMESTAMP', 'DATETIME'),
]
try:
@@ -3502,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:
@@ -4983,6 +5121,58 @@ async def add_transaction_receipt_columns() -> bool:
return False
async def add_oauth_provider_columns() -> bool:
"""Добавить колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) в users."""
try:
google_exists = await check_column_exists('users', 'google_id')
yandex_exists = await check_column_exists('users', 'yandex_id')
discord_exists = await check_column_exists('users', 'discord_id')
vk_exists = await check_column_exists('users', 'vk_id')
if google_exists and yandex_exists and discord_exists and vk_exists:
logger.info('Колонки OAuth провайдеров уже существуют в users')
return True
db_type = await get_database_type()
async with engine.begin() as conn:
if not google_exists:
await conn.execute(text('ALTER TABLE users ADD COLUMN google_id VARCHAR(255)'))
logger.info('✅ Добавлена колонка google_id в users')
if not yandex_exists:
await conn.execute(text('ALTER TABLE users ADD COLUMN yandex_id VARCHAR(255)'))
logger.info('✅ Добавлена колонка yandex_id в users')
if not discord_exists:
await conn.execute(text('ALTER TABLE users ADD COLUMN discord_id VARCHAR(255)'))
logger.info('✅ Добавлена колонка discord_id в users')
if not vk_exists:
if db_type == 'postgresql':
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id BIGINT'))
else:
await conn.execute(text('ALTER TABLE users ADD COLUMN vk_id INTEGER'))
logger.info('✅ Добавлена колонка vk_id в users')
# Создаём уникальные индексы
for col in ('google_id', 'yandex_id', 'discord_id', 'vk_id'):
try:
async with engine.begin() as conn:
if db_type in ('postgresql', 'sqlite'):
await conn.execute(text(f'CREATE UNIQUE INDEX IF NOT EXISTS uq_users_{col} ON users ({col})'))
else:
await conn.execute(text(f'CREATE UNIQUE INDEX uq_users_{col} ON users ({col})'))
except Exception as idx_error:
logger.warning(f'Индекс uq_users_{col} возможно уже существует: {idx_error}')
return True
except Exception as error:
logger.error(f'❌ Ошибка добавления колонок OAuth провайдеров в users: {error}')
return False
async def create_withdrawal_requests_table() -> bool:
"""Создаёт таблицу для заявок на вывод реферального баланса."""
try:
@@ -6459,6 +6649,12 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_events')
virtual_participants_ready = await create_referral_contest_virtual_participants_table()
if virtual_participants_ready:
logger.info('✅ Таблица referral_contest_virtual_participants готова')
else:
logger.warning('⚠️ Проблемы с таблицей referral_contest_virtual_participants')
contest_type_ready = await ensure_referral_contest_type_column()
if contest_type_ready:
logger.info('✅ Колонка contest_type для referral_contests готова')
@@ -6632,6 +6828,13 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с добавлением медиа полей')
logger.info('=== ДОБАВЛЕНИЕ EMAIL ПОЛЕЙ В BROADCAST_HISTORY ===')
email_fields_added = await add_email_fields_to_broadcast_history()
if email_fields_added:
logger.info('✅ Email поля в broadcast_history готовы')
else:
logger.warning('⚠️ Проблемы с добавлением email полей')
logger.info('=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===')
tickets_block_cols_added = await add_ticket_reply_block_columns()
if tickets_block_cols_added:
@@ -6921,6 +7124,20 @@ async def run_universal_migration():
else:
logger.warning('⚠️ Проблемы с миграцией transaction_id_cp')
logger.info('=== ДОБАВЛЕНИЕ КОЛОНОК OAUTH ПРОВАЙДЕРОВ ===')
oauth_columns_ready = await add_oauth_provider_columns()
if oauth_columns_ready:
logger.info('✅ Колонки OAuth провайдеров (google_id, yandex_id, discord_id, vk_id) готовы')
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'))
@@ -6986,6 +7203,7 @@ async def check_migration_status():
'pinned_messages_start_mode_column': False,
'users_last_pinned_column': False,
'broadcast_history_media_fields': False,
'broadcast_history_email_fields': False,
'subscription_duplicates': False,
'subscription_conversions_table': False,
'subscription_events_table': False,
@@ -7032,6 +7250,10 @@ async def check_migration_status():
'campaign_tariff_duration_days_column': False,
'campaign_registration_tariff_id_column': False,
'campaign_registration_tariff_duration_days_column': False,
'users_google_id_column': False,
'users_yandex_id_column': False,
'users_discord_id_column': False,
'users_vk_id_column': False,
}
status['has_made_first_topup_column'] = await check_column_exists('users', 'has_made_first_topup')
@@ -7133,6 +7355,13 @@ async def check_migration_status():
)
status['broadcast_history_media_fields'] = media_fields_exist
email_fields_exist = (
await check_column_exists('broadcast_history', 'channel')
and await check_column_exists('broadcast_history', 'email_subject')
and await check_column_exists('broadcast_history', 'email_html_content')
)
status['broadcast_history_email_fields'] = email_fields_exist
pinned_media_columns_exist = (
status['pinned_messages_table']
and await check_column_exists('pinned_messages', 'media_type')
@@ -7156,6 +7385,12 @@ async def check_migration_status():
'transactions', 'receipt_created_at'
)
# Колонки OAuth провайдеров в users
status['users_google_id_column'] = await check_column_exists('users', 'google_id')
status['users_yandex_id_column'] = await check_column_exists('users', 'yandex_id')
status['users_discord_id_column'] = await check_column_exists('users', 'discord_id')
status['users_vk_id_column'] = await check_column_exists('users', 'vk_id')
async with engine.begin() as conn:
duplicates_check = await conn.execute(
text("""
@@ -7185,6 +7420,7 @@ async def check_migration_status():
'pinned_messages_start_mode_column': 'Режим отправки закрепа при /start',
'users_last_pinned_column': 'Колонка last_pinned_message_id у пользователей',
'broadcast_history_media_fields': 'Медиа поля в broadcast_history',
'broadcast_history_email_fields': 'Email поля в broadcast_history',
'subscription_conversions_table': 'Таблица конверсий подписок',
'subscription_events_table': 'Таблица событий подписок',
'subscription_duplicates': 'Отсутствие дубликатов подписок',
@@ -7225,6 +7461,10 @@ async def check_migration_status():
'subscription_temporary_access_table': 'Таблица subscription_temporary_access',
'transactions_receipt_uuid_column': 'Колонка receipt_uuid в transactions',
'transactions_receipt_created_at_column': 'Колонка receipt_created_at в transactions',
'users_google_id_column': 'Колонка google_id в users',
'users_yandex_id_column': 'Колонка yandex_id в users',
'users_discord_id_column': 'Колонка discord_id в users',
'users_vk_id_column': 'Колонка vk_id в users',
}
for check_key, check_status in status.items():
-166
View File
@@ -1,166 +0,0 @@
"""Flask webhook server for PayPalych callbacks."""
from __future__ import annotations
import asyncio
import json
import logging
import threading
from asyncio import AbstractEventLoop
from concurrent.futures import TimeoutError as FuturesTimeoutError
from typing import Any
from flask import Flask, jsonify, request
from werkzeug.serving import make_server
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.services.pal24_service import Pal24APIError, Pal24Service
from app.services.payment_service import PaymentService
logger = logging.getLogger(__name__)
def _normalize_payload() -> dict[str, str]:
if request.is_json:
payload = request.get_json(silent=True) or {}
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
logger.warning('Pal24 webhook JSON payload не является объектом: %s', payload)
return {}
if request.form:
return {k: v for k, v in request.form.items()}
try:
raw_body = request.data.decode('utf-8')
if raw_body:
payload = json.loads(raw_body)
if isinstance(payload, dict):
return {k: str(v) for k, v in payload.items()}
except json.JSONDecodeError:
logger.debug('Pal24 webhook body не удалось распарсить как JSON')
return {}
def create_pal24_flask_app(
payment_service: PaymentService,
loop: AbstractEventLoop,
) -> Flask:
pal24_service = Pal24Service()
app = Flask(__name__)
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['POST'])
def pal24_webhook() -> tuple:
if not pal24_service.is_configured:
logger.error('Pal24 webhook получен, но сервис не настроен')
return jsonify({'status': 'error', 'reason': 'service_not_configured'}), 503
logger.debug('Получен Pal24 webhook: headers=%s', dict(request.headers))
payload = _normalize_payload()
if not payload:
logger.warning('Пустой Pal24 webhook')
return jsonify({'status': 'error', 'reason': 'empty_payload'}), 400
try:
parsed_payload = pal24_service.parse_callback(payload)
except Pal24APIError as error:
logger.error('Ошибка валидации Pal24 webhook: %s', error)
return jsonify({'status': 'error', 'reason': str(error)}), 400
async def process() -> bool:
async with AsyncSessionLocal() as db:
try:
return await payment_service.process_pal24_callback(db, parsed_payload)
except Exception:
await db.rollback()
raise
try:
future = asyncio.run_coroutine_threadsafe(process(), loop)
processed = future.result(timeout=settings.PAL24_REQUEST_TIMEOUT)
except FuturesTimeoutError:
logger.error('Обработка Pal24 webhook превысила таймаут %sс', settings.PAL24_REQUEST_TIMEOUT)
return jsonify({'status': 'error', 'reason': 'timeout'}), 504
except Exception as error: # pragma: no cover - defensive
logger.exception('Критическая ошибка обработки Pal24 webhook: %s', error)
return jsonify({'status': 'error', 'reason': 'internal_error'}), 500
if processed:
return jsonify({'status': 'ok'}), 200
return jsonify({'status': 'error', 'reason': 'not_processed'}), 400
@app.route(settings.PAL24_WEBHOOK_PATH, methods=['GET'])
def pal24_health() -> tuple:
return jsonify(
{
'status': 'ok',
'service': 'pal24_webhook',
'enabled': settings.is_pal24_enabled(),
}
), 200
@app.route('/pal24/health', methods=['GET'])
def pal24_additional_health() -> tuple:
return jsonify(
{
'status': 'ok',
'service': 'pal24_webhook',
'path': settings.PAL24_WEBHOOK_PATH,
}
), 200
return app
class Pal24WebhookServer:
"""Threaded Flask server for Pal24 callbacks."""
def __init__(self, payment_service: PaymentService, loop: AbstractEventLoop) -> None:
self.app = create_pal24_flask_app(payment_service, loop)
self._server: Any | None = None
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._server:
logger.warning('Pal24 webhook server уже запущен')
return
self._server = make_server(
host='0.0.0.0',
port=settings.PAL24_WEBHOOK_PORT,
app=self.app,
threaded=True,
)
def _serve() -> None:
logger.info(
'Pal24 webhook сервер запущен на %s:%s%s',
'0.0.0.0',
settings.PAL24_WEBHOOK_PORT,
settings.PAL24_WEBHOOK_PATH,
)
self._server.serve_forever()
self._thread = threading.Thread(target=_serve, daemon=True)
self._thread.start()
def stop(self) -> None:
if self._server:
logger.info('Останавливаем Pal24 webhook сервер')
self._server.shutdown()
self._server = None
if self._thread and self._thread.is_alive():
self._thread.join(timeout=5)
self._thread = None
async def start_pal24_webhook_server(payment_service: PaymentService) -> Pal24WebhookServer:
loop = asyncio.get_running_loop()
server = Pal24WebhookServer(payment_service, loop)
await loop.run_in_executor(None, server.start)
return server
+104 -20
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import logging
@@ -366,32 +367,64 @@ class RemnaWaveAPI:
raise RemnaWaveAPIError('Session not initialized. Use async context manager.')
url = f'{self.base_url}{endpoint}'
max_retries = 3
base_delay = 1.0
try:
kwargs = {'url': url, 'params': params}
for attempt in range(max_retries + 1):
try:
kwargs = {'url': url, 'params': params}
if data:
kwargs['json'] = data
if data:
kwargs['json'] = data
async with self.session.request(method, **kwargs) as response:
response_text = await response.text()
async with self.session.request(method, **kwargs) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
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]}')
raise RemnaWaveAPIError(error_message, response.status, response_data)
if response.status == 429 and attempt < max_retries:
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
logger.warning(
'Rate limited (429) on %s %s, retry %d/%d after %.1fs',
method,
endpoint,
attempt + 1,
max_retries,
retry_after,
)
await asyncio.sleep(retry_after)
continue
return response_data
if response.status >= 400:
error_message = response_data.get('message', f'HTTP {response.status}')
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)
except aiohttp.ClientError as e:
logger.error(f'Request failed: {e}')
raise RemnaWaveAPIError(f'Request failed: {e!s}')
return response_data
except aiohttp.ClientError as e:
if attempt < max_retries:
delay = base_delay * (2**attempt)
logger.warning(
'Request failed on %s %s: %s, retry %d/%d after %.1fs',
method,
endpoint,
e,
attempt + 1,
max_retries,
delay,
)
await asyncio.sleep(delay)
continue
logger.error(f'Request failed: {e}')
raise RemnaWaveAPIError(f'Request failed: {e!s}')
raise RemnaWaveAPIError(f'Max retries exceeded for {method} {endpoint}')
async def create_user(
self,
@@ -564,6 +597,33 @@ class RemnaWaveAPI:
user = self._parse_user(response['response'])
return await self.enrich_user_with_happ_link(user)
async def get_user_accessible_nodes(self, uuid: str) -> list[RemnaWaveAccessibleNode]:
"""Получает список доступных нод для пользователя"""
try:
response = await self._make_request('GET', f'/api/users/{uuid}/accessible-nodes')
nodes_data = response.get('response', {}).get('activeNodes', [])
result = []
for node in nodes_data:
# Collect inbounds from activeSquads
inbounds: list[str] = []
for squad in node.get('activeSquads', []):
inbounds.extend(squad.get('activeInbounds', []))
result.append(
RemnaWaveAccessibleNode(
uuid=node['uuid'],
node_name=node['nodeName'],
country_code=node['countryCode'],
config_profile_uuid=node.get('configProfileUuid', ''),
config_profile_name=node.get('configProfileName', ''),
active_inbounds=inbounds,
)
)
return result
except RemnaWaveAPIError as e:
if e.status_code == 404:
return []
raise
async def get_all_users(self, start: int = 0, size: int = 100, enrich_happ_links: bool = False) -> dict[str, Any]:
params = {'start': start, 'size': size}
response = await self._make_request('GET', '/api/users', params=params)
@@ -940,6 +1000,30 @@ class RemnaWaveAPI:
uuid=data['uuid'], name=data['name'], view_position=data['viewPosition'], config=data.get('config')
)
async def get_all_hwid_devices(self) -> dict[str, Any]:
"""GET /api/hwid/devices — all devices for all users (paginated, max 1000/page)."""
all_devices: list[dict[str, Any]] = []
start = 0
page_size = 1000
while True:
response = await self._make_request('GET', '/api/hwid/devices', params={'start': start, 'size': page_size})
data = response.get('response', {'devices': [], 'total': 0})
devices = data.get('devices', [])
total = data.get('total', 0)
all_devices.extend(devices)
if len(all_devices) >= total or not devices:
break
start += len(devices)
return {'devices': all_devices, 'total': len(all_devices)}
async def get_all_panel_subscriptions(self) -> list[dict[str, Any]]:
"""GET /api/subscriptions — all panel subscriptions."""
response = await self._make_request('GET', '/api/subscriptions')
return response.get('response') or []
async def get_user_devices(self, user_uuid: str) -> dict[str, Any]:
try:
response = await self._make_request('GET', f'/api/hwid/devices/{user_uuid}')
@@ -1201,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
+1 -1
View File
@@ -91,7 +91,7 @@ class TributeService:
amount_kopeks = data.get('amount', 0)
telegram_user_id = data.get('telegram_user_id')
if event_name == 'new_donation':
if event_name in ('new_donation', 'recurrent_donation'):
status = 'paid'
elif event_name == 'cancelled_subscription':
status = 'cancelled'
+1
View File
@@ -2,6 +2,7 @@
from . import (
backup,
blacklist,
blocked_users,
bot_configuration,
bulk_ban,
campaigns,
+728
View File
@@ -0,0 +1,728 @@
"""
Хендлеры админ-панели для управления заблокированными пользователями.
Позволяет сканировать пользователей, выявлять тех, кто заблокировал бота,
и выполнять очистку БД и панели Remnawave.
"""
import logging
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.blocked_users_service import (
BlockCheckResult,
BlockedUserAction,
BlockedUsersService,
)
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
# =============================================================================
# Enums для текстов и callback_data
# =============================================================================
class BlockedUsersText(Enum):
"""Тексты для сообщений модуля заблокированных пользователей."""
MENU_TITLE = '🔒 <b>Проверка заблокированных пользователей</b>'
MENU_DESCRIPTION = (
'\n\nЗдесь вы можете проверить, какие пользователи заблокировали бота, '
'и очистить их из базы данных и панели Remnawave.\n\n'
'<b>Как это работает:</b>\n'
'1. Сканирование отправляет тестовый запрос каждому пользователю\n'
'2. Если пользователь заблокировал бота - получаем ошибку\n'
'3. Можно удалить таких пользователей из БД и/или Remnawave'
)
SCAN_STARTED = '🔄 <b>Сканирование запущено...</b>\n\nЭто может занять несколько минут.'
SCAN_PROGRESS = '🔄 <b>Сканирование:</b> {checked}/{total} ({percent}%)'
SCAN_COMPLETE = (
'✅ <b>Сканирование завершено</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Проверено: {total_checked}\n'
'• Заблокировали бота: {blocked_count}\n'
'• Активных: {active_users}\n'
'• Ошибок: {errors}\n'
'• Без Telegram ID: {skipped}\n\n'
'⏱ Время сканирования: {duration:.1f}с'
)
SCAN_NO_BLOCKED = '✅ <b>Отлично!</b>\n\nНе найдено пользователей, заблокировавших бота.'
BLOCKED_LIST_TITLE = '🔒 <b>Заблокированные пользователи</b> ({count})\n\n'
BLOCKED_USER_ROW = '{name} (ID: <code>{telegram_id}</code>)\n'
CLEANUP_CONFIRM_TITLE = '⚠️ <b>Подтверждение действия</b>\n\n'
CLEANUP_CONFIRM_DELETE_DB = (
'Вы собираетесь <b>удалить из БД</b> {count} пользователей.\n'
'Это действие необратимо!\n\n'
'Будут удалены:\n'
'• Профили пользователей\n'
'• Подписки\n'
'• Транзакции\n'
'• Реферальные данные'
)
CLEANUP_CONFIRM_DELETE_REMNAWAVE = (
'Вы собираетесь <b>удалить из Remnawave</b> {count} пользователей.\nИх VPN доступ будет полностью отключен.'
)
CLEANUP_CONFIRM_DELETE_BOTH = (
'Вы собираетесь <b>полностью удалить</b> {count} пользователей:\n'
'• Из базы данных бота\n'
'• Из панели Remnawave\n\n'
'Это действие необратимо!'
)
CLEANUP_CONFIRM_MARK = (
'Вы собираетесь <b>пометить как заблокированных</b> {count} пользователей.\n'
'Они останутся в БД, но будут помечены статусом "blocked".'
)
CLEANUP_PROGRESS = '🗑 <b>Очистка:</b> {processed}/{total}'
CLEANUP_COMPLETE = (
'✅ <b>Очистка завершена</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Удалено из БД: {deleted_db}\n'
'• Удалено из Remnawave: {deleted_remnawave}\n'
'• Помечено как заблокированные: {marked}\n'
'• Ошибок: {errors}'
)
BUTTON_START_SCAN = '🔍 Начать сканирование'
BUTTON_VIEW_BLOCKED = '👥 Список заблокированных ({count})'
BUTTON_DELETE_DB = '🗑 Удалить из БД'
BUTTON_DELETE_REMNAWAVE = '🌐 Удалить из Remnawave'
BUTTON_DELETE_BOTH = '💀 Удалить везде'
BUTTON_MARK_BLOCKED = '🚫 Пометить как заблокированных'
BUTTON_CONFIRM = '✅ Подтвердить'
BUTTON_CANCEL = '❌ Отмена'
BUTTON_BACK = '⬅️ Назад'
BUTTON_BACK_TO_USERS = '⬅️ К пользователям'
class BlockedUsersCallback(Enum):
"""Callback data для кнопок модуля."""
MENU = 'admin_blocked_users'
START_SCAN = 'admin_blocked_scan'
VIEW_LIST = 'admin_blocked_list'
VIEW_LIST_PAGE = 'admin_blocked_list_page_'
ACTION_DELETE_DB = 'admin_blocked_action_db'
ACTION_DELETE_REMNAWAVE = 'admin_blocked_action_rw'
ACTION_DELETE_BOTH = 'admin_blocked_action_both'
ACTION_MARK = 'admin_blocked_action_mark'
CONFIRM_PREFIX = 'admin_blocked_confirm_'
CANCEL = 'admin_blocked_cancel'
# =============================================================================
# FSM States
# =============================================================================
class BlockedUsersStates(StatesGroup):
"""Состояния FSM для модуля заблокированных пользователей."""
scanning = State()
viewing_results = State()
confirming_action = State()
processing_cleanup = State()
# =============================================================================
# Keyboards
# =============================================================================
def get_blocked_users_menu_keyboard(
scan_result: dict[str, Any] | None = None,
) -> InlineKeyboardMarkup:
"""Клавиатура главного меню модуля."""
buttons = [
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_START_SCAN.value,
callback_data=BlockedUsersCallback.START_SCAN.value,
)
]
]
blocked_count = scan_result.get('blocked_count', 0) if scan_result else 0
if blocked_count > 0:
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_VIEW_BLOCKED.value.format(count=blocked_count),
callback_data=BlockedUsersCallback.VIEW_LIST.value,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK_TO_USERS.value,
callback_data='admin_users',
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_blocked_list_keyboard(
page: int = 1,
total_pages: int = 1,
has_blocked: bool = True,
) -> InlineKeyboardMarkup:
"""Клавиатура списка заблокированных пользователей."""
buttons = []
# Пагинация
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page - 1}',
)
)
nav_row.append(
InlineKeyboardButton(
text=f'{page}/{total_pages}',
callback_data='noop',
)
)
if page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page + 1}',
)
)
buttons.append(nav_row)
# Действия
if has_blocked:
buttons.extend(
[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_DB.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_DB.value,
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_REMNAWAVE.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_BOTH.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_BOTH.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_MARK_BLOCKED.value,
callback_data=BlockedUsersCallback.ACTION_MARK.value,
),
],
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK.value,
callback_data=BlockedUsersCallback.MENU.value,
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_keyboard(action: BlockedUserAction) -> InlineKeyboardMarkup:
"""Клавиатура подтверждения действия."""
action_map = {
BlockedUserAction.DELETE_FROM_DB: 'db',
BlockedUserAction.DELETE_FROM_REMNAWAVE: 'rw',
BlockedUserAction.DELETE_BOTH: 'both',
BlockedUserAction.MARK_AS_BLOCKED: 'mark',
}
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CONFIRM.value,
callback_data=f'{BlockedUsersCallback.CONFIRM_PREFIX.value}{action_map[action]}',
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CANCEL.value,
callback_data=BlockedUsersCallback.CANCEL.value,
),
]
]
)
# =============================================================================
# Handlers
# =============================================================================
@admin_required
@error_handler
async def show_blocked_users_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Показывает главное меню модуля заблокированных пользователей."""
data = await state.get_data()
scan_result = data.get('blocked_users_scan_result')
text = BlockedUsersText.MENU_TITLE.value + BlockedUsersText.MENU_DESCRIPTION.value
if scan_result:
text += (
f'\n\n📊 <b>Последнее сканирование:</b>\n'
f'• Заблокированных: {scan_result.get("blocked_count", 0)}\n'
f'• Активных: {scan_result.get("active_users", 0)}'
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result),
)
await callback.answer()
@admin_required
@error_handler
async def start_scan(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Запускает сканирование пользователей."""
await state.set_state(BlockedUsersStates.scanning)
# Отправляем начальное сообщение
await callback.message.edit_text(
BlockedUsersText.SCAN_STARTED.value,
parse_mode=ParseMode.HTML,
)
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(checked: int, total: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
# Обновляем сообщение не чаще раза в 3 секунды
if (now - last_update_time).total_seconds() >= 3:
last_update_time = now
percent = int(checked / total * 100) if total > 0 else 0
try:
await callback.message.edit_text(
BlockedUsersText.SCAN_PROGRESS.value.format(
checked=checked,
total=total,
percent=percent,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass # Игнорируем ошибки обновления сообщения
# Выполняем сканирование
result = await service.scan_all_users(
db,
only_active=True,
progress_callback=progress_callback,
)
# Сериализуем результат в dict для Redis и keyboard
scan_result_dict = {
'total_checked': result.total_checked,
'blocked_count': result.blocked_count,
'active_users': result.active_users,
'errors': result.errors,
'skipped_no_telegram': result.skipped_no_telegram,
'scan_duration_seconds': result.scan_duration_seconds,
}
# Сохраняем результат в state
await state.update_data(
blocked_users_scan_result=scan_result_dict,
blocked_users_list=[
{
'user_id': u.user_id,
'telegram_id': u.telegram_id,
'username': u.username,
'full_name': u.full_name,
'remnawave_uuid': u.remnawave_uuid,
}
for u in result.blocked_users
],
)
await state.set_state(BlockedUsersStates.viewing_results)
# Формируем итоговое сообщение
if result.blocked_count == 0:
text = BlockedUsersText.SCAN_NO_BLOCKED.value
else:
text = BlockedUsersText.SCAN_COMPLETE.value.format(
total_checked=result.total_checked,
blocked_count=result.blocked_count,
active_users=result.active_users,
errors=result.errors,
skipped=result.skipped_no_telegram,
duration=result.scan_duration_seconds,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result_dict),
)
await callback.answer()
@admin_required
@error_handler
async def show_blocked_list(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
page: int = 1,
) -> None:
"""Показывает список заблокированных пользователей."""
data = await state.get_data()
blocked_list: list[dict[str, Any]] = data.get('blocked_users_list', [])
if not blocked_list:
await callback.answer('Нет заблокированных пользователей', show_alert=True)
return
# Пагинация
per_page = 15
total_pages = (len(blocked_list) + per_page - 1) // per_page
page = max(1, min(page, total_pages))
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_users = blocked_list[start_idx:end_idx]
text = BlockedUsersText.BLOCKED_LIST_TITLE.value.format(count=len(blocked_list))
for user_data in page_users:
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
telegram_id = user_data.get('telegram_id', '?')
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
name=name,
telegram_id=telegram_id,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_list_keyboard(page, total_pages, bool(blocked_list)),
)
await callback.answer()
@admin_required
@error_handler
async def handle_blocked_list_pagination(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает пагинацию списка заблокированных."""
try:
page = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
page = 1
await show_blocked_list(callback, db_user, state, page)
@admin_required
@error_handler
async def show_action_confirm(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
action: BlockedUserAction,
) -> None:
"""Показывает подтверждение действия."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
count = len(blocked_list)
if count == 0:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.confirming_action)
await state.update_data(pending_action=action.value)
text = BlockedUsersText.CLEANUP_CONFIRM_TITLE.value
if action == BlockedUserAction.DELETE_FROM_DB:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_DB.value.format(count=count)
elif action == BlockedUserAction.DELETE_FROM_REMNAWAVE:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_REMNAWAVE.value.format(count=count)
elif action == BlockedUserAction.DELETE_BOTH:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_BOTH.value.format(count=count)
elif action == BlockedUserAction.MARK_AS_BLOCKED:
text += BlockedUsersText.CLEANUP_CONFIRM_MARK.value.format(count=count)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_confirm_keyboard(action),
)
await callback.answer()
@admin_required
@error_handler
async def handle_action_delete_db(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из БД."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_DB)
@admin_required
@error_handler
async def handle_action_delete_remnawave(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из Remnawave."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_REMNAWAVE)
@admin_required
@error_handler
async def handle_action_delete_both(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор полного удаления."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_BOTH)
@admin_required
@error_handler
async def handle_action_mark(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор пометки как заблокированных."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.MARK_AS_BLOCKED)
@admin_required
@error_handler
async def handle_confirm_action(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Выполняет подтвержденное действие."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
# Определяем действие из callback_data
action_code = callback.data.replace(BlockedUsersCallback.CONFIRM_PREFIX.value, '')
action_map = {
'db': BlockedUserAction.DELETE_FROM_DB,
'rw': BlockedUserAction.DELETE_FROM_REMNAWAVE,
'both': BlockedUserAction.DELETE_BOTH,
'mark': BlockedUserAction.MARK_AS_BLOCKED,
}
action = action_map.get(action_code)
if not action:
await callback.answer('Неизвестное действие', show_alert=True)
return
if not blocked_list:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.processing_cleanup)
# Преобразуем обратно в BlockCheckResult
blocked_results = [
BlockCheckResult(
user_id=u['user_id'],
telegram_id=u['telegram_id'],
username=u['username'],
full_name=u['full_name'],
status=None, # type: ignore
remnawave_uuid=u['remnawave_uuid'],
)
for u in blocked_list
]
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(processed: int, total_count: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
if (now - last_update_time).total_seconds() >= 2:
last_update_time = now
try:
await callback.message.edit_text(
BlockedUsersText.CLEANUP_PROGRESS.value.format(
processed=processed,
total=total_count,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass
# Выполняем очистку
result = await service.cleanup_blocked_users(
db,
blocked_results,
action,
progress_callback=progress_callback,
)
# Очищаем сохраненные данные
await state.update_data(
blocked_users_scan_result=None,
blocked_users_list=[],
pending_action=None,
)
await state.set_state(None)
# Показываем результат
text = BlockedUsersText.CLEANUP_COMPLETE.value.format(
deleted_db=result.deleted_from_db,
deleted_remnawave=result.deleted_from_remnawave,
marked=result.marked_as_blocked,
errors=len(result.errors),
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(),
)
logger.info(
f'Очистка заблокированных пользователей завершена: '
f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, '
f'marked={result.marked_as_blocked}, errors={len(result.errors)}'
)
await callback.answer()
@admin_required
@error_handler
async def handle_cancel(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Отменяет текущее действие и возвращает в меню."""
await state.update_data(pending_action=None)
await state.set_state(BlockedUsersStates.viewing_results)
await show_blocked_users_menu(callback, db_user, state)
# =============================================================================
# Registration
# =============================================================================
def register_handlers(dp: Dispatcher) -> None:
"""Регистрирует хендлеры модуля заблокированных пользователей."""
# Главное меню
dp.callback_query.register(
show_blocked_users_menu,
F.data == BlockedUsersCallback.MENU.value,
)
# Сканирование
dp.callback_query.register(
start_scan,
F.data == BlockedUsersCallback.START_SCAN.value,
)
# Список заблокированных
dp.callback_query.register(
show_blocked_list,
F.data == BlockedUsersCallback.VIEW_LIST.value,
)
# Пагинация списка
dp.callback_query.register(
handle_blocked_list_pagination,
F.data.startswith(BlockedUsersCallback.VIEW_LIST_PAGE.value),
)
# Выбор действий
dp.callback_query.register(
handle_action_delete_db,
F.data == BlockedUsersCallback.ACTION_DELETE_DB.value,
)
dp.callback_query.register(
handle_action_delete_remnawave,
F.data == BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
)
dp.callback_query.register(
handle_action_delete_both,
F.data == BlockedUsersCallback.ACTION_DELETE_BOTH.value,
)
dp.callback_query.register(
handle_action_mark,
F.data == BlockedUsersCallback.ACTION_MARK.value,
)
# Подтверждение действий
dp.callback_query.register(
handle_confirm_action,
F.data.startswith(BlockedUsersCallback.CONFIRM_PREFIX.value),
)
# Отмена
dp.callback_query.register(
handle_cancel,
F.data == BlockedUsersCallback.CANCEL.value,
)
+467 -9
View File
@@ -9,15 +9,19 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.referral_contest import (
add_virtual_participant,
create_referral_contest,
delete_referral_contest,
delete_virtual_participant,
get_contest_events_count,
get_contest_leaderboard,
get_contest_leaderboard_with_virtual,
get_referral_contest,
get_referral_contests_count,
list_referral_contests,
list_virtual_participants,
toggle_referral_contest,
update_referral_contest,
update_virtual_participant_count,
)
from app.keyboards.admin import (
get_admin_contests_keyboard,
@@ -240,8 +244,10 @@ async def show_contest_details(
return
tz = _ensure_timezone(contest.timezone or settings.TIMEZONE)
leaderboard = await get_contest_leaderboard(db, contest.id, limit=5)
total_events = await get_contest_events_count(db, contest.id)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest.id, limit=5)
virtual_list = await list_virtual_participants(db, contest.id)
virtual_count = sum(vp.referral_count for vp in virtual_list)
total_events = await get_contest_events_count(db, contest.id) + virtual_count
lines = [
f'🏆 <b>{contest.title}</b>',
@@ -256,8 +262,9 @@ async def show_contest_details(
if leaderboard:
lines.append('')
lines.append(texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'))
for idx, (user, score, _) in enumerate(leaderboard, start=1):
lines.append(f'{idx}. {user.full_name}{score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -427,7 +434,7 @@ async def show_leaderboard(
await callback.answer(texts.t('ADMIN_CONTEST_NOT_FOUND', 'Конкурс не найден.'), show_alert=True)
return
leaderboard = await get_contest_leaderboard(db, contest_id, limit=10)
leaderboard = await get_contest_leaderboard_with_virtual(db, contest_id, limit=10)
if not leaderboard:
await callback.answer(texts.t('ADMIN_CONTEST_EMPTY_LEADERBOARD', 'Пока нет участников.'), show_alert=True)
return
@@ -435,9 +442,9 @@ async def show_leaderboard(
lines = [
texts.t('ADMIN_CONTEST_LEADERBOARD_TITLE', '📊 Топ участников:'),
]
for idx, (user, score, _) in enumerate(leaderboard, start=1):
user_id_display = user.telegram_id or user.email or f'#{user.id}'
lines.append(f'{idx}. {user.full_name} ({user_id_display}){score}')
for idx, (name, score, _, is_virtual) in enumerate(leaderboard, start=1):
virt_mark = ' 👻' if is_virtual else ''
lines.append(f'{idx}. {name}{virt_mark}{score}')
await callback.message.edit_text(
'\n'.join(lines),
@@ -676,6 +683,9 @@ async def show_detailed_stats(
from app.services.referral_contest_service import referral_contest_service
stats = await referral_contest_service.get_detailed_contest_stats(db, contest_id)
virtual = await list_virtual_participants(db, contest_id)
virtual_count = len(virtual)
virtual_referrals = sum(vp.referral_count for vp in virtual)
# Общее сообщение с основной статистикой
general_lines = [
@@ -693,6 +703,10 @@ async def show_detailed_stats(
f' 📥 Пополнения баланса: <b>{stats.get("deposit_total", 0) // 100} руб.</b>',
]
if virtual_count > 0:
general_lines.append('')
general_lines.append(f'👻 Виртуальных: <b>{virtual_count}</b> (рефералов: {virtual_referrals})')
await callback.message.edit_text(
'\n'.join(general_lines),
reply_markup=get_referral_contest_manage_keyboard(
@@ -970,6 +984,439 @@ async def debug_contest_transactions(
)
# ── Виртуальные участники ──────────────────────────────────────────────
@admin_required
@error_handler
async def show_virtual_participants(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
contest_id = int(callback.data.split('_')[-1])
contest = await get_referral_contest(db, contest_id)
if not contest:
await callback.answer('Конкурс не найден.', show_alert=True)
return
vps = await list_virtual_participants(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for vp in vps:
lines.append(f'{vp.display_name}{vp.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[
types.InlineKeyboardButton(
text=' Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
types.InlineKeyboardButton(
text='🎭 Массовка',
callback_data=f'admin_contest_vp_mass_{contest_id}',
),
],
]
if vps:
for vp in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {vp.display_name}',
callback_data=f'admin_contest_vp_edit_{vp.id}',
),
types.InlineKeyboardButton(
text='🗑',
callback_data=f'admin_contest_vp_del_{vp.id}',
),
]
)
rows.append(
[
types.InlineKeyboardButton(
text='⬅️ Назад',
callback_data=f'admin_contest_view_{contest_id}',
),
]
)
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
await callback.answer()
@admin_required
@error_handler
async def start_add_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
contest_id = int(callback.data.split('_')[-1])
await state.set_state(AdminStates.adding_virtual_participant_name)
await state.update_data(vp_contest_id=contest_id)
await callback.message.edit_text(
'👻 Введите отображаемое имя виртуального участника:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_virtual_participant_name(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
name = message.text.strip()
if not name or len(name) > 200:
await message.answer('Имя должно быть от 1 до 200 символов. Попробуйте ещё раз:')
return
await state.update_data(vp_name=name)
await state.set_state(AdminStates.adding_virtual_participant_count)
await message.answer(f'Имя: <b>{name}</b>\n\nВведите количество рефералов (число):')
@admin_required
@error_handler
async def process_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
contest_id = data['vp_contest_id']
display_name = data['vp_name']
await state.clear()
vp = await add_virtual_participant(db, contest_id, display_name, count)
await message.answer(
f'✅ Виртуальный участник добавлен:\nИмя: <b>{vp.display_name}</b>\nРефералов: <b>{vp.referral_count}</b>',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def delete_virtual_participant_handler(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
):
vp_id = int(callback.data.split('_')[-1])
# Получим contest_id до удаления
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
contest_id = vp.contest_id
deleted = await delete_virtual_participant(db, vp_id)
if deleted:
await callback.answer('✅ Удалён', show_alert=False)
else:
await callback.answer('Не удалось удалить.', show_alert=True)
# Вернуться к списку
vps = await list_virtual_participants(db, contest_id)
contest = await get_referral_contest(db, contest_id)
lines = [f'👻 <b>Виртуальные участники</b> — {contest.title}', '']
if vps:
for v in vps:
lines.append(f'{v.display_name}{v.referral_count} реф.')
else:
lines.append('Пока нет виртуальных участников.')
rows = [
[
types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
],
]
if vps:
for v in vps:
rows.append(
[
types.InlineKeyboardButton(
text=f'✏️ {v.display_name}', callback_data=f'admin_contest_vp_edit_{v.id}'
),
types.InlineKeyboardButton(text='🗑', callback_data=f'admin_contest_vp_del_{v.id}'),
]
)
rows.append([types.InlineKeyboardButton(text='⬅️ Назад', callback_data=f'admin_contest_view_{contest_id}')])
await callback.message.edit_text(
'\n'.join(lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=rows),
)
@admin_required
@error_handler
async def start_mass_virtual_participants(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Начинает массовое создание виртуальных участников (массовка)."""
contest_id = int(callback.data.split('_')[-1])
await state.set_state(AdminStates.adding_mass_virtual_count)
await state.update_data(mass_vp_contest_id=contest_id)
text = """
🎭 <b>Массовка массовое создание виртуальных участников</b>
<i>Для чего это нужно?</i>
Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
Это помогает:
Мотивировать реальных участников соревноваться
Задать планку для участия
Сделать конкурс более живым
<b>Введите количество призраков для создания:</b>
<i>(от 1 до 50)</i>
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_mass_virtual_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает количество призраков для массового создания."""
try:
count = int(message.text.strip())
if count < 1 or count > 50:
await message.answer(
'❌ Введите число от 1 до 50:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
]
),
)
return
except ValueError:
await message.answer(
'❌ Введите корректное число от 1 до 50:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
]
),
)
return
await state.update_data(mass_vp_count=count)
await state.set_state(AdminStates.adding_mass_virtual_referrals)
data = await state.get_data()
contest_id = data.get('mass_vp_contest_id')
await message.answer(
f'✅ Будет создано <b>{count}</b> призраков.\n\n'
f'<b>Введите количество рефералов у каждого:</b>\n'
f'<i>(от 1 до 100)</i>',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def process_mass_virtual_referrals(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Создаёт массовку призраков с рандомными именами."""
import random
import string
try:
referrals_count = int(message.text.strip())
if referrals_count < 1 or referrals_count > 100:
await message.answer('❌ Введите число от 1 до 100:')
return
except ValueError:
await message.answer('❌ Введите корректное число от 1 до 100:')
return
data = await state.get_data()
contest_id = data.get('mass_vp_contest_id')
ghost_count = data.get('mass_vp_count', 1)
await state.clear()
# Генерируем и создаём призраков
created = []
for _ in range(ghost_count):
# Рандомное имя до 5 символов (буквы + цифры)
name_length = random.randint(3, 5)
name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
vp = await add_virtual_participant(db, contest_id, name, referrals_count)
created.append(vp)
# Показываем результат
text = f"""
<b>Массовка создана!</b>
📊 <b>Результат:</b>
Создано призраков: {len(created)}
Рефералов у каждого: {referrals_count}
Всего виртуальных рефералов: {len(created) * referrals_count}
👻 <b>Созданные призраки:</b>
"""
for vp in created[:10]:
text += f'{vp.display_name}{vp.referral_count} реф.\n'
if len(created) > 10:
text += f'<i>... и ещё {len(created) - 10}</i>\n'
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}'
)
],
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def start_edit_virtual_participant(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
vp_id = int(callback.data.split('_')[-1])
from sqlalchemy import select as sa_select
from app.database.models import ReferralContestVirtualParticipant
result = await db.execute(
sa_select(ReferralContestVirtualParticipant).where(ReferralContestVirtualParticipant.id == vp_id)
)
vp = result.scalar_one_or_none()
if not vp:
await callback.answer('Участник не найден.', show_alert=True)
return
await state.set_state(AdminStates.editing_virtual_participant_count)
await state.update_data(vp_edit_id=vp_id, vp_edit_contest_id=vp.contest_id)
await callback.message.edit_text(
f'✏️ <b>{vp.display_name}</b>\n'
f'Текущее кол-во рефералов: <b>{vp.referral_count}</b>\n\n'
f'Введите новое количество:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{vp.contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_virtual_participant_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
try:
count = int(message.text.strip())
if count < 1:
raise ValueError
except (ValueError, TypeError):
await message.answer('Введите положительное целое число:')
return
data = await state.get_data()
vp_id = data['vp_edit_id']
contest_id = data['vp_edit_contest_id']
await state.clear()
vp = await update_virtual_participant_count(db, vp_id, count)
if vp:
await message.answer(
f'✅ Обновлено: <b>{vp.display_name}</b> — {vp.referral_count} реф.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='👻 К списку', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
else:
await message.answer('Участник не найден.')
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_contests_menu, F.data == 'admin_contests')
dp.callback_query.register(show_referral_contests_menu, F.data == 'admin_contests_referral')
@@ -996,3 +1443,14 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_end_date, AdminStates.creating_referral_contest_end)
dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time)
dp.message.register(process_edit_summary_times, AdminStates.editing_referral_contest_summary_times)
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
+301 -146
View File
@@ -45,7 +45,7 @@ from app.services.pinned_message_service import (
)
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.miniapp_buttons import BUTTON_KEY_TO_CABINET_PATH, build_miniapp_or_callback_button
logger = logging.getLogger(__name__)
@@ -76,12 +76,14 @@ async def safe_edit_or_send_text(callback: types.CallbackQuery, text: str, reply
BUTTON_ROWS = BROADCAST_BUTTON_ROWS
DEFAULT_SELECTED_BUTTONS = DEFAULT_BROADCAST_BUTTONS
TEXT_MENU_MINIAPP_BUTTON_KEYS = {
CABINET_MINIAPP_BUTTON_KEYS = {
'balance',
'referrals',
'promocode',
'connect',
'subscription',
'support',
'home',
}
@@ -106,11 +108,12 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
if button_key not in selected_buttons:
continue
button_config = button_config_map[button_key]
if settings.is_text_main_menu_mode() and button_key in TEXT_MENU_MINIAPP_BUTTON_KEYS:
if settings.is_cabinet_mode() and button_key in CABINET_MINIAPP_BUTTON_KEYS:
row_buttons.append(
build_miniapp_or_callback_button(
text=button_config['text'],
callback_data=button_config['callback'],
cabinet_path=BUTTON_KEY_TO_CABINET_PATH.get(button_key, ''),
)
)
else:
@@ -127,58 +130,78 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
async def _persist_broadcast_result(
db: AsyncSession,
broadcast_history: BroadcastHistory,
broadcast_id: int,
sent_count: int,
failed_count: int,
status: str,
) -> None:
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
"""
Сохраняет результаты рассылки в НОВОЙ сессии.
# Сохраняем ID и время завершения в локальные переменные ДО операций с БД,
# чтобы избежать обращения к атрибутам отсоединенного объекта при потере соединения
broadcast_id = broadcast_history.id
ВАЖНО: Используем свежую сессию вместо переданной, потому что за время
долгой рассылки (минуты/часы) оригинальное соединение гарантированно
закроется по таймауту PostgreSQL (idle_in_transaction_session_timeout).
Args:
broadcast_id: ID записи BroadcastHistory (не ORM-объект!)
sent_count: Количество успешно отправленных сообщений
failed_count: Количество неудачных отправок
status: Финальный статус рассылки ('completed', 'partial', 'failed')
"""
completed_at = datetime.utcnow()
max_retries = 3
retry_delay = 1.0
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
for attempt in range(1, max_retries + 1):
try:
async with AsyncSessionLocal() as session:
broadcast_history = await session.get(BroadcastHistory, broadcast_id)
if not broadcast_history:
logger.critical(
'Не удалось найти запись BroadcastHistory #%s для записи результатов',
broadcast_id,
)
return
try:
await db.commit()
return
except InterfaceError as error:
logger.warning(
'Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз',
exc_info=error,
)
await db.rollback()
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
await session.commit()
try:
async with AsyncSessionLocal() as retry_session:
retry_history = await retry_session.get(BroadcastHistory, broadcast_id)
if not retry_history:
logger.critical(
'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов',
logger.info(
'Результаты рассылки сохранены (id=%s, sent=%d, failed=%d, status=%s)',
broadcast_id,
sent_count,
failed_count,
status,
)
return
retry_history.sent_count = sent_count
retry_history.failed_count = failed_count
retry_history.status = status
retry_history.completed_at = completed_at
await retry_session.commit()
logger.info(
'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)',
broadcast_id,
except InterfaceError as error:
logger.warning(
'Ошибка соединения при сохранении результатов рассылки (попытка %d/%d): %s',
attempt,
max_retries,
error,
)
except Exception as retry_error:
logger.critical(
'Не удалось сохранить результаты рассылки после восстановления подключения',
exc_info=retry_error,
)
if attempt < max_retries:
await asyncio.sleep(retry_delay)
retry_delay *= 2
else:
logger.critical(
'Не удалось сохранить результаты рассылки после %d попыток (id=%s)',
max_retries,
broadcast_id,
)
except Exception as error:
logger.critical(
'Неожиданная ошибка при сохранении результатов рассылки (id=%s)',
broadcast_id,
exc_info=error,
)
return
@admin_required
@@ -1113,15 +1136,38 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
media_file_id = data.get('media_file_id')
media_caption = data.get('media_caption')
# =========================================================================
# КРИТИЧНО: Извлекаем ВСЕ скалярные значения из ORM-объектов СЕЙЧАС,
# пока сессия активна. После начала рассылки соединение с БД может
# закрыться по таймауту, и любое обращение к атрибутам ORM вызовет:
# - MissingGreenlet (lazy loading вне async контекста)
# - InterfaceError (соединение закрыто)
# =========================================================================
admin_id: int = db_user.id
admin_name: str = db_user.full_name # property, читает first_name/last_name
admin_telegram_id: int | None = db_user.telegram_id
admin_language: str = db_user.language
await safe_edit_or_send_text(
callback, '📨 Начинаю рассылку...\n\n⏳ Это может занять несколько минут.', reply_markup=None, parse_mode='HTML'
callback,
'📨 <b>Подготовка рассылки...</b>\n\n⏳ Загружаю список получателей...',
reply_markup=None,
parse_mode='HTML',
)
# Загружаем пользователей и сразу извлекаем telegram_id в список
# чтобы не обращаться к ORM-объектам во время долгой рассылки
if target.startswith('custom_'):
users = await get_custom_users(db, target.replace('custom_', ''))
users_orm = await get_custom_users(db, target.replace('custom_', ''))
else:
users = await get_target_users(db, target)
users_orm = await get_target_users(db, target)
# Извлекаем только telegram_id - это всё что нужно для отправки
# Фильтруем None (email-only пользователи)
recipient_telegram_ids: list[int] = [user.telegram_id for user in users_orm if user.telegram_id is not None]
total_users_count = len(users_orm)
# Создаём запись истории рассылки
broadcast_history = BroadcastHistory(
target_type=target,
message_text=message_text,
@@ -1129,145 +1175,252 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
total_count=len(users),
total_count=total_users_count,
sent_count=0,
failed_count=0,
admin_id=db_user.id,
admin_name=db_user.full_name,
admin_id=admin_id,
admin_name=admin_name,
status='in_progress',
)
db.add(broadcast_history)
await db.commit()
await db.refresh(broadcast_history)
# Сохраняем ID - это единственное что нам нужно после коммита
broadcast_id: int = broadcast_history.id
# =========================================================================
# С этого момента НЕ используем db сессию и ORM-объекты!
# Работаем только со скалярными значениями.
# =========================================================================
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, admin_language)
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
# =========================================================================
# Rate limiting: Telegram допускает ~30 msg/sec для бота.
# Используем batch_size=25 + 1 сек задержка между батчами = ~25 msg/sec
# с запасом, чтобы не получать FloodWait.
# Semaphore=25 — все сообщения батча отправляются параллельно.
# =========================================================================
_BATCH_SIZE = 25
_BATCH_DELAY = 1.0 # секунда между батчами
_MAX_SEND_RETRIES = 3
# Обновляем прогресс каждые N батчей (не каждое сообщение — иначе FloodWait на edit_text)
_PROGRESS_UPDATE_INTERVAL = max(1, 500 // _BATCH_SIZE) # ~каждые 500 сообщений
# Минимальный интервал между обновлениями прогресса (секунды)
_PROGRESS_MIN_INTERVAL = 5.0
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Пропуск email-пользователя %s при рассылке', user.id)
return False, None
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
flood_wait_until: float = 0.0
async with semaphore:
for attempt in range(3):
try:
if has_media and media_file_id:
if media_type == 'photo':
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
elif media_type == 'video':
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
elif media_type == 'document':
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
async def send_single_broadcast(telegram_id: int) -> bool:
"""Отправляет одно сообщение. Возвращает True при успехе."""
nonlocal flood_wait_until
for attempt in range(_MAX_SEND_RETRIES):
# Глобальная пауза при FloodWait
now = asyncio.get_event_loop().time()
if flood_wait_until > now:
await asyncio.sleep(flood_wait_until - now)
try:
if has_media and media_file_id:
send_method = {
'photo': callback.bot.send_photo,
'video': callback.bot.send_video,
'document': callback.bot.send_document,
}.get(media_type)
if send_method:
media_kwarg = {
'photo': 'photo',
'video': 'video',
'document': 'document',
}[media_type]
await send_method(
chat_id=telegram_id,
**{media_kwarg: media_file_id},
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
else:
# Неизвестный media_type — отправляем как текст
await callback.bot.send_message(
chat_id=user.telegram_id,
chat_id=telegram_id,
text=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
await asyncio.sleep(per_message_delay)
return True, user.telegram_id
except TelegramRetryAfter as e:
retry_delay = min(e.retry_after + 1, 30)
logger.warning(f'Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек.')
await asyncio.sleep(retry_delay)
except TelegramForbiddenError:
# Пользователь мог удалить бота или запретить сообщения
logger.info(f'Рассылка недоступна для пользователя {user.telegram_id}: Forbidden')
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(f'Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}')
return False, user.telegram_id
except Exception as e:
logger.error(
f'Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}'
else:
await callback.bot.send_message(
chat_id=telegram_id,
text=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
return True
except TelegramRetryAfter as e:
# Глобальная пауза — тормозим все корутины
wait_seconds = e.retry_after + 1
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
logger.warning(
'FloodWait: Telegram просит подождать %d сек (пользователь %d, попытка %d/%d)',
e.retry_after,
telegram_id,
attempt + 1,
_MAX_SEND_RETRIES,
)
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return False
except TelegramBadRequest as e:
logger.debug('BadRequest при рассылке пользователю %d: %s', telegram_id, e)
return False
except Exception as e:
logger.error(
'Ошибка отправки пользователю %d (попытка %d/%d): %s',
telegram_id,
attempt + 1,
_MAX_SEND_RETRIES,
e,
)
if attempt < _MAX_SEND_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 50
for i in range(0, len(users), batch_size):
batch = users[i : i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
# =========================================================================
# Прогресс-бар в реальном времени (как в сканере заблокированных)
# =========================================================================
total_recipients = len(recipient_telegram_ids)
last_progress_update: float = 0.0
# ID сообщения, которое обновляем (может быть заменено при ошибке)
progress_message = callback.message
def _build_progress_text(
current_sent: int,
current_failed: int,
total: int,
phase: str = 'sending',
) -> str:
processed = current_sent + current_failed
percent = round(processed / total * 100, 1) if total > 0 else 0
bar_length = 20
filled = int(bar_length * processed / total) if total > 0 else 0
bar = '' * filled + '' * (bar_length - filled)
if phase == 'sending':
return (
f'📨 <b>Рассылка в процессе...</b>\n\n'
f'[{bar}] {percent}%\n\n'
f'📊 <b>Прогресс:</b>\n'
f'• Отправлено: {current_sent}\n'
f'• Ошибок: {current_failed}\n'
f'• Обработано: {processed}/{total}\n\n'
f'⏳ Не закрывайте диалог — рассылка продолжается...'
)
return ''
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
"""Безопасно обновляет сообщение с прогрессом."""
nonlocal last_progress_update, progress_message
now = asyncio.get_event_loop().time()
if now - last_progress_update < _PROGRESS_MIN_INTERVAL:
return
last_progress_update = now
text = _build_progress_text(current_sent, current_failed, total_recipients)
try:
await progress_message.edit_text(text, parse_mode='HTML')
except TelegramRetryAfter as e:
# Не паникуем — пропускаем обновление прогресса
logger.debug('FloodWait при обновлении прогресса, пропускаем: %d сек', e.retry_after)
except TelegramBadRequest:
# Сообщение удалено или контент не изменился — отправляем новое
try:
progress_message = await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=text,
parse_mode='HTML',
)
except Exception:
pass
except Exception:
pass # Не ломаем рассылку из-за ошибок обновления прогресса
# Первое обновление прогресса
await _update_progress_message(0, 0)
# =========================================================================
# Основной цикл рассылки — батчами по _BATCH_SIZE
# =========================================================================
for batch_idx, i in enumerate(range(0, total_recipients, _BATCH_SIZE)):
batch = recipient_telegram_ids[i : i + _BATCH_SIZE]
# Отправляем батч параллельно
results = await asyncio.gather(
*[send_single_broadcast(tid) for tid in batch],
return_exceptions=True,
)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
if isinstance(result, bool):
if result:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
logger.error('Необработанное исключение в рассылке: %s', result)
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.25)
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
await _update_progress_message(sent_count, failed_count)
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
# Учитываем пропущенных email-only пользователей
skipped_email_users = total_users_count - total_recipients
if skipped_email_users > 0:
logger.info('Пропущено %d email-only пользователей при рассылке', skipped_email_users)
status = 'completed' if failed_count == 0 else 'partial'
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
await _persist_broadcast_result(
db=db,
broadcast_history=broadcast_history,
broadcast_id=broadcast_id,
sent_count=sent_count,
failed_count=failed_count,
status=status,
)
media_info = ''
if has_media:
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}'
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
result_text = f"""
<b>Рассылка завершена!</b>
result_text = (
f'✅ <b>Рассылка завершена!</b>\n\n'
f'📊 <b>Результат:</b>\n'
f'• Отправлено: {sent_count}\n'
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
f'<b>Администратор:</b> {admin_name}'
)
📊 <b>Результат:</b>
- Отправлено: {sent_count}
- Не доставлено: {failed_count}
- Всего пользователей: {len(users)}
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
<b>Администратор:</b> {db_user.full_name}
"""
back_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
)
try:
await callback.message.edit_text(
result_text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
),
parse_mode='HTML',
)
await progress_message.edit_text(result_text, reply_markup=back_keyboard, parse_mode='HTML')
except TelegramBadRequest as e:
error_msg = str(e).lower()
if (
@@ -1275,22 +1428,24 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
or 'there is no text' in error_msg
or "message can't be edited" in error_msg
):
# Сообщение удалено или это медиа - отправляем новое
await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=result_text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]
]
),
reply_markup=back_keyboard,
parse_mode='HTML',
)
else:
raise
await state.clear()
logger.info(f'Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})')
logger.info(
'Рассылка завершена админом %s: sent=%d, failed=%d, total=%d (медиа: %s)',
admin_telegram_id,
sent_count,
failed_count,
total_users_count,
has_media,
)
async def get_target_users_count(db: AsyncSession, target: str) -> int:
+2 -139
View File
@@ -41,17 +41,13 @@ def _build_notification_settings_view(language: str):
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config['trial_inactive_1h'].get('enabled', True))
trial_24h_status = _format_toggle(config['trial_inactive_24h'].get('enabled', True))
trial_channel_status = _format_toggle(config['trial_channel_unsubscribed'].get('enabled', True))
trial_channel_status = _format_toggle(config.get('trial_channel_unsubscribed', {}).get('enabled', True))
expired_1d_status = _format_toggle(config['expired_1d'].get('enabled', True))
second_wave_status = _format_toggle(config['expired_second_wave'].get('enabled', True))
third_wave_status = _format_toggle(config['expired_third_wave'].get('enabled', True))
summary_text = (
'🔔 <b>Уведомления пользователям</b>\n\n'
f'• 1 час после триала: {trial_1h_status}\n'
f'• 24 часа после триала: {trial_24h_status}\n'
f'• Отписка от канала: {trial_channel_status}\n'
f'• 1 день после истечения: {expired_1d_status}\n'
f'• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n'
@@ -62,26 +58,6 @@ def _build_notification_settings_view(language: str):
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=f'{trial_1h_status} • 1 час после триала', callback_data='admin_mon_notify_toggle_trial_1h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 1 час после триала', callback_data='admin_mon_notify_preview_trial_1h'
)
],
[
InlineKeyboardButton(
text=f'{trial_24h_status} • 24 часа после триала', callback_data='admin_mon_notify_toggle_trial_24h'
)
],
[
InlineKeyboardButton(
text='🧪 Тест: 24 часа после триала', callback_data='admin_mon_notify_preview_trial_24h'
)
],
[
InlineKeyboardButton(
text=f'{trial_channel_status} • Отписка от канала',
@@ -170,76 +146,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_inactive_1h':
template = texts.get(
'TRIAL_INACTIVE_1H',
(
'⏳ <b>Прошёл час, а подключения нет</b>\n\n'
'Если возникли сложности с запуском — воспользуйтесь инструкциями.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_inactive_24h':
template = texts.get(
'TRIAL_INACTIVE_24H',
(
'⏳ <b>Вы ещё не подключились к VPN</b>\n\n'
'Прошли сутки с активации тестового периода, но трафик не зафиксирован.'
'\n\nНажмите кнопку ниже, чтобы подключиться.'
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime('%d.%m.%Y %H:%M'),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t('CONNECT_BUTTON', '🔗 Подключиться'),
callback_data='subscription_connect',
)
],
[
InlineKeyboardButton(
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 Моя подписка'),
callback_data='menu_subscription',
)
],
[
InlineKeyboardButton(
text=texts.t('SUPPORT_BUTTON', '🆘 Поддержка'),
callback_data='menu_support',
)
],
]
)
elif notification_type == 'trial_channel_unsubscribed':
if notification_type == 'trial_channel_unsubscribed':
template = texts.get(
'TRIAL_CHANNEL_UNSUBSCRIBED',
(
@@ -535,48 +442,6 @@ async def admin_notify_settings(callback: CallbackQuery):
await callback.answer('❌ Не удалось загрузить настройки', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_1h')
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_1h')
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_1h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 1h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_24h')
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer('✅ Включено' if not enabled else '⏸️ Отключено')
await _render_notification_settings(callback)
@router.callback_query(F.data == 'admin_mon_notify_preview_trial_24h')
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, 'trial_inactive_24h')
await callback.answer('✅ Пример отправлен')
except Exception as exc:
logger.error('Failed to send trial 24h preview: %s', exc)
await callback.answer('❌ Не удалось отправить тест', show_alert=True)
@router.callback_query(F.data == 'admin_mon_notify_toggle_trial_channel')
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
@@ -668,8 +533,6 @@ async def preview_all_notifications(callback: CallbackQuery):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
'trial_inactive_1h',
'trial_inactive_24h',
'trial_channel_unsubscribed',
'expired_1d',
'expired_2d',
+250 -1
View File
@@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str:
return 'CryptoBot'
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.FREEKASSA:
return settings.get_freekassa_display_name()
return method.value
@@ -144,6 +148,18 @@ def _status_info(
}
return mapping.get(status, ('', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
if record.method == PaymentMethod.KASSA_AI:
mapping = {
'pending': ('', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
'created': ('', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
'processing': ('', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')),
'success': ('', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
'paid': ('', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
'canceled': ('', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')),
'error': ('', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')),
}
return mapping.get(status, ('', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
return '', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')
@@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', ''}
return status in {'pending', 'created', ''}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing', ''}
return False
@@ -184,6 +202,7 @@ def _build_list_keyboard(
page: int,
total_pages: int,
language: str,
has_checkable: bool = False,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
@@ -204,6 +223,28 @@ def _build_list_keyboard(
]
)
# Кнопка "Проверить все" если есть что проверять
if has_checkable:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'),
callback_data='admin_payments_check_all',
)
]
)
# Кнопка экспорта если есть платежи
if records:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'),
callback_data='admin_payments_export',
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
@@ -485,11 +526,22 @@ async def show_payments_overview(
lines = [header, '', description]
# Проверяем есть ли платежи для массовой проверки
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
has_checkable = len(checkable_records) > 0
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append('')
lines.append(notice)
if has_checkable:
lines.append('')
lines.append(
texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format(
count=len(checkable_records)
)
)
else:
empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.')
lines.append('')
@@ -500,6 +552,7 @@ async def show_payments_overview(
page=page,
total_pages=total_pages,
language=db_user.language,
has_checkable=has_checkable,
)
await callback.message.edit_text(
@@ -550,28 +603,42 @@ async def manual_check_payment(
db_user: User,
db: AsyncSession,
) -> None:
import logging
logger = logging.getLogger(__name__)
logger.info('manual_check_payment called: %s', callback.data)
parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_')
if not parsed:
logger.warning('Failed to parse: %s', callback.data)
await callback.answer('❌ Invalid payment reference', show_alert=True)
return
method, payment_id = parsed
logger.info('Checking payment: method=%s, id=%s', method, payment_id)
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
logger.warning('Payment not found: method=%s, id=%s', method, payment_id)
await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True)
return
logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid)
if not _is_checkable(record):
logger.info('Payment not checkable: method=%s, status=%s', method, record.status)
await callback.answer(
texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'),
show_alert=True,
)
return
logger.info('Running manual check...')
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
logger.info('Check result: updated=%s', updated is not None)
if not updated:
await callback.answer(
@@ -597,7 +664,189 @@ async def manual_check_payment(
await callback.answer(message, show_alert=True)
@admin_required
@error_handler
async def check_all_payments(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
"""Массовая проверка всех ожидающих платежей."""
import logging
logger = logging.getLogger(__name__)
logger.info('check_all_payments called')
texts = get_texts(db_user.language)
# Получаем все ожидающие платежи
records = await list_recent_pending_payments(db)
logger.info('Found %d total records', len(records))
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
logger.info('Found %d checkable records', len(checkable_records))
if not checkable_records:
await callback.answer(
texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'),
show_alert=True,
)
return
await callback.answer(
texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)),
)
payment_service = PaymentService(callback.bot)
checked = 0
confirmed = 0
failed = 0
for record in checkable_records:
try:
logger.info('Checking %s payment id=%s', record.method.value, record.local_id)
updated = await run_manual_check(db, record.method, record.local_id, payment_service)
checked += 1
logger.info('Check result: is_paid=%s', updated.is_paid if updated else None)
if updated and updated.is_paid and not record.is_paid:
confirmed += 1
except Exception as e:
logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True)
failed += 1
logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed)
# Показываем результат
result_lines = [
texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 <b>Результат проверки</b>'),
'',
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked),
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed),
]
if failed:
result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed))
# Перезагружаем список платежей
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page_records = records[:PAGE_SIZE]
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
result_lines.append('')
result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 <b>Top-up verification</b>'))
if page_records:
result_lines.append('')
for idx, record in enumerate(page_records, start=1):
result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
result_lines.append('')
keyboard = _build_list_keyboard(
page_records,
page=1,
total_pages=total_pages,
language=db_user.language,
has_checkable=len(checkable_records) > 0,
)
logger.info('Updating message with results...')
try:
await callback.message.edit_text(
'\n'.join(result_lines),
parse_mode='HTML',
reply_markup=keyboard,
)
logger.info('Message updated successfully')
except Exception as e:
logger.error('Failed to update message: %s', e, exc_info=True)
@admin_required
@error_handler
async def export_payments(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
"""Экспорт данных платежей в JSON файл."""
import json
from aiogram.types import BufferedInputFile
texts = get_texts(db_user.language)
records = await list_recent_pending_payments(db)
if not records:
await callback.answer(
texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'),
show_alert=True,
)
return
# Формируем данные для экспорта
export_data = []
for record in records:
payment = record.payment
user = record.user
payment_data = {
'id': record.local_id,
'method': record.method.value,
'method_display': _method_display(record.method),
'identifier': record.identifier,
'amount_kopeks': record.amount_kopeks,
'amount_rubles': record.amount_kopeks / 100,
'status': record.status,
'is_paid': record.is_paid,
'created_at': record.created_at.isoformat() if record.created_at else None,
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'user': {
'id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'full_name': user.full_name,
},
}
# Добавляем специфичные поля в зависимости от метода
if hasattr(payment, 'order_id'):
payment_data['order_id'] = payment.order_id
if hasattr(payment, 'payment_url'):
payment_data['payment_url'] = payment.payment_url
if hasattr(payment, 'callback_payload'):
payment_data['callback_payload'] = payment.callback_payload
export_data.append(payment_data)
# Создаём JSON файл
json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str)
file_bytes = json_content.encode('utf-8')
# Отправляем файл
from datetime import datetime
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
await callback.message.answer_document(
document=BufferedInputFile(file_bytes, filename=filename),
caption=texts.t(
'ADMIN_PAYMENTS_EXPORT_CAPTION',
'📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}',
).format(
count=len(export_data),
paid=sum(1 for r in export_data if r['is_paid']),
pending=sum(1 for r in export_data if not r['is_paid']),
),
)
await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен'))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all')
dp.callback_query.register(export_payments, F.data == 'admin_payments_export')
dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_'))
dp.callback_query.register(
show_payment_details,
+802
View File
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
keyboard_rows = [
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
[types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
]
# Кнопка заявок на вывод (если функция включена)
@@ -650,11 +651,812 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
)
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
"""Возвращает начальную и конечную даты для заданного периода."""
now = datetime.datetime.now()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if period == 'today':
start_date = today
end_date = today + datetime.timedelta(days=1)
elif period == 'yesterday':
start_date = today - datetime.timedelta(days=1)
end_date = today
elif period == 'week':
start_date = today - datetime.timedelta(days=7)
end_date = today + datetime.timedelta(days=1)
elif period == 'month':
start_date = today - datetime.timedelta(days=30)
end_date = today + datetime.timedelta(days=1)
else:
# По умолчанию — сегодня
start_date = today
end_date = today + datetime.timedelta(days=1)
return start_date, end_date
def _get_period_display_name(period: str) -> str:
"""Возвращает человекочитаемое название периода."""
names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
return names.get(period, 'сегодня')
async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str):
"""Внутренняя функция для отображения диагностики за указанный период."""
try:
await callback.answer('Анализирую логи...')
from app.services.referral_diagnostics_service import referral_diagnostics_service
# Сохраняем период в state
await state.update_data(diagnostics_period=period)
from app.states import AdminStates
await state.set_state(AdminStates.referral_diagnostics_period)
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
# Формируем отчёт
period_display = _get_period_display_name(period)
text = f"""
🔍 <b>Диагностика рефералов {period_display}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
Уникальных пользователей: {report.unique_users_clicked}
Потерянных рефералов: {len(report.lost_referrals)}
"""
if report.lost_referrals:
text += '\n<b>❌ Потерянные рефералы:</b>\n'
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
for i, lost in enumerate(report.lost_referrals[:15], 1):
# Статус пользователя
if not lost.registered:
status = '⚠️ Не в БД'
elif not lost.has_referrer:
status = '❌ Без реферера'
else:
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
# Время
time_str = lost.click_time.strftime('%H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
else:
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
# Информация о логах
log_path = referral_diagnostics_service.log_path
log_exists = log_path.exists()
log_size = log_path.stat().st_size if log_exists else 0
text += f'\n<i>📂 {log_path.name}'
if log_exists:
text += f' ({log_size / 1024:.0f} KB)'
text += f' | Строк: {report.lines_in_period}'
else:
text += ' (не найден!)'
text += '</i>'
# Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
keyboard_rows = [
[
types.InlineKeyboardButton(text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'),
],
[types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')],
[types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')],
[
types.InlineKeyboardButton(
text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest'
)
],
]
# Кнопки действий (только если есть потерянные рефералы)
if report.lost_referrals:
keyboard_rows.append(
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
)
keyboard_rows.extend(
[
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
await callback.answer('Ошибка при анализе логов', show_alert=True)
@admin_required
@error_handler
async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Показывает диагностику реферальной системы по логам."""
# Определяем период из callback_data или используем "today" по умолчанию
if ':' in callback.data:
period = callback.data.split(':')[1]
else:
period = 'today'
await _show_diagnostics_for_period(callback, db, state, period)
@admin_required
@error_handler
async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Показывает предпросмотр исправлений потерянных рефералов."""
try:
await callback.answer('Анализирую...')
# Получаем период из state
state_data = await state.get_data()
period = state_data.get('diagnostics_period', 'today')
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
# Проверяем, работаем ли с загруженным файлом
if period == 'uploaded_file':
# Используем сохранённый отчёт из загруженного файла (десериализуем)
report_data = state_data.get('uploaded_file_report')
if not report_data:
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
return
report = DiagnosticReport.from_dict(report_data)
period_display = 'загруженный файл'
else:
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
period_display = _get_period_display_name(period)
if not report.lost_referrals:
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
return
# Запускаем предпросмотр исправлений
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
# Формируем отчёт
text = f"""
📋 <b>Предпросмотр исправлений {period_display}</b>
<b>📊 Что будет сделано:</b>
Исправлено рефералов: {fix_report.users_fixed}
Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
Ошибок: {fix_report.errors}
<b>🔍 Детали:</b>
"""
# Показываем первые 10 деталей
for i, detail in enumerate(fix_report.details[:10], 1):
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{detail.username}'
if detail.error:
text += f'{i}. {user_name} — ❌ {detail.error}\n'
else:
text += f'{i}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
if detail.had_first_topup:
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
if len(fix_report.details) > 10:
text += f'\n<i>... и ещё {len(fix_report.details) - 10}</i>\n'
text += '\n⚠️ <b>Внимание!</b> Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
# Кнопка назад зависит от источника
back_button_text = '⬅️ К диагностике'
back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
[types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
@admin_required
@error_handler
async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Применяет исправления потерянных рефералов."""
try:
await callback.answer('Применяю исправления...')
# Получаем период из state
state_data = await state.get_data()
period = state_data.get('diagnostics_period', 'today')
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
# Проверяем, работаем ли с загруженным файлом
if period == 'uploaded_file':
# Используем сохранённый отчёт из загруженного файла (десериализуем)
report_data = state_data.get('uploaded_file_report')
if not report_data:
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
return
report = DiagnosticReport.from_dict(report_data)
period_display = 'загруженный файл'
else:
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
period_display = _get_period_display_name(period)
if not report.lost_referrals:
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
return
# Применяем исправления
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
# Формируем отчёт
text = f"""
<b>Исправления применены {period_display}</b>
<b>📊 Результаты:</b>
Исправлено рефералов: {fix_report.users_fixed}
Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
Ошибок: {fix_report.errors}
<b>🔍 Детали:</b>
"""
# Показываем первые 10 успешных деталей
success_count = 0
for detail in fix_report.details:
if not detail.error and success_count < 10:
success_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{user_name}'
text += f'{success_count}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
if fix_report.users_fixed > 10:
text += f'\n<i>... и ещё {fix_report.users_fixed - 10} исправлений</i>\n'
# Показываем ошибки
if fix_report.errors > 0:
text += '\n<b>❌ Ошибки:</b>\n'
error_count = 0
for detail in fix_report.details:
if detail.error and error_count < 5:
error_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
text += f'{user_name}: {detail.error}\n'
if fix_report.errors > 5:
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
# Кнопки зависят от источника
keyboard_rows = []
if period != 'uploaded_file':
keyboard_rows.append(
[types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
)
keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(text, reply_markup=keyboard)
# Очищаем сохранённый отчёт из state
if period == 'uploaded_file':
await state.update_data(uploaded_file_report=None)
except Exception as e:
logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
await callback.answer('Ошибка при применении исправлений', show_alert=True)
# =============================================================================
# Проверка бонусов по БД
# =============================================================================
@admin_required
@error_handler
async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Проверяет по БД — всем ли рефералам начислены бонусы."""
from app.services.referral_diagnostics_service import (
referral_diagnostics_service,
)
await callback.answer('🔍 Проверяю бонусы...')
try:
report = await referral_diagnostics_service.check_missing_bonuses(db)
# Сохраняем отчёт в state для последующего применения
await state.update_data(missing_bonuses_report=report.to_dict())
text = f"""
🔍 <b>Проверка бонусов по БД</b>
📊 <b>Статистика:</b>
Всего рефералов: {report.total_referrals_checked}
С пополнением минимума: {report.referrals_with_topup}
<b>Без бонусов: {len(report.missing_bonuses)}</b>
"""
if report.missing_bonuses:
text += f"""
💰 <b>Требуется начислить:</b>
Рефералам: {report.total_missing_to_referrals / 100:.0f}
Рефереерам: {report.total_missing_to_referrers / 100:.0f}
<b>Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}</b>
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
"""
for i, mb in enumerate(report.missing_bonuses[:15], 1):
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
text += f'\n{i}. <b>{referral_name}</b>'
text += f'\n └ Пригласил: {referrer_name}'
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}'
text += f'\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}'
if len(report.missing_bonuses) > 15:
text += f'\n\n<i>... и ещё {len(report.missing_bonuses) - 15} чел.</i>'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
else:
text += '\n✅ <b>Все бонусы начислены!</b>'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
await callback.answer('Ошибка при проверке бонусов', show_alert=True)
@admin_required
@error_handler
async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Применяет начисление пропущенных бонусов."""
from app.services.referral_diagnostics_service import (
MissingBonusReport,
referral_diagnostics_service,
)
await callback.answer('💰 Начисляю бонусы...')
try:
# Получаем сохранённый отчёт
data = await state.get_data()
report_dict = data.get('missing_bonuses_report')
if not report_dict:
await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
return
report = MissingBonusReport.from_dict(report_dict)
if not report.missing_bonuses:
await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
return
# Применяем исправления
fix_report = await referral_diagnostics_service.fix_missing_bonuses(db, report.missing_bonuses, apply=True)
text = f"""
<b>Бонусы начислены!</b>
📊 <b>Результат:</b>
Обработано: {fix_report.users_fixed} пользователей
Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}
Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}
<b>Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}</b>
"""
if fix_report.errors > 0:
text += f'\n⚠️ Ошибок: {fix_report.errors}'
# Очищаем отчёт из state
await state.update_data(missing_bonuses_report=None)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
await callback.answer('Ошибка при начислении бонусов', show_alert=True)
@admin_required
@error_handler
async def sync_referrals_with_contest(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
"""Синхронизирует всех рефералов с активными конкурсами."""
from app.database.crud.referral_contest import get_contests_for_events
from app.services.referral_contest_service import referral_contest_service
await callback.answer('🏆 Синхронизирую с конкурсами...')
try:
from datetime import datetime
now_utc = datetime.utcnow()
# Получаем активные конкурсы
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
all_contests = list(paid_contests) + list(reg_contests)
if not all_contests:
await callback.message.edit_text(
'❌ <b>Нет активных конкурсов рефералов</b>\n\n'
'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Синхронизируем каждый конкурс
total_created = 0
total_updated = 0
total_skipped = 0
contest_results = []
for contest in all_contests:
stats = await referral_contest_service.sync_contest(db, contest.id)
if 'error' not in stats:
total_created += stats.get('created', 0)
total_updated += stats.get('updated', 0)
total_skipped += stats.get('skipped', 0)
contest_results.append(f'{contest.title}: +{stats.get("created", 0)} новых')
else:
contest_results.append(f'{contest.title}: ошибка')
text = f"""
🏆 <b>Синхронизация с конкурсами завершена!</b>
📊 <b>Результат:</b>
Конкурсов обработано: {len(all_contests)}
Новых событий добавлено: {total_created}
Обновлено: {total_updated}
Пропущено (уже есть): {total_skipped}
📋 <b>По конкурсам:</b>
"""
text += '\n'.join(contest_results)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
await callback.answer('Ошибка при синхронизации', show_alert=True)
@admin_required
@error_handler
async def request_log_file_upload(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Запрашивает загрузку лог-файла для анализа."""
await state.set_state(AdminStates.waiting_for_log_file)
text = """
📤 <b>Загрузка лог-файла для анализа</b>
Отправьте файл лога (расширение .log или .txt).
Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
<b>Важно:</b>
Файл должен быть текстовым (.log, .txt)
Максимальный размер: 50 MB
После анализа файл будет автоматически удалён
Если ротация логов удалила старые данные загрузите резервную копию.
"""
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
)
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
"""Получает и анализирует загруженный лог-файл."""
import tempfile
from pathlib import Path
if not message.document:
await message.answer(
'❌ Пожалуйста, отправьте файл документом.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Проверяем расширение файла
file_name = message.document.file_name or 'unknown'
file_ext = Path(file_name).suffix.lower()
if file_ext not in ['.log', '.txt']:
await message.answer(
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Проверяем размер файла
max_size = 50 * 1024 * 1024 # 50 MB
if message.document.file_size > max_size:
await message.answer(
f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\nМаксимальный размер: 50 MB',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Информируем о начале загрузки
status_message = await message.answer(
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
)
temp_file_path = None
try:
# Скачиваем файл во временную директорию
temp_dir = tempfile.gettempdir()
temp_file_path = str(Path(temp_dir) / f'ref_diagnostics_{message.from_user.id}_{file_name}')
# Скачиваем файл
file = await message.bot.get_file(message.document.file_id)
await message.bot.download_file(file.file_path, temp_file_path)
logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
# Обновляем статус
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
# Анализируем файл
from app.services.referral_diagnostics_service import referral_diagnostics_service
report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
# Формируем отчёт
text = f"""
🔍 <b>Анализ лог-файла: {file_name}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
Уникальных пользователей: {report.unique_users_clicked}
Потерянных рефералов: {len(report.lost_referrals)}
Строк в файле: {report.lines_in_period}
"""
if report.lost_referrals:
text += '\n<b>❌ Потерянные рефералы:</b>\n'
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
for i, lost in enumerate(report.lost_referrals[:15], 1):
# Статус пользователя
if not lost.registered:
status = '⚠️ Не в БД'
elif not lost.has_referrer:
status = '❌ Без реферера'
else:
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
# Время
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
else:
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
# Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
await state.update_data(
diagnostics_period='uploaded_file',
uploaded_file_report=report.to_dict(),
)
# Кнопки действий
keyboard_rows = []
if report.lost_referrals:
keyboard_rows.append(
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
)
keyboard_rows.extend(
[
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
# Удаляем статусное сообщение
await status_message.delete()
# Отправляем результат
await message.answer(text, reply_markup=keyboard)
# Очищаем состояние
await state.set_state(AdminStates.referral_diagnostics_period)
except Exception as e:
logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
try:
await status_message.edit_text(
f'❌ <b>Ошибка при анализе файла</b>\n\n'
f'Файл: {file_name}\n'
f'Ошибка: {e!s}\n\n'
f'Проверьте, что файл является текстовым логом бота.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload'
)
],
[
types.InlineKeyboardButton(
text='⬅️ К диагностике', callback_data='admin_referral_diagnostics'
)
],
]
),
)
except:
await message.answer(
f'❌ Ошибка при анализе файла: {e!s}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
]
),
)
finally:
# Удаляем временный файл
if temp_file_path and Path(temp_file_path).exists():
try:
Path(temp_file_path).unlink()
logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
except Exception as e:
logger.error(f'Ошибка удаления временного файла: {e}')
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals')
dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top')
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:'))
dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings')
dp.callback_query.register(show_referral_diagnostics, F.data == 'admin_referral_diagnostics')
dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
# Загрузка лог-файла
dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
# Проверка бонусов по БД
dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
# Хендлеры заявок на вывод
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
+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'Сохранить изменения?'
+9
View File
@@ -1993,6 +1993,15 @@ async def process_edit_traffic_topup_packages(
await state.clear()
return
if not message.text:
await message.answer(
'Пожалуйста, отправьте текстовое сообщение.\n\n'
'Формат: <code>ГБ:цена_в_копейках</code>\n'
'Пример: <code>5:5000, 10:9000, 20:15000</code>',
parse_mode='HTML',
)
return
packages = _parse_traffic_topup_packages(message.text.strip())
if not packages:
+3 -2
View File
@@ -1045,10 +1045,11 @@ async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: s
return
if not getattr(user, 'telegram_id', None):
logger.error(
'Cannot notify ticket #%s user without telegram_id (username=%s)',
logger.warning(
'Cannot notify ticket #%s user without telegram_id (username=%s, auth_type=%s)',
ticket.id,
getattr(user, 'username', None),
getattr(user, 'auth_type', None),
)
return
+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'
+160 -263
View File
@@ -65,11 +65,8 @@ class UserFilterType(Enum):
"""Типы фильтрации пользователей."""
BALANCE = 'balance'
TRAFFIC = 'traffic'
ACTIVITY = 'activity'
SPENDING = 'spending'
PURCHASES = 'purchases'
CAMPAIGN = 'campaign'
POTENTIAL_CUSTOMERS = 'potential_customers'
@dataclass
@@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_balance_list',
order_param='order_by_balance',
),
UserFilterType.TRAFFIC: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_traffic_list,
title='👥 <b>Список пользователей по использованному трафику</b>',
empty_message='📶 Пользователи с трафиком не найдены',
pagination_prefix='admin_users_traffic_list',
order_param='order_by_traffic',
),
UserFilterType.ACTIVITY: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_last_activity_list,
title='👥 <b>Пользователи по активности</b>',
empty_message='🕒 Пользователи с активностью не найдены',
pagination_prefix='admin_users_activity_list',
order_param='order_by_last_activity',
),
UserFilterType.SPENDING: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_spending_list,
title='👥 <b>Пользователи по сумме трат</b>',
empty_message='💳 Пользователи с тратами не найдены',
pagination_prefix='admin_users_spending_list',
order_param='order_by_total_spent',
),
UserFilterType.PURCHASES: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_purchases_list,
title='👥 <b>Пользователи по количеству покупок</b>',
empty_message='🛒 Пользователи с покупками не найдены',
pagination_prefix='admin_users_purchases_list',
order_param='order_by_purchase_count',
),
UserFilterType.CAMPAIGN: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_campaign_list,
title='👥 <b>Пользователи по кампании регистрации</b>',
@@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_campaign_list',
order_param='', # использует специальный метод
),
UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_potential_customers_list,
title='👥 <b>Потенциальные клиенты</b>',
empty_message='💰 Потенциальные клиенты не найдены',
pagination_prefix='admin_users_potential_customers_list',
order_param='', # использует специальный метод
),
}
@@ -173,34 +149,6 @@ def _build_user_button_text(
days_left = (user.subscription.end_date - datetime.utcnow()).days
button_text += f' | 📅 {days_left}д'
elif filter_type == UserFilterType.TRAFFIC:
if user.subscription:
sub = user.subscription
used = sub.traffic_used_gb or 0.0
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
limit_display = f'{sub.traffic_limit_gb}'
else:
limit_display = '♾️'
traffic_display = f'{used:.1f}/{limit_display} ГБ'
else:
traffic_display = 'нет подписки'
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}'
if user.balance_kopeks > 0:
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
elif filter_type == UserFilterType.ACTIVITY:
activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно'
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}'
elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {}
total_spent = stats.get('total_spent', 0)
purchases = stats.get('purchase_count', 0)
if filter_type == UserFilterType.SPENDING:
button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}'
else:
button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}'
elif filter_type == UserFilterType.CAMPAIGN:
info = extra_data.get(user.id, {}) if extra_data else {}
campaign_name = info.get('campaign_name') or 'Без кампании'
@@ -219,18 +167,6 @@ def _build_user_button_text(
button_text = f'{status_emoji} {sub_emoji} {short_name}'
if user.balance_kopeks > 0:
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
elif filter_type == UserFilterType.TRAFFIC:
if user.subscription:
sub = user.subscription
used = sub.traffic_used_gb or 0.0
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
limit_display = f'{sub.traffic_limit_gb}'
else:
limit_display = '♾️'
traffic_display = f'{used:.1f}/{limit_display} ГБ'
else:
traffic_display = 'нет'
button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}'
else:
button_text = f'{status_emoji} {short_name}'
@@ -280,10 +216,6 @@ async def _show_users_list_filtered(
await callback.answer()
return
# Для spending/purchases нужны дополнительные данные
if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users])
# Формируем текст заголовка
text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n'
text += 'Нажмите на пользователя для управления:'
@@ -576,38 +508,122 @@ async def show_users_ready_to_renew(
@admin_required
@error_handler
async def show_users_list_by_traffic(
async def show_potential_customers(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по использованному трафику (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page)
"""Показывает пользователей без активной подписки с балансом >= месячной цены."""
await state.set_state(AdminStates.viewing_user_from_potential_customers_list)
texts = get_texts(db_user.language)
from app.config import PERIOD_PRICES
@admin_required
@error_handler
async def show_users_list_by_last_activity(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по последней активности."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page)
monthly_price = PERIOD_PRICES.get(30, 99000)
user_service = UserService()
users_data = await user_service.get_potential_customers(
db,
min_balance_kopeks=monthly_price,
page=page,
limit=10,
)
@admin_required
@error_handler
async def show_users_list_by_spending(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по сумме трат (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page)
amount_text = settings.format_price(monthly_price)
header = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE',
'💰 Потенциальные клиенты',
)
description = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC',
'Пользователи без активной подписки с балансом {amount} или больше.',
).format(amount=amount_text)
if not users_data['users']:
empty_text = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY',
'Сейчас нет пользователей, которые подходят под этот фильтр.',
)
await callback.message.edit_text(
f'{header}\n\n{description}\n\n{empty_text}',
reply_markup=get_admin_users_keyboard(db_user.language),
)
await callback.answer()
return
@admin_required
@error_handler
async def show_users_list_by_purchases(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по количеству покупок (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page)
text = f'{header}\n\n{description}\n\n'
text += 'Нажмите на пользователя для управления:'
keyboard = []
for user in users_data['users']:
subscription = user.subscription
status_emoji = '' if user.status == UserStatus.ACTIVE.value else '🚫'
subscription_emoji = ''
if subscription:
if subscription.is_trial:
subscription_emoji = '🎁'
elif subscription.is_active:
subscription_emoji = '💎'
else:
subscription_emoji = ''
button_text = (
f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}'
)
if len(button_text) > 60:
short_name = user.full_name
if len(short_name) > 20:
short_name = short_name[:17] + '...'
button_text = (
f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}'
)
keyboard.append(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f'admin_user_manage_{user.id}',
)
]
)
if users_data['total_pages'] > 1:
pagination_row = get_admin_pagination_keyboard(
users_data['current_page'],
users_data['total_pages'],
'admin_users_potential_customers_list',
'admin_users_potential_customers_filter',
db_user.language,
).inline_keyboard[0]
keyboard.append(pagination_row)
keyboard.extend(
[
[
types.InlineKeyboardButton(
text='🔍 Поиск',
callback_data='admin_users_search',
),
types.InlineKeyboardButton(
text='📊 Статистика',
callback_data='admin_users_stats',
),
],
[
types.InlineKeyboardButton(
text='⬅️ Назад',
callback_data='admin_users',
)
],
]
)
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination(
await show_users_list_by_balance(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_traffic_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_traffic(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_traffic(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_activity_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_last_activity(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_last_activity(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_spending_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_spending(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_spending(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_purchases_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_purchases(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_purchases(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_ready_to_renew_pagination(
@@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination(
await show_users_ready_to_renew(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_potential_customers_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
page = int(callback.data.split('_')[-1])
await show_potential_customers(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_potential_customers(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_campaign_list_pagination(
@@ -928,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(
@@ -1310,18 +1273,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
current_state = await state.get_state()
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = 'admin_users_balance_filter'
elif current_state == AdminStates.viewing_user_from_traffic_list:
back_callback = 'admin_users_traffic_filter'
elif current_state == AdminStates.viewing_user_from_last_activity_list:
back_callback = 'admin_users_activity_filter'
elif current_state == AdminStates.viewing_user_from_spending_list:
back_callback = 'admin_users_spending_filter'
elif current_state == AdminStates.viewing_user_from_purchases_list:
back_callback = 'admin_users_purchases_filter'
elif current_state == AdminStates.viewing_user_from_campaign_list:
back_callback = 'admin_users_campaign_filter'
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
back_callback = 'admin_users_ready_to_renew_filter'
elif current_state == AdminStates.viewing_user_from_potential_customers_list:
back_callback = 'admin_users_potential_customers_filter'
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -2635,8 +2592,15 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
await callback.answer()
return
with_active_sub = sum(1 for u in inactive_users if u.subscription and u.subscription.is_active)
will_delete = len(inactive_users) - with_active_sub
text = '🗑️ <b>Неактивные пользователи</b>\n'
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n\n'
text += f'Без активности более {settings.INACTIVE_USER_DELETE_MONTHS} месяцев: {len(inactive_users)}\n'
if with_active_sub > 0:
text += f'🛡️ С активной подпиской (не будут удалены): {with_active_sub}\n'
text += f'🗑️ Будет удалено: {will_delete}\n'
text += '\n'
for user in inactive_users[:10]:
if user.telegram_id:
@@ -2645,7 +2609,9 @@ async def show_inactive_users(callback: types.CallbackQuery, db_user: User, db:
else:
user_link = f'<b>{user.full_name}</b>'
user_id_display = user.email or f'#{user.id}'
text += f'👤 {user_link}\n'
has_active = user.subscription and user.subscription.is_active
sub_badge = ' 🛡️' if has_active else ''
text += f'👤 {user_link}{sub_badge}\n'
text += f'🆔 <code>{user_id_display}</code>\n'
last_activity_display = (
format_time_ago(user.last_activity, db_user.language) if user.last_activity else 'Никогда'
@@ -3662,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()
@@ -4288,10 +4195,14 @@ async def _calculate_subscription_period_price(
@error_handler
async def cleanup_inactive_users(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
user_service = UserService()
deleted_count = await user_service.cleanup_inactive_users(db)
deleted_count, skipped_count = await user_service.cleanup_inactive_users(db)
text = f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}'
if skipped_count > 0:
text += f'\n⏭️ Пропущено (активная подписка): {skipped_count}'
await callback.message.edit_text(
f'✅ Очистка завершена\n\nУдалено неактивных пользователей: {deleted_count}',
text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_users')]]
),
@@ -4654,6 +4565,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
user_id=target_user.id,
),
active_internal_squads=subscription.connected_squads,
)
@@ -4667,6 +4580,8 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
user_id=target_user.id,
)
async with remnawave_service.get_api_client() as api:
create_kwargs = dict(
@@ -4678,10 +4593,12 @@ async def admin_buy_subscription_execute(callback: types.CallbackQuery, db_user:
else 0,
traffic_limit_strategy=TrafficLimitStrategy.MONTH,
telegram_id=target_user.telegram_id,
email=target_user.email,
description=settings.format_remnawave_user_description(
full_name=target_user.full_name,
username=target_user.username,
telegram_id=target_user.telegram_id,
email=target_user.email,
),
active_internal_squads=subscription.connected_squads,
)
@@ -5483,26 +5400,14 @@ def register_handlers(dp: Dispatcher):
handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_')
)
dp.callback_query.register(
handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_')
)
dp.callback_query.register(
handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_')
)
dp.callback_query.register(
handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_')
)
dp.callback_query.register(
handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_')
)
dp.callback_query.register(
handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_')
)
dp.callback_query.register(
handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_')
)
dp.callback_query.register(
handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_')
)
@@ -5604,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_'))
@@ -5659,14 +5562,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter')
dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter')
dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter')
dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter')
dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter')
dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter')
dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter')
dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter')
-18
View File
@@ -8,7 +8,6 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -126,23 +125,6 @@ async def process_cryptobot_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
-18
View File
@@ -8,7 +8,6 @@ from app.database.models import User
from app.external.telegram_stars import TelegramStarsService
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -94,23 +93,6 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
-35
View File
@@ -10,7 +10,6 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -171,23 +170,6 @@ async def process_yookassa_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
@@ -338,23 +320,6 @@ async def process_yookassa_sbp_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
+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')
+11 -11
View File
@@ -153,7 +153,7 @@ async def show_main_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -243,7 +243,7 @@ async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
async def show_service_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -277,7 +277,7 @@ async def show_info_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -320,7 +320,7 @@ async def show_promo_groups_info(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -468,7 +468,7 @@ async def show_faq_pages(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -525,7 +525,7 @@ async def show_faq_page(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -662,7 +662,7 @@ async def show_privacy_policy(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -779,7 +779,7 @@ async def show_public_offer(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -896,7 +896,7 @@ async def show_language_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -938,7 +938,7 @@ async def process_language_change(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -1000,7 +1000,7 @@ async def process_language_change(
async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
+42 -18
View File
@@ -9,7 +9,6 @@ from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.promocode_service import PromoCodeService
from app.states import PromoCodeStates
from app.utils.decorators import error_handler
@@ -71,23 +70,6 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co
@error_handler
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Активация промокода невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
code = message.text.strip()
@@ -102,14 +84,52 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
)
return
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
# Валидация формата
if not validate_promo_format(code):
await message.answer(texts.PROMOCODE_INVALID, reply_markup=get_back_keyboard(db_user.language))
return
# Rate-limit на перебор
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
# Лимит на стакинг (макс активаций в день)
if not promo_limiter.can_activate(message.from_user.id):
await message.answer(
texts.t(
'PROMO_DAILY_LIMIT',
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
),
reply_markup=get_back_keyboard(db_user.language),
)
await state.clear()
return
result = await activate_promocode_for_registration(db, db_user.id, code, message.bot)
if result['success']:
promo_limiter.record_activation(message.from_user.id)
await message.answer(
texts.PROMOCODE_SUCCESS.format(description=result['description']),
reply_markup=get_back_keyboard(db_user.language),
)
else:
# Записываем неудачную попытку только для not_found (перебор)
if result['error'] == 'not_found':
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
error_messages = {
'not_found': texts.PROMOCODE_INVALID,
'expired': texts.PROMOCODE_EXPIRED,
@@ -122,6 +142,10 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
'PROMOCODE_ACTIVE_DISCOUNT_EXISTS',
'❌ У вас уже есть активная скидка. Используйте её перед активацией новой.',
),
'daily_limit': texts.t(
'PROMO_DAILY_LIMIT',
'❌ Достигнут лимит активаций промокодов на сегодня. Попробуйте завтра.',
),
'server_error': texts.ERROR,
}
+1 -11
View File
@@ -70,24 +70,15 @@ async def start_simple_subscription_purchase(
# (независимо от того, включён ли выбор устройств)
if current_subscription:
current_device_limit = current_subscription.device_limit or device_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно
if getattr(current_subscription, 'modem_enabled', False):
current_device_limit = max(1, current_device_limit - 1)
# Используем максимум из текущего и дефолтного
device_limit = max(device_limit, current_device_limit)
# Проверяем, включён ли модем у текущей подписки
modem_enabled = False
if current_subscription:
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
# Подготовим параметры простой подписки
subscription_params = {
'period_days': settings.SIMPLE_SUBSCRIPTION_PERIOD_DAYS,
'device_limit': device_limit,
'traffic_limit_gb': settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
'squad_uuid': settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
'modem_enabled': modem_enabled,
}
# Сохраняем параметры в состояние
@@ -113,13 +104,12 @@ async def start_simple_subscription_purchase(
user_balance_kopeks = getattr(db_user, 'balance_kopeks', 0)
logger.warning(
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | total=%s | squads=%s',
'SIMPLE_SUBSCRIPTION_DEBUG_START | user=%s | period=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | total=%s | squads=%s',
db_user.id,
period_days,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
price_kopeks,
+245 -114
View File
@@ -3,7 +3,7 @@ from datetime import datetime
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramForbiddenError
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +22,7 @@ from app.database.crud.user import (
from app.database.crud.user_message import get_random_active_message
from app.database.models import PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import (
get_back_keyboard,
get_language_selection_keyboard,
get_main_menu_keyboard_async,
get_post_registration_keyboard,
@@ -35,7 +36,6 @@ from app.middlewares.channel_checker import (
get_pending_payload_from_redis,
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.campaign_service import AdvertisingCampaignService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.pinned_message_service import (
@@ -159,10 +159,27 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
texts = get_texts(language)
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
potential_code = message.text.strip()
if len(potential_code) < 4 or len(potential_code) > 20:
if len(potential_code) < 3 or len(potential_code) > 50:
return False
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
if not validate_promo_format(potential_code):
return False
# Rate-limit на перебор промокодов
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return True
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if referrer:
@@ -217,7 +234,10 @@ async def handle_potential_referral_code(message: types.Message, state: FSMConte
return True
# Ни реферальный код, ни промокод не найдены
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(
texts.t(
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
@@ -309,24 +329,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
data = await state.get_data() or {}
had_pending_payload = 'pending_start_payload' in data
pending_start_payload = data.pop('pending_start_payload', None)
had_campaign_notification_flag = 'campaign_notification_sent' in data
campaign_notification_sent = data.pop('campaign_notification_sent', False)
state_needs_update = had_pending_payload or had_campaign_notification_flag
# ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
# pending_start_payload обрабатывается отдельно ниже
campaign_notification_sent = data.get('campaign_notification_sent', False)
state_needs_update = False
# Получаем payload из state или Redis
pending_start_payload = data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
data['pending_start_payload'] = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
# Очищаем Redis после получения
await delete_pending_payload_from_redis(message.from_user.id)
# НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
referral_code = None
campaign = None
@@ -483,9 +506,24 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f'🔄 Удаленный пользователь {user.telegram_id} начинает повторную регистрацию')
try:
from sqlalchemy import delete
from sqlalchemy import delete, update as sa_update
from app.database.models import PromoCodeUse, ReferralEarning, SubscriptionServer, Transaction
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PromoCodeUse,
ReferralEarning,
SubscriptionServer,
Transaction,
WataPayment,
YooKassaPayment,
)
if user.subscription:
await decrement_subscription_server_counts(db, user.subscription)
@@ -500,9 +538,37 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
await db.execute(
sa_update(ReferralEarning)
.where(ReferralEarning.user_id == user.id)
.values(referral_transaction_id=None)
)
await db.execute(
sa_update(ReferralEarning)
.where(ReferralEarning.referral_id == user.id)
.values(referral_transaction_id=None)
)
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
payment_models = [
YooKassaPayment,
CryptoBotPayment,
HeleketPayment,
MulenPayPayment,
Pal24Payment,
WataPayment,
PlategaPayment,
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
]
for payment_model in payment_models:
await db.execute(
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
)
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
user.status = UserStatus.ACTIVE.value
@@ -774,12 +840,11 @@ async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext,
try:
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
except Exception as e:
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
try:
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
except:
pass
except TelegramBadRequest as e:
if 'message is not modified' in str(e):
pass # Сообщение уже содержит нужный текст
else:
logger.error(f'Ошибка при показе сообщения об отклонении правил: {e}')
logger.info(f'✅ Правила обработаны для пользователя {callback.from_user.id}')
@@ -886,14 +951,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}')
@@ -927,8 +989,26 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
code = message.text.strip()
# Валидация формата
if not validate_promo_format(code):
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
# Rate-limit на перебор
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
if referrer:
@@ -958,7 +1038,10 @@ async def process_referral_code_input(message: types.Message, state: FSMContext,
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены
# Ни реферальный код, ни промокод не найдены — записываем неудачу
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
logger.info(f'❌ Неверный код (ни реферальный, ни промокод): {code}')
return
@@ -990,25 +1073,6 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}')
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.message.answer(
f'🚫 Регистрация невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1167,6 +1231,12 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(callback.from_user.id)
logger.info(
'🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id
)
await state.clear()
if campaign_message:
@@ -1196,6 +1266,20 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
)
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
await _send_pinned_message(callback.bot, db, user)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning(f'HTML parse error в приветственном сообщении, повтор без parse_mode: {e}')
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode=None,
)
await _send_pinned_message(callback.bot, db, user)
except Exception as fallback_err:
logger.error(f'Ошибка при повторной отправке приветственного сообщения: {fallback_err}')
else:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
except Exception as e:
logger.error(f'Ошибка при отправке приветственного сообщения: {e}')
else:
@@ -1253,25 +1337,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession):
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}')
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Регистрация невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1454,6 +1519,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(message.from_user.id)
logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
await state.clear()
if campaign_message:
@@ -1477,12 +1546,33 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
if offer_text and not skip_welcome_offer:
try:
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
user_has_subscription = user.subscription and getattr(user.subscription, 'is_active', False)
if user_has_subscription:
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
else:
keyboard = get_post_registration_keyboard(user.language)
await message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
reply_markup=keyboard,
)
logger.info(f'✅ Приветственное сообщение отправлено пользователю {user.telegram_id}')
await _send_pinned_message(message.bot, db, user)
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:
@@ -1711,58 +1801,34 @@ 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)
try:
state_data = await state.get_data() or {}
pending_start_payload = state_data.pop('pending_start_payload', None)
# Получаем payload БЕЗ удаления - удалим только после успешной проверки подписки
pending_start_payload = state_data.get('pending_start_payload')
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
state_data['pending_start_payload'] = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
state_updated = pending_start_payload is not None
if pending_start_payload:
logger.info(
"📦 CHANNEL CHECK: Найден сохраненный payload '%s'",
pending_start_payload,
)
# Очищаем Redis после получения payload
await delete_pending_payload_from_redis(query.from_user.id)
# Всегда обновляем referral_code если есть новый payload
# (исправление бага с устаревшими данными в state)
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
logger.info(
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
campaign.id,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
)
if state_updated:
await state.set_data(state_data)
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
@@ -1781,11 +1847,61 @@ async def required_sub_channel_check(
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '%s' сохранён для следующей попытки",
pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
show_alert=True,
)
# Подписка подтверждена - теперь удаляем payload и обрабатываем его
if pending_start_payload:
# Удаляем из FSM state
state_data.pop('pending_start_payload', None)
# Очищаем Redis после успешной проверки подписки
await delete_pending_payload_from_redis(query.from_user.id)
# Всегда обновляем referral_code если есть новый payload
# (исправление бага с устаревшими данными в state)
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
# Обрабатываем payload только если ещё не обработан
# (проверяем по наличию referral_code или campaign_id в state)
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
logger.info(
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
campaign.id,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
pending_start_payload,
)
else:
logger.info(
'✅ CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
)
await state.set_data(state_data)
if user and user.subscription:
subscription = user.subscription
if subscription.is_trial and subscription.status == SubscriptionStatus.DISABLED.value:
@@ -1821,15 +1937,17 @@ async def required_sub_channel_check(
except Exception as e:
logger.warning(f'Не удалось удалить сообщение: {e}')
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
# и перед показом главного меню или завершением регистрации
if pending_start_payload:
await delete_pending_payload_from_redis(query.from_user.id)
logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
if user and user.status != UserStatus.DELETED.value:
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
menu_text = await get_main_menu_text(user, texts, db)
from aiogram.types import FSInputFile
from app.utils.message_patch import LOGO_PATH
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -1855,13 +1973,14 @@ async def required_sub_channel_check(
)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
photo=get_logo_media(),
caption=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
@@ -1903,6 +2022,11 @@ async def required_sub_channel_check(
)
await db.refresh(user, ['subscription'])
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
state_data.pop('pending_start_payload', None)
await state.set_data(state_data)
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
@@ -1916,10 +2040,6 @@ async def required_sub_channel_check(
menu_text = await get_main_menu_text(user, texts, db)
from aiogram.types import FSInputFile
from app.utils.message_patch import LOGO_PATH
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -1945,13 +2065,14 @@ async def required_sub_channel_check(
)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
photo=get_logo_media(),
caption=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
@@ -1971,19 +2092,16 @@ async def required_sub_channel_check(
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
else:
from aiogram.types import FSInputFile
from app.utils.message_patch import LOGO_PATH
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE:
await bot.send_photo(
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=FSInputFile(LOGO_PATH),
photo=get_logo_media(),
caption=rules_text,
reply_markup=get_rules_keyboard(language),
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
@@ -1992,9 +2110,22 @@ async def required_sub_channel_check(
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except TelegramBadRequest as e:
error_msg = str(e).lower()
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
else:
logger.error(f'Ошибка Telegram API в required_sub_channel_check: {e}')
try:
await query.answer(f'{texts.ERROR}!', show_alert=True)
except Exception:
pass
except Exception as e:
logger.error(f'Ошибка в required_sub_channel_check: {e}')
await query.answer(f'{texts.ERROR}!', show_alert=True)
try:
await query.answer(f'{texts.ERROR}!', show_alert=True)
except Exception:
pass
def register_handlers(dp: Dispatcher):
-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',
+33
View File
@@ -39,6 +39,21 @@ async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db:
)
return
# Суточные подписки имеют свой механизм продления, глобальный autopay не применяется
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
),
show_alert=True,
)
return
status = (
texts.t('AUTOPAY_STATUS_ENABLED', 'включен')
if subscription.autopay_enabled
@@ -68,6 +83,24 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
subscription = db_user.subscription
enable = callback.data == 'autopay_enable'
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
if enable:
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
),
show_alert=True,
)
return
await update_subscription_autopay(db, subscription, enable)
texts = get_texts(db_user.language)
+7 -1
View File
@@ -416,8 +416,14 @@ def get_traffic_switch_keyboard(
buttons.append([InlineKeyboardButton(text=button_text, callback_data=f'switch_traffic_{gb}')])
language_code = (language or 'ru').split('-')[0].lower()
buttons.append(
[InlineKeyboardButton(text='⬅️ Назад' if language == 'ru' else '⬅️ Back', callback_data='subscription_settings')]
[
InlineKeyboardButton(
text='⬅️ Назад' if language_code in {'ru', 'fa'} else '⬅️ Back',
callback_data='subscription_settings',
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
+4
View File
@@ -468,6 +468,10 @@ async def select_country(callback: types.CallbackQuery, state: FSMContext, db_us
country_uuid = callback.data.split('_')[1]
data = await state.get_data()
if 'period_days' not in data:
await callback.answer('❌ Данные подписки устарели. Начните оформление заново.', show_alert=True)
return
selected_countries = data.get('countries', [])
if country_uuid in selected_countries:
selected_countries.remove(country_uuid)
+226 -53
View File
@@ -22,6 +22,7 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
apply_percentage_discount,
@@ -185,18 +186,29 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
if tariff:
price_per_device = tariff_device_price
price_text = texts.format_price(price_per_device)
tariff_min_devices = getattr(tariff, 'device_limit', 1) or 1
# Добавляем информацию о минимальном лимите если он больше 1
min_devices_info = ''
if tariff_min_devices > 1:
min_devices_info = texts.t(
'CHANGE_DEVICES_MIN_LIMIT_INFO',
'\nМинимум для тарифа: {min_devices} устройств\n',
).format(min_devices=tariff_min_devices)
prompt_text = texts.t(
'CHANGE_DEVICES_PROMPT_TARIFF',
(
'📱 <b>Изменение количества устройств</b>\n\n'
'Текущий лимит: {current_devices} устройств\n'
'Цена за доп. устройство: {price}/мес\n'
'{min_devices_info}'
'Выберите новое количество устройств:\n\n'
'💡 <b>Важно:</b>\n'
'• При увеличении - доплата пропорционально оставшемуся времени\n'
'• При уменьшении - возврат средств не производится'
),
).format(current_devices=current_devices, price=price_text)
).format(current_devices=current_devices, price=price_text, min_devices_info=min_devices_info)
else:
prompt_text = texts.t(
'CHANGE_DEVICES_PROMPT',
@@ -275,6 +287,18 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
)
return
# Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
if new_devices_count < tariff_min_devices:
await callback.answer(
texts.t(
'DEVICES_MIN_LIMIT_REACHED',
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
).format(limit=tariff_min_devices),
show_alert=True,
)
return
devices_difference = new_devices_count - current_devices
if devices_difference > 0:
@@ -290,26 +314,54 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
chargeable_devices = additional_devices
devices_price_per_month = chargeable_devices * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.utcnow()
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
# Для обычных тарифов - по месяцам
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
period_label = f'{charged_months} мес'
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -325,11 +377,28 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
missing=texts.format_price(missing_kopeks),
)
# Сохраняем корзину для автопокупки после пополнения баланса
await user_cart_service.save_user_cart(
user_id=db_user.id,
cart_data={
'cart_mode': 'add_devices',
'devices_to_add': devices_difference,
'price_kopeks': price,
},
)
logger.info(
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
db_user.telegram_id,
devices_difference,
price,
)
await callback.message.answer(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
amount_kopeks=missing_kopeks,
has_saved_cart=True,
),
parse_mode='HTML',
)
@@ -343,10 +412,11 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
if price > 0:
cost_text = texts.t(
'DEVICE_CHANGE_EXTRA_COST',
'Доплата: {amount} (за {months} мес)',
'Доплата: {amount} (за {period})',
).format(
amount=texts.format_price(price),
months=charged_months,
period=period_label,
months=period_label,
)
if total_discount > 0:
cost_text += texts.t(
@@ -427,13 +497,41 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
if tariff:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'),
show_alert=True,
)
return
elif not settings.is_devices_selection_enabled():
await callback.answer(
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
show_alert=True,
)
return
# Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
if new_devices_count < tariff_min_devices:
await callback.answer(
texts.t(
'DEVICES_MIN_LIMIT_REACHED',
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
).format(limit=tariff_min_devices),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -464,7 +562,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# При уменьшении лимита - сбросить лишние устройства
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
@@ -475,16 +573,34 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
devices_list = response['response'].get('devices', [])
connected_count = len(devices_list)
# Если подключённых устройств больше чем новый лимит - сбросить все
# Если подключённых устройств больше чем новый лимит - удалить лишние
if connected_count > new_devices_count:
devices_to_remove = connected_count - new_devices_count
logger.info(
f'🔧 Сброс устройств при уменьшении лимита: '
f'подключено {connected_count}, новый лимит {new_devices_count}'
f'🔧 Удаление лишних устройств при уменьшении лимита: '
f'подключено {connected_count}, новый лимит {new_devices_count}, '
f'удаляем {devices_to_remove}'
)
await api.reset_user_devices(db_user.remnawave_uuid)
devices_reset_count = connected_count
# Сортируем по дате (последние в конце) и удаляем последние
sorted_devices = sorted(
devices_list,
key=lambda d: d.get('updatedAt') or d.get('createdAt') or '',
)
devices_to_delete = sorted_devices[-devices_to_remove:]
for device in devices_to_delete:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
devices_reset_count += 1
logger.info(f'✅ Удалено устройство {device_hwid}')
except Exception as del_error:
logger.error(f'Ошибка удаления устройства {device_hwid}: {del_error}')
except Exception as reset_error:
logger.error(f'Ошибка сброса устройств при уменьшении лимита: {reset_error}')
logger.error(f'Ошибка удаления устройств при уменьшении лимита: {reset_error}')
await db.refresh(db_user)
await db.refresh(subscription)
@@ -524,9 +640,9 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
).format(old=current_devices, new=new_devices_count)
if devices_reset_count > 0:
success_text += texts.t(
'DEVICE_CHANGE_DEVICES_RESET',
'\n🔄 Сброшено устройств: {count}\n💡 Подключите заново нужные устройства (до {limit} шт.)\n\n',
).format(count=devices_reset_count, limit=new_devices_count)
'DEVICE_CHANGE_DEVICES_REMOVED',
'\n🗑 Удалено устройств: {count}\n',
).format(count=devices_reset_count)
success_text += texts.t(
'DEVICE_CHANGE_NO_REFUND_INFO',
'ℹ️ Возврат средств не производится',
@@ -949,35 +1065,63 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
return
devices_price_per_month = devices_count * price_per_device
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточных тарифов считаем по дням (как в кабинете)
now = datetime.utcnow()
days_left = max(1, (subscription.end_date - now).days)
period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
# Цена = месячная_цена * days_left / 30
price = int(discounted_per_month * days_left / 30)
price = max(100, price) # Минимум 1 рубль
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
# Для обычных тарифов - по месяцам
months_hint = get_remaining_months(subscription.end_date)
period_hint_days = months_hint * 30 if months_hint > 0 else None
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
'devices',
period_hint_days,
)
discounted_per_month, discount_per_month = apply_percentage_discount(
devices_price_per_month,
devices_discount_percent,
)
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
)
total_discount = discount_per_month * charged_months
period_label = f'{charged_months} мес'
logger.info(
'Добавление %s устройств: %.2f₽/мес × %s мес = %.2f₽ (скидка %.2f₽)',
'Добавление %s устройств: %.2f₽/мес × %s = %.2f₽ (скидка %.2f₽)',
devices_count,
discounted_per_month / 100,
charged_months,
period_label,
price / 100,
total_discount / 100,
)
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
required_text = f'{texts.format_price(price)} (за {charged_months} мес)'
required_text = f'{texts.format_price(price)} (за {period_label})'
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -993,12 +1137,29 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
missing=texts.format_price(missing_kopeks),
)
# Сохраняем корзину для автопокупки после пополнения баланса
await user_cart_service.save_user_cart(
user_id=db_user.id,
cart_data={
'cart_mode': 'add_devices',
'devices_to_add': devices_count,
'price_kopeks': price,
},
)
logger.info(
'Сохранена корзина add_devices для пользователя %s: +%s устройств, цена %s коп.',
db_user.telegram_id,
devices_count,
price,
)
await callback.message.edit_text(
message_text,
reply_markup=get_insufficient_balance_keyboard(
db_user.language,
resume_callback=resume_callback,
amount_kopeks=missing_kopeks,
has_saved_cart=True,
),
parse_mode='HTML',
)
@@ -1007,7 +1168,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
try:
success = await subtract_user_balance(
db, db_user, price, f'Добавление {devices_count} устройств на {charged_months} мес'
db, db_user, price, f'Добавление {devices_count} устройств на {period_label}'
)
if not success:
@@ -1024,18 +1185,30 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f'Добавление {devices_count} устройств на {charged_months} мес',
description=f'Добавление {devices_count} устройств на {period_label}',
)
await db.refresh(db_user)
await db.refresh(subscription)
# Отправляем уведомление админам о докупке устройств
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
old_device_limit = subscription.device_limit - devices_count
await notification_service.send_subscription_update_notification(
db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления о докупке устройств: {e}')
success_text = (
'✅ Устройства успешно добавлены!\n\n'
f'📱 Добавлено: {devices_count} устройств\n'
f'Новый лимит: {subscription.device_limit} устройств\n'
)
success_text += f'💰 Списано: {texts.format_price(price)} (за {charged_months} мес)'
success_text += f'💰 Списано: {texts.format_price(price)} (за {period_label})'
if total_discount > 0:
success_text += f' (скидка {devices_discount_percent}%: -{texts.format_price(total_discount)})'
-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')
+10 -5
View File
@@ -24,6 +24,10 @@ async def _prepare_subscription_summary(
texts,
) -> tuple[str, dict[str, Any]]:
summary_data = dict(data)
if 'period_days' not in summary_data:
raise KeyError('period_days missing from subscription data — FSM state likely expired')
countries = await _get_available_countries(db_user.promo_group_id)
months_in_period = calculate_months_from_days(summary_data['period_days'])
@@ -400,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)
@@ -428,7 +433,7 @@ async def get_subscription_info_text(subscription, texts, db_user, db: AsyncSess
type=type_text,
end_date=format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M'),
days_left=max(0, subscription.days_left),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=traffic_text,
countries_count=len(subscription.connected_squads),
devices_used=devices_used,
@@ -440,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
+100 -137
View File
@@ -39,7 +39,6 @@ from app.keyboards.inline import (
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
@@ -427,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,
@@ -561,6 +553,15 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
texts = get_texts(db_user.language)
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
await callback.message.edit_text(
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
@@ -753,6 +754,15 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
await callback.answer()
return
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(db_user, 'auth_type', 'telegram')):
await callback.message.edit_text(
texts.t('TRIAL_DISABLED_FOR_USER_TYPE', 'Пробный период недоступен'),
reply_markup=get_back_keyboard(db_user.language),
)
await callback.answer()
return
# Проверяем, использовал ли пользователь триал
# PENDING триальные подписки не считаются - пользователь может повторить оплату
trial_blocked = False
@@ -1385,6 +1395,11 @@ async def return_to_saved_cart(callback: types.CallbackQuery, state: FSMContext,
prepared_cart_data = dict(cart_data)
if 'period_days' not in prepared_cart_data:
await callback.answer('❌ Корзина повреждена. Оформите подписку заново.', show_alert=True)
await user_cart_service.delete_user_cart(db_user.id)
return
if not settings.is_devices_selection_enabled():
try:
from .pricing import _prepare_subscription_summary
@@ -1522,6 +1537,15 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
# В режиме тарифов проверяем наличие tariff_id
if settings.is_tariffs_mode():
if subscription.tariff_id:
# Проверяем, суточный ли тариф — у суточных нет period_prices, продление через resume
from app.database.crud.tariff import get_tariff_by_id
tariff = getattr(subscription, 'tariff', None) or await get_tariff_by_id(db, subscription.tariff_id)
if tariff and getattr(tariff, 'is_daily', False):
# Суточный тариф: перенаправляем на страницу подписки (там кнопка «Возобновить»)
await show_subscription_info(callback, db_user, db)
return
# У подписки есть тариф - перенаправляем на продление по тарифу
from .tariff_purchase import show_tariff_extend
@@ -1581,11 +1605,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_total_base = devices_price_per_month * months_in_period
@@ -1724,24 +1743,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Продление подписки невозможно\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -1810,11 +1811,6 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
else:
device_limit = forced_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
# поэтому не должен учитываться как платное устройство при продлении
if getattr(subscription, 'modem_enabled', False):
device_limit = max(1, device_limit - 1)
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
devices_discount_percent = db_user.get_promo_discount(
@@ -2228,24 +2224,6 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Покупка подписки невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
@@ -2434,26 +2412,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'Применяем новую цену.'
)
# Используем пересчитанную цену
@@ -3074,18 +3057,12 @@ 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),
traffic_used=texts.format_traffic(subscription.traffic_used_gb),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb),
traffic_used=texts.format_traffic(subscription.traffic_used_gb, is_limit=False),
traffic_limit=texts.format_traffic(subscription.traffic_limit_gb, is_limit=True),
devices_used=devices_used,
devices_limit=devices_limit_display,
)
@@ -3143,11 +3120,15 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
# Прикрепляем тариф к подписке для CRUD функций
subscription.tariff = tariff
# Переключаем статус паузы
# Определяем, нужно ли возобновление: пауза пользователя ИЛИ остановка системой (disabled/expired)
from app.database.models import SubscriptionStatus
was_paused = getattr(subscription, 'is_daily_paused', False)
is_inactive = subscription.status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
needs_resume = was_paused or is_inactive
# При возобновлении проверяем баланс
if was_paused:
if needs_resume:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
if daily_price > 0 and db_user.balance_kopeks < daily_price:
await callback.answer(
@@ -3159,10 +3140,11 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
return
subscription = await toggle_daily_subscription_pause(db, subscription)
if needs_resume:
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
from app.database.crud.subscription import resume_daily_subscription
if was_paused:
# Была пауза, теперь возобновили
subscription = await resume_daily_subscription(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_RESUMED', '▶️ Подписка возобновлена!')
# Синхронизируем с Remnawave - активируем пользователя
try:
@@ -3179,10 +3161,9 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
except Exception as e:
logger.error(f'Ошибка синхронизации с Remnawave при возобновлении: {e}')
else:
# Была активна, теперь на паузе
# Подписка активна, ставим на паузу
subscription = await toggle_daily_subscription_pause(db, subscription)
message = texts.t('DAILY_SUBSCRIPTION_PAUSED', '⏸️ Подписка приостановлена!')
# При паузе можно отключить пользователя в Remnawave (опционально)
# Пока оставляем активным, т.к. пауза - это только остановка списания
await callback.answer(message, show_alert=True)
@@ -3241,6 +3222,9 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
await db.refresh(db_user)
# Сохраняем ID до начала транзакции (на случай detached session)
user_id_snapshot = db_user.id
# Создаем триальную подписку
subscription: Subscription | None = None
remnawave_user = None
@@ -3402,22 +3386,33 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as error:
logger.error(
'Unexpected error during paid trial activation for user %s: %s',
db_user.id,
user_id_snapshot,
error,
)
# Пытаемся откатить и вернуть деньги
if subscription:
await rollback_trial_subscription_activation(db, subscription)
from app.database.crud.user import add_user_balance
# Откатываем сессию чтобы очистить PendingRollbackError
try:
await db.rollback()
except Exception:
pass
await add_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
transaction_type=TransactionType.REFUND,
)
await db.refresh(db_user)
# Пытаемся вернуть деньги
try:
from app.database.crud.user import add_user_balance
await add_user_balance(
db,
db_user,
trial_price_kopeks,
texts.t('TRIAL_REFUND_DESCRIPTION', 'Возврат за неудачную активацию триала'),
transaction_type=TransactionType.REFUND,
)
await db.refresh(db_user)
except Exception as refund_error:
logger.error(
'Failed to refund trial payment for user %s: %s',
user_id_snapshot,
refund_error,
)
await callback.message.edit_text(
texts.t(
@@ -4113,11 +4108,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_device_connection_help, F.data == 'device_connection_help')
# Регистрируем обработчики модема
from .modem import register_modem_handlers
register_modem_handlers(dp)
# Регистрируем обработчики покупки по тарифам
from .tariff_purchase import register_tariff_purchase_handlers
@@ -4134,24 +4124,6 @@ async def handle_simple_subscription_purchase(
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Простая покупка подписки невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
@@ -4170,10 +4142,6 @@ async def handle_simple_subscription_purchase(
if current_subscription and current_subscription.is_active:
# При продлении используем текущие устройства подписки, а не дефолтные
extend_device_limit = current_subscription.device_limit or simple_device_limit
# Модем добавляет +1 к device_limit, но оплачивается отдельно
modem_enabled = getattr(current_subscription, 'modem_enabled', False)
if modem_enabled:
extend_device_limit = max(1, extend_device_limit - 1)
# Используем максимум из текущего и дефолтного
extend_device_limit = max(simple_device_limit, extend_device_limit)
@@ -4187,7 +4155,6 @@ async def handle_simple_subscription_purchase(
device_limit=extend_device_limit,
traffic_limit_gb=settings.SIMPLE_SUBSCRIPTION_TRAFFIC_GB,
squad_uuid=settings.SIMPLE_SUBSCRIPTION_SQUAD_UUID,
modem_enabled=modem_enabled,
)
return
@@ -4307,7 +4274,6 @@ async def _extend_existing_subscription(
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
modem_enabled: bool = False,
):
"""Продлевает существующую подписку."""
from datetime import datetime, timedelta
@@ -4325,7 +4291,6 @@ async def _extend_existing_subscription(
'device_limit': device_limit,
'traffic_limit_gb': traffic_limit_gb,
'squad_uuid': squad_uuid,
'modem_enabled': modem_enabled,
}
price_kopeks, price_breakdown = await _calculate_simple_subscription_price(
db,
@@ -4334,17 +4299,15 @@ async def _extend_existing_subscription(
resolved_squad_uuid=squad_uuid,
)
logger.warning(
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | modem=%s | servers=%s | discount=%s | device_limit=%s | modem_enabled=%s',
'SIMPLE_SUBSCRIPTION_EXTEND_PRICE | user=%s | total=%s | base=%s | traffic=%s | devices=%s | servers=%s | discount=%s | device_limit=%s',
db_user.id,
price_kopeks,
price_breakdown.get('base_price', 0),
price_breakdown.get('traffic_price', 0),
price_breakdown.get('devices_price', 0),
price_breakdown.get('modem_price', 0),
price_breakdown.get('servers_price', 0),
price_breakdown.get('total_discount', 0),
device_limit,
modem_enabled,
)
# Проверяем баланс пользователя
+84 -11
View File
@@ -1424,10 +1424,23 @@ async def confirm_daily_tariff_purchase(
# ==================== Продление по тарифу ====================
def _calc_extra_devices_cost(tariff: Tariff, subscription_device_limit: int, period_days: int) -> int:
"""Рассчитывает стоимость дополнительных устройств сверх тарифа для периода."""
additional = max(0, subscription_device_limit - (tariff.device_limit or 1))
if additional <= 0:
return 0
device_price = getattr(tariff, 'device_price_kopeks', None) or 0
if device_price <= 0:
return 0
months = max(1, round(period_days / 30))
return additional * device_price * months
def get_tariff_extend_keyboard(
tariff: Tariff,
language: str,
db_user: User | None = None,
subscription_device_limit: int | None = None,
) -> InlineKeyboardMarkup:
"""Создает клавиатуру выбора периода для продления по тарифу с учетом скидок по периодам."""
texts = get_texts(language)
@@ -1438,6 +1451,10 @@ def get_tariff_extend_keyboard(
period = int(period_str)
price = prices[period_str]
# Добавляем стоимость дополнительных устройств
if subscription_device_limit is not None:
price += _calc_extra_devices_cost(tariff, subscription_device_limit, period)
# Получаем скидку для конкретного периода
discount_percent = 0
if db_user:
@@ -1508,13 +1525,17 @@ async def show_tariff_extend(
if has_period_discounts:
discount_hint = '\n🎁 <i>Скидки зависят от выбранного периода</i>'
actual_device_limit = subscription.device_limit or tariff.device_limit
await callback.message.edit_text(
f'🔄 <b>Продление подписки</b>{discount_hint}\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n\n'
f'📱 Устройств: {actual_device_limit}\n\n'
'Выберите период продления:',
reply_markup=get_tariff_extend_keyboard(tariff, db_user.language, db_user=db_user),
reply_markup=get_tariff_extend_keyboard(
tariff, db_user.language, db_user=db_user, subscription_device_limit=actual_device_limit
),
parse_mode='HTML',
)
await callback.answer()
@@ -1531,6 +1552,12 @@ async def select_tariff_extend_period(
texts = get_texts(db_user.language)
parts = callback.data.split(':')
tariff_id = int(parts[1])
# Кнопка «Назад» шлёт tariff_extend:{id} без периода — показываем экран выбора периода
if len(parts) < 3:
await show_tariff_extend(callback, db_user, db)
return
period = int(parts[2])
tariff = await get_tariff_by_id(db, tariff_id)
@@ -1538,12 +1565,16 @@ async def select_tariff_extend_period(
await callback.answer('Тариф недоступен', show_alert=True)
return
subscription = await get_subscription_by_user_id(db, db_user.id)
actual_device_limit = (subscription.device_limit if subscription else None) or tariff.device_limit
# Получаем скидку для выбранного периода
discount_percent = _get_user_period_discount(db_user, period)
# Получаем цену
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, discount_percent)
# Проверяем баланс
@@ -1560,7 +1591,7 @@ async def select_tariff_extend_period(
f'✅ <b>Подтверждение продления</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Период: {_format_period(period)}\n'
f'{discount_text}\n'
f'💰 <b>К оплате: {_format_price_kopeks(final_price)}</b>\n\n'
@@ -1572,9 +1603,6 @@ async def select_tariff_extend_period(
else:
missing = final_price - user_balance
# Получаем текущую подписку для сохранения в корзину
subscription = await get_subscription_by_user_id(db, db_user.id)
# Сохраняем данные корзины для автопокупки после пополнения
cart_data = {
'cart_mode': 'extend',
@@ -1588,7 +1616,7 @@ async def select_tariff_extend_period(
'return_to_cart': True,
'description': f'Продление тарифа {tariff.name} на {period} дней',
'traffic_limit_gb': tariff.traffic_limit_gb,
'device_limit': tariff.device_limit,
'device_limit': actual_device_limit,
'allowed_squads': tariff.allowed_squads or [],
'discount_percent': discount_percent,
}
@@ -1641,12 +1669,15 @@ async def confirm_tariff_extend(
await callback.answer('Подписка не найдена', show_alert=True)
return
actual_device_limit = subscription.device_limit or tariff.device_limit
data = await state.get_data()
discount_percent = data.get('extend_discount_percent', 0)
# Получаем цену
# Получаем цену (тариф + дополнительные устройства)
prices = tariff.period_prices or {}
base_price = prices.get(str(period), 0)
base_price += _calc_extra_devices_cost(tariff, actual_device_limit, period)
final_price = _apply_promo_discount(base_price, discount_percent)
# Проверяем баланс
@@ -1724,7 +1755,7 @@ async def confirm_tariff_extend(
f'🎉 <b>Подписка успешно продлена!</b>\n\n'
f'📦 Тариф: <b>{tariff.name}</b>\n'
f'📊 Трафик: {traffic}\n'
f'📱 Устройств: {tariff.device_limit}\n'
f'📱 Устройств: {actual_device_limit}\n'
f'📅 Добавлено: {_format_period(period)}\n'
f'💰 Списано: {_format_price_kopeks(final_price)}',
reply_markup=InlineKeyboardMarkup(
@@ -2242,6 +2273,19 @@ async def confirm_tariff_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при смене тарифа для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
# Создаем транзакцию
await create_transaction(
db,
@@ -2262,6 +2306,7 @@ async def confirm_tariff_switch(
days_for_new_tariff, # Итоговый срок подписки
was_trial_conversion=False,
amount_kopeks=final_price,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
@@ -2395,7 +2440,7 @@ async def confirm_daily_tariff_switch(
await db.commit()
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
# Обновляем пользователя в Remnawave (create_remnawave_user также сбрасывает устройства)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
@@ -2407,6 +2452,19 @@ async def confirm_daily_tariff_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при смене на суточный тариф для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
# Создаем транзакцию
await create_transaction(
db,
@@ -2427,6 +2485,7 @@ async def confirm_daily_tariff_switch(
1, # 1 день
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
@@ -2972,6 +3031,19 @@ async def confirm_instant_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при переключении тарифа: {e}')
# Создаем транзакцию если была оплата
if is_upgrade and upgrade_cost > 0:
await create_transaction(
@@ -2993,6 +3065,7 @@ async def confirm_instant_switch(
remaining_days,
was_trial_conversion=False,
amount_kopeks=upgrade_cost,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
+42 -2
View File
@@ -21,6 +21,7 @@ from app.keyboards.inline import (
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
from app.services.subscription_service import SubscriptionService
from app.services.user_cart_service import user_cart_service
from app.states import SubscriptionStates
from app.utils.pricing_utils import (
apply_percentage_discount,
@@ -239,7 +240,7 @@ async def handle_reset_traffic(callback: types.CallbackQuery, db_user: User, db:
await callback.message.edit_text(
f'🔄 <b>Сброс трафика</b>\n\n'
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb)}\n'
f'Использовано: {texts.format_traffic(subscription.traffic_used_gb, is_limit=False)}\n'
f'Лимит: {texts.format_traffic(subscription.traffic_limit_gb)}\n\n'
f'Стоимость сброса: {texts.format_price(reset_price)}{price_info}{balance_info}\n\n'
'После сброса счетчик использованного трафика станет равным 0.',
@@ -483,7 +484,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
discount_per_month = discount_result['discount']
charged_months = 1
if subscription:
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
# Пропорциональный расчёт применяем только в классическом режиме.
is_tariff_mode = settings.is_tariffs_mode() and subscription and subscription.tariff_id
if is_tariff_mode:
price = discounted_per_month
elif subscription:
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
@@ -495,6 +503,24 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
if db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
# Save cart for auto-purchase after balance top-up
cart_data = {
'cart_mode': 'add_traffic',
'subscription_id': subscription.id,
'traffic_gb': traffic_gb,
'price_kopeks': price,
'base_price_kopeks': discounted_per_month,
'discount_percent': discount_result['percent'],
'source': 'bot',
'description': f'Докупка {traffic_gb} ГБ трафика',
}
try:
await user_cart_service.save_user_cart(db_user.id, cart_data)
logger.info(f'Cart saved for traffic purchase (bot) user {db_user.telegram_id}: +{traffic_gb} GB')
except Exception as e:
logger.error(f'Error saving cart for traffic purchase (bot): {e}')
message_text = texts.t(
'ADDON_INSUFFICIENT_FUNDS_MESSAGE',
(
@@ -521,6 +547,9 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
await callback.answer()
return
# Сохраняем старое значение трафика для уведомления
old_traffic_limit = subscription.traffic_limit_gb
try:
success = await subtract_user_balance(
db,
@@ -561,6 +590,17 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
await db.refresh(db_user)
await db.refresh(subscription)
# Отправляем уведомление админам о докупке трафика
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, 'traffic', old_traffic_limit, subscription.traffic_limit_gb, price
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления о докупке трафика: {e}')
success_text = '✅ Трафик успешно добавлен!\n\n'
if traffic_gb == 0:
success_text += '🎉 Теперь у вас безлимитный трафик!'
+70 -113
View File
@@ -3,12 +3,12 @@ import logging
import time
from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InaccessibleMessage
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
from app.database.crud.user import get_user_by_id
from app.database.models import Ticket, TicketStatus, User
@@ -63,12 +63,20 @@ async def show_ticket_priority_selection(
)
return
await callback.message.edit_text(
texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:'),
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
prompt_text = texts.t('TICKET_TITLE_INPUT', 'Введите заголовок тикета:')
cancel_kb = get_ticket_cancel_keyboard(db_user.language)
prompt_msg = callback.message
try:
await callback.message.edit_text(prompt_text, reply_markup=cancel_kb)
except TelegramBadRequest:
# Предыдущее сообщение — фото (нет текста для edit_text), удаляем и шлём новое
try:
await callback.message.delete()
except Exception:
pass
prompt_msg = await callback.message.answer(prompt_text, reply_markup=cancel_kb)
# Запоминаем исходное сообщение бота, чтобы далее редактировать его, а не слать новые
await state.update_data(prompt_chat_id=callback.message.chat.id, prompt_message_id=callback.message.message_id)
await state.update_data(prompt_chat_id=prompt_msg.chat.id, prompt_message_id=prompt_msg.message_id)
await state.set_state(TicketStates.waiting_for_title)
await callback.answer()
@@ -80,6 +88,9 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
return
"""Обработать ввод заголовка тикета"""
if not message.text:
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
return
title = message.text.strip()
data_prompt = await state.get_data()
@@ -89,58 +100,18 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
asyncio.create_task(_try_delete_message_later(message.bot, message.chat.id, message.message_id, 2.0))
if len(title) < 5:
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t(
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t('TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:')
)
text_val = texts.t(
'TICKET_TITLE_TOO_SHORT', 'Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:'
)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
return
if len(title) > 255:
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
)
text_val = texts.t(
'TICKET_TITLE_TOO_LONG', 'Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:'
)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
return
# Глобальный блок
@@ -163,29 +134,8 @@ async def handle_ticket_title_input(message: types.Message, state: FSMContext, d
await state.update_data(title=title)
texts = get_texts(db_user.language)
if prompt_chat_id and prompt_message_id:
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=text_val,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(
texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:'),
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
text_val = texts.t('TICKET_MESSAGE_INPUT', 'Опишите проблему (до 500 символов) или отправьте фото с подписью:')
await _edit_or_send(message, prompt_chat_id, prompt_message_id, text_val, db_user.language)
await state.set_state(TicketStates.waiting_for_message)
@@ -260,12 +210,10 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
)
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id, message_id=prompt_message_id, caption=text_msg, parse_mode=None
)
else:
try:
await message.bot.edit_message_text(chat_id=prompt_chat_id, message_id=prompt_message_id, text=text_msg)
except TelegramBadRequest:
await message.answer(text_msg)
else:
await message.answer(text_msg)
await state.clear()
@@ -282,24 +230,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
err_text = texts.t(
'TICKET_MESSAGE_TOO_SHORT', 'Сообщение слишком короткое. Опишите проблему подробнее или отправьте фото:'
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=err_text,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
parse_mode=None,
)
else:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
text=err_text,
reply_markup=get_ticket_cancel_keyboard(db_user.language),
)
else:
await message.answer(err_text)
await _edit_or_send(message, prompt_chat_id, prompt_message_id, err_text, db_user.language)
return
data = await state.get_data()
@@ -353,15 +284,7 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
]
)
if prompt_chat_id and prompt_message_id:
if settings.ENABLE_LOGO_MODE:
await message.bot.edit_message_caption(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
caption=creation_text,
reply_markup=keyboard,
parse_mode='HTML',
)
else:
try:
await message.bot.edit_message_text(
chat_id=prompt_chat_id,
message_id=prompt_message_id,
@@ -369,6 +292,8 @@ async def handle_ticket_message_input(message: types.Message, state: FSMContext,
reply_markup=keyboard,
parse_mode='HTML',
)
except TelegramBadRequest:
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
else:
await message.answer(creation_text, reply_markup=keyboard, parse_mode='HTML')
@@ -762,6 +687,28 @@ async def user_delete_message(callback: types.CallbackQuery):
await callback.answer('')
async def _edit_or_send(
message: types.Message,
chat_id: int | None,
message_id: int | None,
text: str,
language: str,
) -> None:
"""Попытаться отредактировать prompt-сообщение, при неудаче — отправить новое."""
if chat_id and message_id:
try:
await message.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=get_ticket_cancel_keyboard(language),
)
return
except TelegramBadRequest:
pass
await message.answer(text, reply_markup=get_ticket_cancel_keyboard(language))
async def _try_delete_message_later(bot: Bot, chat_id: int, message_id: int, delay_seconds: float = 1.0):
try:
await asyncio.sleep(delay_seconds)
@@ -779,10 +726,20 @@ async def reply_to_ticket(callback: types.CallbackQuery, state: FSMContext, db_u
texts = get_texts(db_user.language)
await callback.message.edit_text(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
try:
await callback.message.edit_text(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
except TelegramBadRequest:
try:
await callback.message.delete()
except Exception:
pass
await callback.message.answer(
texts.t('TICKET_REPLY_INPUT', 'Введите ваш ответ:'),
reply_markup=get_ticket_reply_cancel_keyboard(db_user.language),
)
await state.set_state(TicketStates.waiting_for_reply)
await callback.answer()
+10 -1
View File
@@ -1,6 +1,6 @@
import logging
from aiogram import types
from aiogram import Bot, types
from aiohttp import web
from app.config import settings
@@ -13,6 +13,15 @@ from app.external.tribute import TributeService
logger = logging.getLogger(__name__)
# Глобальная ссылка на бота для отправки уведомлений
_bot_instance: Bot | None = None
def set_webhook_bot(bot: Bot) -> None:
"""Устанавливает экземпляр бота для отправки уведомлений об ошибках в webhook."""
global _bot_instance
_bot_instance = bot
async def tribute_webhook(request):
try:
+18 -24
View File
@@ -326,6 +326,12 @@ def get_admin_users_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
text=_t(texts, 'ADMIN_USERS_BULK_BAN', '🛑 Массовый бан'), callback_data='admin_bulk_ban_start'
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_BLOCKED_CHECK', '🔒 Заблокировавшие бота'),
callback_data='admin_blocked_users',
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')],
]
)
@@ -342,36 +348,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark
callback_data='admin_users_balance_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'),
callback_data='admin_users_traffic_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'),
callback_data='admin_users_activity_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'),
callback_data='admin_users_spending_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'),
callback_data='admin_users_purchases_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'),
callback_data='admin_users_ready_to_renew_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'),
callback_data='admin_users_potential_customers_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'),
@@ -649,6 +637,12 @@ def get_referral_contest_manage_keyboard(
callback_data=f'admin_contest_edit_times_{contest_id}',
),
],
[
InlineKeyboardButton(
text='👻 Виртуальные',
callback_data=f'admin_contest_vp_{contest_id}',
),
],
[
InlineKeyboardButton(
text='🔄 Синхронизация',
+187 -64
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
# Получаем данные для плейсхолдеров
@@ -247,6 +245,8 @@ _LANGUAGE_DISPLAY_NAMES = {
'zh-hant': '🇹🇼 中文 (繁體)',
'vi': '🇻🇳 Tiếng Việt',
'vi-vn': '🇻🇳 Tiếng Việt',
'fa': '🇮🇷 فارسی',
'fa-ir': '🇮🇷 فارسی',
}
@@ -362,32 +362,101 @@ def get_language_selection_keyboard(
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _build_text_main_menu_keyboard(
def _build_cabinet_main_menu_keyboard(
language: str,
texts,
*,
is_admin: bool,
is_moderator: bool,
balance_kopeks: int = 0,
) -> InlineKeyboardMarkup:
profile_text = texts.t('MENU_PROFILE', '👤 Личный кабинет')
miniapp_url = settings.get_main_menu_miniapp_url()
"""Build the main-menu keyboard for Cabinet mode.
if miniapp_url:
profile_button = InlineKeyboardButton(
text=profile_text,
web_app=types.WebAppInfo(url=miniapp_url),
)
Each button opens the corresponding section of the cabinet frontend
via ``MINIAPP_CUSTOM_URL`` + path (e.g. ``/subscription``, ``/balance``).
"""
from app.utils.button_styles_cache import CALLBACK_TO_SECTION, get_cached_button_styles
from app.utils.miniapp_buttons import (
CALLBACK_TO_CABINET_STYLE,
_resolve_style,
build_cabinet_url,
)
global_style = _resolve_style((settings.CABINET_BUTTON_STYLE or '').strip())
cached_styles = get_cached_button_styles()
def _cabinet_button(
text: str,
path: str,
callback_fallback: str,
*,
style: str | None = None,
icon_custom_emoji_id: str | None = None,
) -> InlineKeyboardButton:
url = build_cabinet_url(path)
if url:
section = CALLBACK_TO_SECTION.get(callback_fallback)
section_cfg = cached_styles.get(section or '', {}) if section else {}
# 'default' in per-section config means "no color" — do not fall through.
if style:
resolved = _resolve_style(style)
elif section_cfg.get('style'):
resolved = _resolve_style(section_cfg['style'])
else:
resolved = global_style or _resolve_style(CALLBACK_TO_CABINET_STYLE.get(callback_fallback))
resolved_emoji = icon_custom_emoji_id or section_cfg.get('icon_custom_emoji_id') or None
return InlineKeyboardButton(
text=text,
web_app=types.WebAppInfo(url=url),
style=resolved,
icon_custom_emoji_id=resolved_emoji or None,
)
return InlineKeyboardButton(text=text, callback_data=callback_fallback)
# -- Primary action row: Cabinet home --
home_cfg = cached_styles.get('home', {})
if home_cfg.get('enabled', True):
profile_text = home_cfg.get('labels', {}).get(language, '') or texts.t('MENU_PROFILE', '👤 Личный кабинет')
keyboard_rows: list[list[InlineKeyboardButton]] = [
[_cabinet_button(profile_text, '/', 'menu_profile_unavailable')],
]
else:
profile_button = InlineKeyboardButton(
text=profile_text,
callback_data='menu_profile_unavailable',
)
keyboard_rows: list[list[InlineKeyboardButton]] = []
keyboard_rows: list[list[InlineKeyboardButton]] = [[profile_button]]
# -- Section buttons as paired rows --
paired: list[InlineKeyboardButton] = []
if settings.is_language_selection_enabled():
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language')])
# Subscription (green — main action)
sub_cfg = cached_styles.get('subscription', {})
if sub_cfg.get('enabled', True):
sub_text = sub_cfg.get('labels', {}).get(language, '') or texts.MENU_SUBSCRIPTION
paired.append(_cabinet_button(sub_text, '/subscription', 'menu_subscription'))
# Balance
bal_cfg = cached_styles.get('balance', {})
if bal_cfg.get('enabled', True):
safe_balance = balance_kopeks or 0
# Custom label overrides the whole text including balance amount
custom_bal = bal_cfg.get('labels', {}).get(language, '')
if custom_bal:
balance_text = custom_bal
elif hasattr(texts, 'BALANCE_BUTTON') and safe_balance > 0:
balance_text = texts.BALANCE_BUTTON.format(balance=texts.format_price(safe_balance))
else:
balance_text = texts.t('BALANCE_BUTTON_DEFAULT', '💰 Баланс: {balance}').format(
balance=texts.format_price(safe_balance),
)
paired.append(_cabinet_button(balance_text, '/balance', 'menu_balance'))
# Referrals (if enabled)
ref_cfg = cached_styles.get('referral', {})
if settings.is_referral_program_enabled() and ref_cfg.get('enabled', True):
ref_text = ref_cfg.get('labels', {}).get(language, '') or texts.MENU_REFERRALS
paired.append(_cabinet_button(ref_text, '/referral', 'menu_referrals'))
# Support
support_enabled = False
try:
from app.services.support_settings_service import SupportSettingsService
@@ -396,11 +465,33 @@ def _build_text_main_menu_keyboard(
except Exception:
support_enabled = settings.SUPPORT_MENU_ENABLED
if support_enabled:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data='menu_support')])
sup_cfg = cached_styles.get('support', {})
if support_enabled and sup_cfg.get('enabled', True):
sup_text = sup_cfg.get('labels', {}).get(language, '') or texts.MENU_SUPPORT
paired.append(_cabinet_button(sup_text, '/support', 'menu_support'))
# Info
info_cfg = cached_styles.get('info', {})
if info_cfg.get('enabled', True):
info_text = info_cfg.get('labels', {}).get(language, '') or texts.t('MENU_INFO', '️ Инфо')
paired.append(_cabinet_button(info_text, '/info', 'menu_info'))
# Language selection (stays as callback — not a cabinet section)
if settings.is_language_selection_enabled():
paired.append(InlineKeyboardButton(text=texts.MENU_LANGUAGE, callback_data='menu_language'))
# Lay out in pairs
for i in range(0, len(paired), 2):
keyboard_rows.append(paired[i : i + 2])
# Admin / Moderator
admin_cfg = cached_styles.get('admin', {})
if is_admin:
keyboard_rows.append([InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')])
admin_buttons = [InlineKeyboardButton(text=texts.MENU_ADMIN, callback_data='admin_panel')]
if admin_cfg.get('enabled', True):
admin_web_text = admin_cfg.get('labels', {}).get(language, '') or '🖥 Веб-Админка'
admin_buttons.append(_cabinet_button(admin_web_text, '/admin', 'admin_panel'))
keyboard_rows.append(admin_buttons)
elif is_moderator:
keyboard_rows.append([InlineKeyboardButton(text='🧑‍⚖️ Модерация', callback_data='moderator_panel')])
@@ -423,12 +514,13 @@ def get_main_menu_keyboard(
) -> InlineKeyboardMarkup:
texts = get_texts(language)
if settings.is_text_main_menu_mode():
return _build_text_main_menu_keyboard(
if settings.is_cabinet_mode():
return _build_cabinet_main_menu_keyboard(
language,
texts,
is_admin=is_admin,
is_moderator=is_moderator,
balance_kopeks=balance_kopeks,
)
if settings.DEBUG:
@@ -1000,9 +1092,15 @@ def get_subscription_keyboard(
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
if is_daily_tariff:
# Для суточного тарифа показываем кнопку паузы/возобновления
# Для суточного тарифа: проверяем статус подписки
from app.database.models import SubscriptionStatus
sub_status = getattr(subscription, 'status', None)
is_paused = getattr(subscription, 'is_daily_paused', False)
if is_paused:
is_inactive = sub_status in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value)
if is_inactive or is_paused:
# Подписка остановлена (системой или пользователем) — показываем «Возобновить»
pause_text = texts.t('RESUME_DAILY_BUTTON', '▶️ Возобновить подписку')
else:
pause_text = texts.t('PAUSE_DAILY_BUTTON', '⏸️ Приостановить подписку')
@@ -1789,6 +1887,8 @@ def get_add_traffic_keyboard(
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
months_multiplier = 1
period_text = ''
@@ -1798,7 +1898,7 @@ def get_add_traffic_keyboard(
period_text = f' (за {months_multiplier} мес)'
packages = settings.get_traffic_topup_packages()
enabled_packages = [pkg for pkg in packages if pkg['enabled']]
enabled_packages = [pkg for pkg in packages if pkg['enabled'] and pkg['price'] > 0]
if not enabled_packages:
return InlineKeyboardMarkup(
@@ -1826,17 +1926,20 @@ def get_add_traffic_keyboard(
total_discount = discount_per_month * months_multiplier
if gb == 0:
if language == 'ru':
if use_russian_fallback:
text = f'♾️ Безлимитный трафик - {total_price // 100}{period_text}'
else:
text = f'♾️ Unlimited traffic - {total_price // 100}{period_text}'
elif language == 'ru':
elif use_russian_fallback:
text = f'📊 +{gb} ГБ трафика - {total_price // 100}{period_text}'
else:
text = f'📊 +{gb} GB traffic - {total_price // 100}{period_text}'
if discount_percent > 0 and total_discount > 0:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
if use_russian_fallback:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
else:
text += f' (discount {discount_percent}%: -{total_discount // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
@@ -1860,16 +1963,9 @@ def get_add_traffic_keyboard_from_tariff(
subscription_end_date: Дата окончания подписки для расчета цены
discount_percent: Процент скидки
"""
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
language_code = (language or DEFAULT_LANGUAGE).split('-')[0].lower()
use_russian_fallback = language_code in {'ru', 'fa'}
if not packages:
return InlineKeyboardMarkup(
@@ -1886,24 +1982,29 @@ def get_add_traffic_keyboard_from_tariff(
buttons = []
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
# Сортируем пакеты по размеру, исключаем пакеты с нулевой ценой
sorted_packages = sorted(((gb, p) for gb, p in packages.items() if p > 0), key=lambda x: x[0])
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
for gb, price_per_month in sorted_packages:
discounted_per_month, discount_per_month = apply_percentage_discount(
discounted_price, discount_value = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
if language == 'ru':
text = f'📊 +{gb} ГБ трафика - {total_price // 100}{period_text}'
period_text = ' /мес' if use_russian_fallback else ' /mo'
if use_russian_fallback:
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100}{period_text}'
else:
text = f'📊 +{gb} GB traffic - {total_price // 100}{period_text}'
text = f'📊 +{gb} GB traffic - {discounted_price // 100}{period_text}'
if discount_percent > 0 and total_discount > 0:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
if discount_percent > 0 and discount_value > 0:
if use_russian_fallback:
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
else:
text += f' (discount {discount_percent}%: -{discount_value // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
@@ -1924,12 +2025,28 @@ def get_change_devices_keyboard(
texts = get_texts(language)
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
# Проверяем является ли тариф суточным
is_daily_tariff = tariff and getattr(tariff, 'is_daily', False)
# Для суточных тарифов считаем по дням, для обычных - по месяцам
if is_daily_tariff and subscription_end_date:
# Суточный тариф: цена за оставшиеся дни (обычно 1 день)
from datetime import datetime
now = datetime.utcnow()
days_left = max(1, (subscription_end_date - now).days)
# Множитель = days_left / 30 (как в кабинете)
price_multiplier = days_left / 30
period_text = f' (за {days_left} дн.)' if days_left > 1 else ' (за 1 день)'
else:
# Обычный тариф: цена за оставшиеся месяцы
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
price_multiplier = months_multiplier
# Используем цену из тарифа если есть, иначе глобальную настройку
tariff_device_price = getattr(tariff, 'device_price_kopeks', None) if tariff else None
@@ -1943,9 +2060,18 @@ def get_change_devices_keyboard(
buttons = []
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
if tariff_max_devices and tariff_max_devices > 0:
max_devices = tariff_max_devices
else:
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
# Минимальное количество устройств: device_limit тарифа или 1
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
min_devices = max(1, tariff_min_devices)
start_range = max(min_devices, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
for devices_count in range(start_range, end_range):
@@ -1967,10 +2093,12 @@ def get_change_devices_keyboard(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_price = int(discounted_per_month * price_multiplier)
total_price = max(100, total_price) # Минимум 1 рубль
price_text = f' (+{total_price // 100}{period_text})'
if discount_percent > 0 and discount_per_month * months_multiplier > 0:
price_text += f' (скидка {discount_percent}%: -{(discount_per_month * months_multiplier) // 100}₽)'
total_discount = int(discount_per_month * price_multiplier)
if discount_percent > 0 and total_discount > 0:
price_text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
action_text = ''
else:
price_text = ' (бесплатно)'
@@ -2042,7 +2170,7 @@ def get_reset_traffic_confirm_keyboard(
[
InlineKeyboardButton(
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'),
callback_data=f'topup_amount_{missing_kopeks}',
callback_data='balance_topup',
)
]
)
@@ -2590,11 +2718,6 @@ def get_updated_subscription_settings_keyboard(
]
)
if settings.is_modem_enabled() and not has_tariff:
keyboard.append(
[InlineKeyboardButton(text=texts.t('MODEM_BUTTON', '📡 Модем'), callback_data='subscription_modem')]
)
keyboard.append(
[
InlineKeyboardButton(
+1 -1
View File
@@ -231,7 +231,7 @@ def ensure_locale_templates() -> None:
_copy_locale(template, destination / template.name)
return
for locale_code in ('ru', 'en'):
for locale_code in ('ru', 'en', 'fa'):
source_path = _DEFAULT_LOCALES_DIR / f'{locale_code}.json'
target_path = destination / f'{locale_code}.json'
+26 -2
View File
@@ -925,6 +925,7 @@
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nPrice per extra device: {price}/mo\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\n️ Payments are not refunded\n",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
@@ -977,7 +978,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {period})",
"DEVICE_CHANGE_FREE": "Free",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
@@ -1547,6 +1548,9 @@
"TRIAL_PROVISIONING_FAILED": "We couldn't finish setting up the trial. Any charge has been refunded. Please try again later.",
"TRIAL_ROLLBACK_FAILED": "We couldn't cancel the trial activation after a payment error. Please contact support and try again later.",
"TRIAL_REFUND_FAILED": "We couldn't refund the trial activation charge. Please contact support immediately.",
"TRIAL_PAYMENT_DESCRIPTION": "Trial subscription payment",
"TRIAL_REFUND_DESCRIPTION": "Refund for failed trial activation",
"TRIAL_ACTIVATION_ERROR": "❌ An error occurred during trial activation. Funds have been returned to your balance.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 {amount} has been deducted from your balance.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Access paused</b>\n\nWe couldn't find your subscription to our channel, so the trial plan has been disabled.\n\nJoin the channel and tap “{check_button}” to restore access.",
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
@@ -1652,6 +1656,7 @@
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Blocked bot users",
"CONTEST_START_ANNOUNCEMENT": "Game starts",
"CONTEST_PRIZE": "Prize",
"DAYS": "days subscription",
@@ -1693,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"
}
File diff suppressed because it is too large Load Diff
+31 -3
View File
@@ -807,6 +807,10 @@
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
@@ -941,6 +945,7 @@
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nЦена за доп. устройство: {price}/мес\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
@@ -994,7 +999,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
"DEVICE_CHANGE_FREE": "Бесплатно",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
@@ -1534,7 +1539,7 @@
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
"TOPUP_BALANCE_BUTTON": "💳 Пополнить баланс",
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
@@ -1564,6 +1569,9 @@
"TRIAL_PROVISIONING_FAILED": "Не удалось завершить активацию триала. Средства возвращены на баланс. Попробуйте позже.",
"TRIAL_ROLLBACK_FAILED": "Не удалось отменить активацию триала после ошибки списания. Свяжитесь с поддержкой и попробуйте позже.",
"TRIAL_REFUND_FAILED": "Не удалось вернуть оплату за активацию триала. Немедленно свяжитесь с поддержкой.",
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробной подписки",
"TRIAL_REFUND_DESCRIPTION": "Возврат за неудачную активацию триала",
"TRIAL_ACTIVATION_ERROR": "❌ Произошла ошибка при активации триала. Средства возвращены на баланс.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 С вашего баланса списано {amount}.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ приостановлен</b>\n\nМы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\nПодпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
@@ -1669,6 +1677,7 @@
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Заблокировавшие бота",
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
"CONTEST_PRIZE": "Приз",
"DAYS": "дн. подписки",
@@ -1710,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": "✖️ Закрыть"
}
+24 -2
View File
@@ -866,6 +866,7 @@
"CHANGE_DEVICES_CONFIRM": "\n  📱 <b>Підтвердження зміни</b>\n\n  Поточна кількість: {current_devices} пристроїв\n  Нова кількість: {new_devices} пристроїв\n\n  Дія: {action}\n  💰 {cost}\n\n  Підтвердити зміну?\n  ",
"CHANGE_DEVICES_INFO": "\n  📱 <b>Зміна кількості пристроїв</b>\n\n  Поточний ліміт: {current_devices} пристроїв\n\n  Оберіть нову кількість пристроїв:\n\n  💡 <b>Важливо:</b>\n  • При збільшенні - доплата пропорційно часу, що залишився\n  • При зменшенні - повернення коштів не здійснюється\n  ",
"CHANGE_DEVICES_PROMPT": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nЦіна за дод. пристрій: {price}/міс\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n  ✅ Кількість пристроїв зменшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  ℹ️ Повернення коштів не здійснюється\n  ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n  ✅ Кількість пристроїв збільшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  💰 Списано: {amount}\n  ",
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
@@ -917,7 +918,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Підтвердження зміни</b>\n\nПоточна кількість: {current} пристроїв\nНова кількість: {new} пристроїв\n\nДія: {action}\n💰 {cost}\n\nПідтвердити зміну?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Кількість пристроїв зменшено!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (знижка {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} міс)",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
"DEVICE_CHANGE_FREE": "Безкоштовно",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Кількість пристроїв збільшено!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Повернення коштів не здійснюється",
@@ -1478,6 +1479,9 @@
"TRIAL_PROVISIONING_FAILED": "Не вдалося завершити активацію тріалу. Кошти повернуто на баланс. Спробуйте пізніше.",
"TRIAL_ROLLBACK_FAILED": "Не вдалося скасувати активацію тріалу після помилки списання. Зв'яжіться з підтримкою і спробуйте пізніше.",
"TRIAL_REFUND_FAILED": "Не вдалося повернути оплату за активацію тріалу. Негайно зв'яжіться з підтримкою.",
"TRIAL_PAYMENT_DESCRIPTION": "Оплата пробної підписки",
"TRIAL_REFUND_DESCRIPTION": "Повернення за невдалу активацію тріалу",
"TRIAL_ACTIVATION_ERROR": "❌ Виникла помилка при активації тріалу. Кошти повернуто на баланс.",
"TRIAL_PAYMENT_CHARGED_NOTE": "💳 З вашого балансу списано {amount}.",
"TRIAL_CHANNEL_UNSUBSCRIBED": "\n🚫 <b>Доступ призупинено</b>\n\nМи не знайшли вашу підписку на наш канал, тому тестову підписку вимкнено.\n\nПідпишіться на канал і натисніть «{check_button}», щоб повернути доступ.",
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестова підписка скоро закінчиться!</b>\n\nВаша тестова підписка закінчується через декілька годин.\n\n💎 <b>Не хочете залишитися без VPN?</b>\nПереходьте на повну підписку!\n\n🔥 <b>Спеціальна пропозиція:</b>\n• 30 днів усього за {price}\n• Безлімітний трафік  \n• Всі сервери доступні\n• Швидкість до 1ГБіт/сек\n\n⚡️ Встигніть оформити до закінчення тестового періоду!\n",
@@ -1582,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": "✖️ Закрити"
}
+27 -2
View File
@@ -864,6 +864,7 @@
"CHANGE_DEVICES_CONFIRM":"\n  📱<b>确认更改</b>\n\n  当前数量:{current_devices}台设备\n  新数量:{new_devices}台设备\n\n  操作:{action}\n  💰{cost}\n\n  确认更改吗?\n  ",
"CHANGE_DEVICES_INFO":"\n  📱<b>更改设备数量</b>\n\n  当前限制:{current_devices}台设备\n\n  请选择新的设备数量:\n\n  💡<b>重要提示:</b>\n  •增加-按剩余时间比例补差价\n  •减少-不退款\n  ",
"CHANGE_DEVICES_PROMPT":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
"CHANGE_DEVICES_PROMPT_TARIFF":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n每台额外设备价格:{price}/月\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
"CHANGE_DEVICES_SUCCESS_DECREASE":"\n  ✅设备数量已减少!\n\n  📱之前:{old_count}→现在:{new_count}\n  ️不退款\n  ",
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n  ✅设备数量已增加!\n\n  📱之前:{old_count}→现在:{new_count}\n  💰已扣除:{amount}\n  ",
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
@@ -915,7 +916,7 @@
"DEVICE_CHANGE_CONFIRMATION":"📱<b>确认更改</b>\n\n当前数量:{current}台设备\n新数量:{new}台设备\n\n操作:{action}\n💰{cost}\n\n确认更改吗?",
"DEVICE_CHANGE_DECREASE_SUCCESS":"✅设备数量已减少!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO":"(折扣{percent}%:-{amount})",
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}(共{months}个月)",
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}({period})",
"DEVICE_CHANGE_FREE":"免费",
"DEVICE_CHANGE_INCREASE_SUCCESS":"✅设备数量已增加!\n\n",
"DEVICE_CHANGE_NO_REFUND":"不退款",
@@ -1476,6 +1477,9 @@
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
@@ -1806,6 +1810,9 @@
"TRIAL_PROVISIONING_FAILED":"未能完成试用激活。资金已退回余额。请稍后再试。",
"TRIAL_ROLLBACK_FAILED":"扣款失败后未能取消试用激活。请联系支持并稍后再试。",
"TRIAL_REFUND_FAILED":"未能退还试用激活付款。请立即联系支持。",
"TRIAL_PAYMENT_DESCRIPTION":"试用订阅付款",
"TRIAL_REFUND_DESCRIPTION":"试用激活失败退款",
"TRIAL_ACTIVATION_ERROR":"❌ 试用激活出错。资金已退回余额。",
"TRIAL_PAYMENT_CHARGED_NOTE":"💳已从您的余额中扣除{amount}。",
"TRIAL_CHANNEL_UNSUBSCRIBED":"\n🚫<b>访问已暂停</b>\n\n我们未找到您对我们频道的订阅,因此试用订阅已禁用。\n\n请订阅频道并点击“{check_button}”以恢复访问。",
"TRIAL_ENDING_SOON":"\n🎁<b>试用订阅即将结束!</b>\n\n您的试用订阅将在几小时后过期。\n\n💎<b>不想没有VPN吗?</b>\n升级到完整订阅!\n\n🔥<b>特别优惠:</b>\n•30天仅需{price}\n•无限流量 \n•所有服务器可用\n•速度高达1Gbit/s\n\n⚡️赶在试用期结束前订购吧!\n",
@@ -1912,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":"✖️ 关闭"
}
+20 -2
View File
@@ -35,6 +35,18 @@ _DYNAMIC_LANGUAGE_CONFIGS = {
'Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n'
),
},
'fa': {
'traffic_pattern': '📊 {size} گیگابایت - {price}',
'unlimited_pattern': '📊 نامحدود - {price}',
'support_info': (
'\n🛟 <b>پشتیبانی</b>\n\n'
'برای هرگونه سؤال به پشتیبانی پیام دهید:\n\n'
'👤 {support_username}\n\n'
'• 🎫 ایجاد تیکت\n'
'• 📋 تیکت‌های من\n'
'• 💬 تماس مستقیم\n'
),
},
'en': {
'traffic_pattern': '📊 {size} GB - {price}',
'unlimited_pattern': '📊 Unlimited - {price}',
@@ -190,9 +202,15 @@ class Texts:
return settings.format_price(kopeks)
@staticmethod
def format_traffic(gb: float) -> str:
def format_traffic(gb: float, is_limit: bool = True) -> str:
"""Format traffic value.
Args:
gb: Traffic in gigabytes
is_limit: If True, 0 means unlimited. If False, 0 means zero used.
"""
if gb == 0:
return '∞ (безлимит)'
return '∞ (безлимит)' if is_limit else '0 ГБ'
if gb >= 1024:
return f'{gb / 1024:.1f} ТБ'
return f'{gb:.0f} ГБ'
+231
View File
@@ -0,0 +1,231 @@
"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram.
Перехватывает все log records уровня ERROR и CRITICAL и отправляет их
в админский чат через существующий механизм send_error_to_admin_chat()
из app.middlewares.global_error.
Дедупликация:
- Записи, уже обработанные GlobalErrorMiddleware или @error_handler,
помечаются атрибутом _admin_notified = True и пропускаются.
- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения
дублирования одинаковых ошибок за короткий период.
Async bridge:
- logging.Handler.emit() -- синхронный. Мы используем
asyncio.get_running_loop().call_soon_threadsafe() для планирования
asyncio.Task из любого потока (sync или async).
Deferred init:
- Bot instance создаётся позже в main.py. Метод set_bot() позволяет
передать его после создания. До этого записи молча пропускаются.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import time
from typing import Final
from aiogram import Bot
# Константы
RECENT_HASHES_MAX_SIZE: Final[int] = 256
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error
# Логгеры, от которых мы гарантированно не хотим получать уведомления,
# даже если они вдруг выдадут ERROR (шум от транспортного уровня).
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
'aiohttp.access',
'aiohttp.client',
'aiohttp.internal',
'uvicorn.access',
'uvicorn.error',
'uvicorn.protocols',
'websockets',
'asyncio',
)
class TelegramErrorHandler(logging.Handler):
"""Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат.
Использует существующий механизм троттлинга и буферизации из
``app.middlewares.global_error.send_error_to_admin_chat``.
Usage::
handler = TelegramErrorHandler()
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
# Позже, когда Bot создан:
handler.set_bot(bot)
"""
def __init__(self, level: int = logging.ERROR) -> None:
super().__init__(level=level)
self._bot: Bot | None = None
# LRU-подобный кеш хешей недавних сообщений: hash -> timestamp
self._recent_hashes: dict[str, float] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def set_bot(self, bot: Bot) -> None:
"""Устанавливает Bot instance для отправки сообщений.
Вызывается из main.py после создания бота.
"""
self._bot = bot
# ------------------------------------------------------------------
# logging.Handler interface
# ------------------------------------------------------------------
def emit(self, record: logging.LogRecord) -> None:
"""Обрабатывает log record.
Синхронный метод (требование logging). Планирует async-отправку
через event loop.
"""
# 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel)
if record.levelno < logging.ERROR:
return
# 2. Уже отправлено через GlobalErrorMiddleware / @error_handler
if getattr(record, '_admin_notified', False):
return
# 3. Фильтруем шумные логгеры
if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
return
# 4. Бот ещё не инициализирован -- пропускаем
bot = self._bot
if bot is None:
return
# 5. Дедупликация по хешу (logger_name + message)
msg_hash = self._compute_hash(record)
now = time.monotonic()
# Чистим просроченные записи (ленивая очистка)
self._evict_stale(now)
if msg_hash in self._recent_hashes:
return
self._recent_hashes[msg_hash] = now
# 6. Планируем отправку через event loop
self._schedule_send(bot, record)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _compute_hash(record: logging.LogRecord) -> str:
"""Вычисляет короткий хеш для дедупликации.
Хешируем имя логгера + сообщение (без timestamp).
"""
raw = f'{record.name}:{record.getMessage()}'
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
def _evict_stale(self, now: float) -> None:
"""Удаляет устаревшие записи из кеша хешей."""
if not self._recent_hashes:
return
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
for k in stale_keys:
self._recent_hashes.pop(k, None)
# Принудительная очистка при переполнении — удаляем самые старые
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get)
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
self._recent_hashes.pop(k, None)
def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None:
"""Планирует асинхронную отправку в event loop.
Работает из любого потока:
- Если вызов из async-контекста -- создаём Task напрямую.
- Если из другого потока -- используем call_soon_threadsafe.
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Нет running loop -- мы в стороннем потоке без loop.
# Пытаемся получить loop, привязанный к основному потоку.
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
return
loop.call_soon_threadsafe(self._create_send_task, bot, record, loop)
except RuntimeError:
return
else:
# Мы в async-контексте -- создаём task напрямую
self._create_send_task(bot, record, loop)
def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None:
"""Создаёт asyncio.Task для отправки уведомления."""
loop.create_task(self._send(bot, record))
@staticmethod
async def _send(bot: Bot, record: logging.LogRecord) -> None:
"""Отправляет log record в админский чат через существующую инфраструктуру."""
try:
# Ленивый импорт -- избегаем циклических зависимостей при старте
from app.middlewares.global_error import send_error_to_admin_chat
# Формируем pseudo-Exception из log record
error = _make_log_record_error(record)
context_parts: list[str] = [f'Logger: {record.name}']
if record.funcName:
context_parts.append(f'Function: {record.funcName}')
if record.pathname and record.lineno:
context_parts.append(f'Location: {record.pathname}:{record.lineno}')
context = '\n'.join(context_parts)
# Извлекаем traceback из log record (если есть exc_info)
tb_override: str | None = None
if record.exc_info and record.exc_info[2] is not None:
import traceback
tb_override = ''.join(traceback.format_exception(*record.exc_info))
elif record.exc_text:
tb_override = record.exc_text
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
except Exception:
# Ни в коем случае не даём исключению утечь -- это logging handler,
# рекурсия убьёт приложение.
pass
def _make_log_record_error(record: logging.LogRecord) -> Exception:
"""Создаёт Exception-обёртку для LogRecord.
send_error_to_admin_chat использует type(error).__name__ как error_type.
Мы динамически создаём класс с правильным именем, чтобы не мутировать
общий класс между вызовами.
"""
class_name = f'Log{record.levelname.capitalize()}'
error_cls = type(
class_name,
(Exception,),
{
'__str__': lambda self: self.args[0] if self.args else '',
},
)
error = error_cls(record.getMessage())
error.record = record # type: ignore[attr-defined]
return error

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