Compare commits

...

439 Commits

Author SHA1 Message Date
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
159 changed files with 17799 additions and 26628 deletions
+6 -11
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=
@@ -542,7 +544,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
@@ -739,7 +740,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 +829,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 +837,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.9.1"
}
+180
View File
@@ -0,0 +1,180 @@
# Changelog
## [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.9.1" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+3 -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
@@ -1012,7 +1011,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 +1021,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` — ограничивает длину очереди входящих обновлений, чтобы защащаться от перегрузок.
@@ -1343,7 +1342,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 Автоплатёж с настройкой дня списания
- 🎁 Реферальные и промо-бонусы
-**Быстрое пополнение** с кнопками быстрых сумм
- 🔄 **Умная автоактивация** подписки после пополнения баланса
📱 **Управление подписками**
@@ -1530,7 +1528,6 @@ CONTEST_BUTTON_VISIBLE=true
- 🔄 **Миграция сквадов** - массовый перенос пользователей между сквадами
- 🧾 **История операций** - хранение всех транзакций и действий для аудита
- 💸 **Сервис автопроверки транзакций** - автоматическая проверка транзакций в статусе "В ожидании оплаты" за последние 24ч
- 🔄 **Умная автоактивация** - автоматическая активация подписки после пополнения баланса
- 📝 **Ротация логов** - автоматическая очистка и архивация старых логов
- 🎮 **Система конкурсов** - ежедневные игры и реферальные конкурсы с призами
+7
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,6 +61,7 @@ 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
@@ -118,6 +120,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
blacklist_middleware = BlacklistMiddleware()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
@@ -189,6 +195,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)
+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
+6
View File
@@ -17,6 +17,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 +28,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 +48,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 +87,8 @@ 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)
# 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'
+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 ============
+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
+190 -17
View File
@@ -11,14 +11,20 @@ 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 +36,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 +48,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 +173,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 +184,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 +195,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 +210,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 +386,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 +486,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 +958,159 @@ 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.
Sends a 6-digit verification code to the new email address.
User must have a verified email to change it.
"""
# Check if user has a verified email
if not user.email or not user.email_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='You must have a verified email to change it',
)
# 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',
)
# 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,
}
+117 -141
View File
@@ -13,6 +13,7 @@ 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,6 @@ from app.services.payment_verification_service import (
method_display_name,
run_manual_check,
)
from app.services.yookassa_service import YooKassaService
from ..dependencies import get_cabinet_db, get_current_cabinet_user
from ..schemas.balance import (
@@ -127,134 +127,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 +211,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 +312,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 +340,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 +355,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,
@@ -700,6 +637,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 +803,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 +842,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 +867,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
+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'])
+75 -22
View File
@@ -112,6 +112,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'
@@ -236,6 +237,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 +339,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
@@ -406,7 +403,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
@@ -528,7 +524,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 +687,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
@@ -1030,8 +1044,9 @@ 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}'
@@ -1161,22 +1176,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 +1313,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,
@@ -2416,7 +2432,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 +2514,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 +2530,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)
+30
View File
@@ -192,6 +192,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
+32 -1
View File
@@ -95,6 +95,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,6 +277,10 @@ 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 (
@@ -710,7 +733,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 +749,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:
+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)
+269 -23
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}')
@@ -686,6 +702,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 +739,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 +797,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 +825,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 +1071,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 +1083,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 +1125,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}')
+39 -1
View File
@@ -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:
@@ -1548,6 +1562,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'
@@ -1844,7 +1876,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 +1889,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')
+206
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:
@@ -4983,6 +5094,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 +6622,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 +6801,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 +7097,13 @@ 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 провайдеров')
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 +7169,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 +7216,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 +7321,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 +7351,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 +7386,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 +7427,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
+102 -19
View File
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import logging
@@ -366,32 +367,63 @@ 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}')
logger.error(f'API Error {response.status}: {error_message}')
logger.error(f'Response: {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 +596,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 +999,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}')
+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)
+295 -143
View File
@@ -127,58 +127,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 +1133,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 +1172,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 +1425,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:
+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
+160 -192
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(
@@ -1310,18 +1283,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 +2602,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 +2619,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 'Никогда'
@@ -4288,10 +4264,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 +4634,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 +4649,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 +4662,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 +5469,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_')
)
@@ -5659,14 +5633,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:
+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',
-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()
+170 -101
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 (
@@ -309,24 +309,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 +486,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 +518,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 +820,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}')
@@ -990,25 +1035,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 +1193,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:
@@ -1253,25 +1285,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 +1467,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,9 +1494,16 @@ 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)
@@ -1717,52 +1741,26 @@ async def required_sub_channel_check(
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 +1779,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,14 +1869,18 @@ 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
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -1855,13 +1907,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 +1956,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,9 +1974,7 @@ 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
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
@@ -1945,13 +2001,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 +2028,18 @@ 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
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
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 +2048,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):
+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)})'
+5 -1
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'])
@@ -428,7 +432,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,
+52 -70
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,
@@ -561,6 +560,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 +761,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 +1402,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
@@ -1724,24 +1746,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)
@@ -2228,24 +2232,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 'Действие ограничено администратором'
@@ -3084,8 +3070,8 @@ async def handle_subscription_settings(callback: types.CallbackQuery, db_user: U
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,
)
@@ -3241,6 +3227,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 +3391,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(
@@ -4134,24 +4134,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:
+78 -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()
@@ -1538,12 +1559,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 +1585,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 +1597,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 +1610,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 +1663,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 +1749,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 +2267,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 +2300,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 +2434,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 +2446,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 +2479,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 +3025,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 +3059,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 += '🎉 Теперь у вас безлимитный трафик!'
+3
View File
@@ -80,6 +80,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()
+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='🔄 Синхронизация',
+64 -32
View File
@@ -247,6 +247,8 @@ _LANGUAGE_DISPLAY_NAMES = {
'zh-hant': '🇹🇼 中文 (繁體)',
'vi': '🇻🇳 Tiếng Việt',
'vi-vn': '🇻🇳 Tiếng Việt',
'fa': '🇮🇷 فارسی',
'fa-ir': '🇮🇷 فارسی',
}
@@ -1789,6 +1791,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 = ''
@@ -1826,17 +1830,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 +1867,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(
@@ -1889,21 +1889,26 @@ def get_add_traffic_keyboard_from_tariff(
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), 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 +1929,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 +1964,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 +1997,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 +2074,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',
)
]
)
+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'
+6 -1
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",
File diff suppressed because it is too large Load Diff
+11 -2
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": "дн. подписки",
+5 -1
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",
+8 -1
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",
+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
+17 -2
View File
@@ -7,6 +7,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
from sqlalchemy.exc import InterfaceError, OperationalError
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
@@ -204,13 +205,27 @@ class AuthMiddleware(BaseMiddleware):
data['is_admin'] = settings.is_admin(user.id)
result = await handler(event, data)
await db.commit()
try:
await db.commit()
except (InterfaceError, OperationalError) as conn_err:
# Соединение закрылось (таймаут после долгой операции) - просто логируем
logger.warning(f'⚠️ Соединение с БД закрыто после обработки, пропускаем commit: {conn_err}')
return result
except (InterfaceError, OperationalError) as conn_err:
# Соединение с БД закрылось - не пытаемся rollback
logger.error(f'Ошибка соединения с БД в AuthMiddleware: {conn_err}')
logger.error(f'Event type: {type(event)}')
if hasattr(event, 'data'):
logger.error(f'Callback data: {event.data}')
raise
except Exception as e:
logger.error(f'Ошибка в AuthMiddleware: {e}')
logger.error(f'Event type: {type(event)}')
if hasattr(event, 'data'):
logger.error(f'Callback data: {event.data}')
await db.rollback()
try:
await db.rollback()
except (InterfaceError, OperationalError):
pass # Соединение уже закрыто
raise
+49
View File
@@ -0,0 +1,49 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, PreCheckoutQuery, TelegramObject, User as TgUser
from app.services.blacklist_service import blacklist_service
logger = logging.getLogger(__name__)
class BlacklistMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user: TgUser | None = None
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
user = event.from_user
if not user or user.is_bot:
return await handler(event, data)
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.id, user.username)
if not is_blacklisted:
return await handler(event, data)
logger.warning(f'🚫 Пользователь {user.id} (@{user.username}) из черного списка: {reason}')
block_text = (
f'🚫 Доступ запрещен\n\nПричина: {reason}\n\nЕсли вы считаете, что это ошибка, обратитесь в поддержку.'
)
try:
if isinstance(event, Message):
await event.answer(block_text)
elif isinstance(event, CallbackQuery):
await event.answer(block_text, show_alert=True)
elif isinstance(event, PreCheckoutQuery):
await event.answer(ok=False, error_message='Доступ запрещен')
except Exception as e:
logger.error(f'Ошибка отправки сообщения о блокировке пользователю {user.id}: {e}')
return None
+9 -1
View File
@@ -15,7 +15,7 @@ from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import AsyncSessionLocal
from app.database.models import SubscriptionStatus
from app.database.models import SubscriptionStatus, UserStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
@@ -396,6 +396,14 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированным пользователям
if user.status == UserStatus.BLOCKED.value:
logger.info(
'🚫 Пропуск реактивации для заблокированного пользователя %s',
telegram_id,
)
return
subscription = user.subscription
# Реактивируем только DISABLED подписки
+218 -41
View File
@@ -1,14 +1,60 @@
import logging
import traceback
from collections.abc import Awaitable, Callable
from typing import Any
from datetime import datetime, timedelta
from typing import Any, Final
from aiogram import BaseMiddleware
from aiogram import BaseMiddleware, Bot
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, TelegramObject
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, TelegramObject
from sqlalchemy.exc import InterfaceError, OperationalError
from app.config import settings
from app.services.startup_notification_service import _get_error_recommendations
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
# Константы
ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5
ERROR_BUFFER_MAX_SIZE: Final[int] = 10
ERROR_MESSAGE_MAX_LENGTH: Final[int] = 500
REPORT_SEPARATOR_WIDTH: Final[int] = 50
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
# Фразы ошибок Telegram API
OLD_QUERY_PHRASES: Final[tuple[str, ...]] = (
'query is too old',
'query id is invalid',
'response timeout expired',
)
BAD_REQUEST_PHRASES: Final[tuple[str, ...]] = (
'message not found',
'chat not found',
'bot was blocked by the user',
'user is deactivated',
)
TOPIC_ERROR_PHRASES: Final[tuple[str, ...]] = (
'topic must be specified',
'topic_closed',
'topic_deleted',
'forum_closed',
)
MESSAGE_NOT_MODIFIED_PHRASE: Final[str] = 'message is not modified'
BOT_BLOCKED_PHRASE: Final[str] = 'bot was blocked'
USER_DEACTIVATED_PHRASE: Final[str] = 'user is deactivated'
CHAT_NOT_FOUND_PHRASE: Final[str] = 'chat not found'
MESSAGE_NOT_FOUND_PHRASE: Final[str] = 'message not found'
# Троттлинг для предотвращения спама ошибками
_last_error_notification: datetime | None = None
_error_notification_cooldown = timedelta(minutes=ERROR_NOTIFICATION_COOLDOWN_MINUTES)
_error_buffer: list[tuple[str, str, str]] = [] # (error_type, error_message, traceback)
class GlobalErrorMiddleware(BaseMiddleware):
async def __call__(
@@ -20,81 +66,83 @@ class GlobalErrorMiddleware(BaseMiddleware):
try:
return await handler(event, data)
except TelegramBadRequest as e:
return await self._handle_telegram_error(event, e)
return await self._handle_telegram_error(event, e, data)
except (InterfaceError, OperationalError) as e:
# Ошибки соединения с БД (таймаут после долгих операций) - логируем, но не спамим админам
logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware: %s', e)
raise
except Exception as e:
logger.error(f'Неожиданная ошибка в GlobalErrorMiddleware: {e}', exc_info=True)
user_info = self._get_user_info(event)
logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=%s): %s', user_info, e, exc_info=True)
raise
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
error_message = str(error).lower()
if self._is_old_query_error(error_message):
return await self._handle_old_query(event, error)
if self._is_message_not_modified_error(error_message):
return await self._handle_message_not_modified(event, error)
return await self._handle_message_not_modified(event, error, data)
if self._is_topic_required_error(error_message):
# Канал с топиками — просто игнорируем
logger.debug(f'📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}')
logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика: %s', error)
return None
if self._is_bad_request_error(error_message):
return await self._handle_bad_request(event, error)
logger.error(f'Неизвестная Telegram API ошибка: {error}')
return await self._handle_bad_request(event, error, data)
# Неизвестная ошибка — логируем
user_info = self._get_user_info(event)
logger.error('Неизвестная Telegram API ошибка (user=%s): %s', user_info, error)
raise error
def _is_old_query_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['query is too old', 'query id is invalid', 'response timeout expired']
)
return any(phrase in error_message for phrase in OLD_QUERY_PHRASES)
def _is_message_not_modified_error(self, error_message: str) -> bool:
return 'message is not modified' in error_message
return MESSAGE_NOT_MODIFIED_PHRASE in error_message
def _is_bad_request_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated']
)
return any(phrase in error_message for phrase in BAD_REQUEST_PHRASES)
def _is_topic_required_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed']
)
return any(phrase in error_message for phrase in TOPIC_ERROR_PHRASES)
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
if isinstance(event, CallbackQuery):
user_info = self._get_user_info(event)
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
logger.warning("[GlobalErrorMiddleware] Игнорируем устаревший callback '%s' от %s", event.data, user_info)
else:
logger.warning(f'🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}')
logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос: %s', error)
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
logger.debug(f'📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}')
async def _handle_message_not_modified(
self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]
):
logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено: %s', error)
if isinstance(event, CallbackQuery):
try:
await event.answer()
logger.debug("Успешно ответили на callback после 'message not modified'")
logger.debug("Успешно ответили на callback после 'message not modified'")
except TelegramBadRequest as answer_error:
if not self._is_old_query_error(str(answer_error).lower()):
logger.error(f'Ошибка при ответе на callback: {answer_error}')
logger.warning('Ошибка при ответе на callback: %s', answer_error)
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
error_message = str(error).lower()
if 'bot was blocked' in error_message:
if BOT_BLOCKED_PHRASE in error_message:
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
logger.info(f'🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}')
logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info)
return
if 'user is deactivated' in error_message:
if USER_DEACTIVATED_PHRASE in error_message:
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
logger.info(f'👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}')
logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info)
return
if 'chat not found' in error_message or 'message not found' in error_message:
logger.warning(f'🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}')
if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message:
logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error)
return
logger.error(f'❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}')
user_info = self._get_user_info(event)
logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=%s): %s', user_info, error)
raise error
def _get_user_info(self, event: TelegramObject) -> str:
@@ -130,13 +178,13 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
def _count_error(self, error: TelegramBadRequest):
error_message = str(error).lower()
if 'query is too old' in error_message:
if OLD_QUERY_PHRASES[0] in error_message:
self.error_counts['old_queries'] += 1
elif 'message is not modified' in error_message:
elif MESSAGE_NOT_MODIFIED_PHRASE in error_message:
self.error_counts['message_not_modified'] += 1
elif 'bot was blocked' in error_message:
elif BOT_BLOCKED_PHRASE in error_message:
self.error_counts['bot_blocked'] += 1
elif 'user is deactivated' in error_message:
elif USER_DEACTIVATED_PHRASE in error_message:
self.error_counts['user_deactivated'] += 1
else:
self.error_counts['other_errors'] += 1
@@ -147,3 +195,132 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
def reset_statistics(self):
for key in self.error_counts:
self.error_counts[key] = 0
async def send_error_to_admin_chat(
bot: Bot, error: Exception, context: str = '', tb_override: str | None = None
) -> bool:
"""
Отправляет уведомление об ошибке в админский чат с троттлингом.
Args:
bot: Экземпляр бота
error: Исключение
context: Дополнительный контекст (например, информация о пользователе)
tb_override: Готовый traceback (если вызывается не из except-блока)
Returns:
bool: True если уведомление отправлено
"""
global _last_error_notification
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
return False
error_type = type(error).__name__
error_message = str(error)[:ERROR_MESSAGE_MAX_LENGTH]
tb_str = tb_override or traceback.format_exc()
if tb_str == 'NoneType: None\n' or tb_str == 'NoneType: None':
tb_str = '(no traceback available)'
# Добавляем в буфер
_error_buffer.append((error_type, error_message, tb_str))
if len(_error_buffer) > ERROR_BUFFER_MAX_SIZE:
_error_buffer.pop(0)
# Проверяем троттлинг
now = datetime.utcnow()
if _last_error_notification and (now - _last_error_notification) < _error_notification_cooldown:
logger.debug('Ошибка добавлена в буфер, троттлинг активен: %s', error_type)
return False
_last_error_notification = now
try:
timestamp = format_local_datetime(now, DATETIME_FORMAT)
separator = '=' * REPORT_SEPARATOR_WIDTH
# Формируем лог-файл со всеми ошибками из буфера
log_lines = [
'ERROR REPORT',
separator,
f'Timestamp: {timestamp}',
f'Errors in buffer: {len(_error_buffer)}',
'',
]
for i, (err_type, err_msg, err_tb) in enumerate(_error_buffer):
log_lines.extend(
[
separator,
f'ERROR #{i}: {err_type}',
separator,
f'Message: {err_msg}',
'',
'Traceback:',
err_tb,
'',
]
)
log_content = '\n'.join(log_lines)
# Очищаем буфер после отправки
errors_count = len(_error_buffer)
_error_buffer.clear()
file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt'
file = BufferedInputFile(
file=log_content.encode('utf-8'),
filename=file_name,
)
message_text = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'⚠️ Ошибка во время работы\n\n'
f'<b>Тип:</b> <code>{error_type}</code>\n'
f'<b>Ошибок в отчёте:</b> {errors_count}\n'
)
if context:
message_text += f'<b>Контекст:</b> {context}\n'
# Добавляем рекомендации если есть
recommendations = _get_error_recommendations(error_message)
if recommendations:
message_text += f'\n{recommendations}\n'
message_text += f'\n<i>{timestamp}</i>'
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='💬 Сообщить разработчику',
url=DEVELOPER_CONTACT_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': chat_id,
'document': file,
'caption': message_text,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
}
if topic_id:
message_kwargs['message_thread_id'] = topic_id
await bot.send_document(**message_kwargs)
logger.info('Уведомление об ошибке отправлено в чат %s', chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки уведомления об ошибке: %s', e, extra={'_admin_notified': True})
return False
+298 -140
View File
@@ -288,9 +288,7 @@ class AdminNotificationService:
return False
user_status = '🆕 Новый' if not user.has_had_paid_subscription else '🔄 Существующий'
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
trial_device_limit = subscription.device_limit
@@ -308,33 +306,79 @@ class AdminNotificationService:
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
# Получаем название тарифа (если режим тарифов)
tariff_name = await self._get_tariff_name(db, subscription)
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
message_lines = [
'🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>',
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
f'👥 <b>Статус:</b> {user_status}',
'',
]
{promo_block}
# Промогруппа — только название, без скидок
if promo_group:
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
else:
message_lines.append('🏷️ <b>Промогруппа:</b> —')
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
{payment_block}
# Тариф триала (если есть)
if tariff_name:
message_lines.append(f'📦 <b>Тариф:</b> {tariff_name}')
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
message_lines.append('')
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
message_lines.extend(
[
'⏰ <b>Параметры триала:</b>',
f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней',
f'📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}',
f'📱 Устройства: {trial_device_limit}',
f'🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else "По умолчанию"}',
]
)
return await self._send_message(message)
if payment_block:
message_lines.append(payment_block)
message_lines.append('')
message_lines.append(
f'📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}'
)
# Реферер — только если есть
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 <b>Реферер:</b> {referrer_info}')
message_lines.append('')
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>')
return await self._send_message('\n'.join(message_lines))
except Exception as e:
logger.error(f'Ошибка отправки уведомления о триале: {e}')
return False
async def _get_tariff_name(self, db: AsyncSession, subscription: Subscription) -> str | None:
"""Получает название тарифа подписки, если он есть."""
if not subscription.tariff_id:
return None
try:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
return tariff.name
except Exception:
pass
return None
async def send_subscription_purchase_notification(
self,
db: AsyncSession,
@@ -344,6 +388,7 @@ class AdminNotificationService:
period_days: int,
was_trial_conversion: bool = False,
amount_kopeks: int | None = None,
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
) -> bool:
try:
total_amount = (
@@ -371,93 +416,138 @@ class AdminNotificationService:
if not self._is_enabled():
return False
event_type = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА' if was_trial_conversion else '💎 ПОКУПКА ПОДПИСКИ'
if was_trial_conversion:
user_status = '🎯 Конверсия из триала'
elif user.has_had_paid_subscription:
user_status = '🔄 Продление/Обновление'
# Определяем тип операции и заголовок
if purchase_type == 'tariff_switch':
event_title = '🔄 СМЕНА ТАРИФА'
user_status = 'Смена тарифа'
elif was_trial_conversion:
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
user_status = 'Конверсия'
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
user_status = 'Продление'
else:
user_status = '🆕 Первая покупка'
event_title = '💎 ПОКУПКА ПОДПИСКИ'
user_status = 'Первая покупка'
# Получаем название тарифа
tariff_name = await self._get_tariff_name(db, subscription)
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else 'Баланс'
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
transaction_id = transaction.id if transaction else ''
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
message = f"""💎 <b>{event_type}</b>
# Формируем компактное сообщение
message_lines = [
f'<b>{event_title}</b>',
'',
f'👤 {user_display} ({user_id_display})',
]
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
{promo_block}
message_lines.append(f'📋 {user_status}')
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(total_amount)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction_id}
# Тариф (если есть)
if tariff_name:
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
📱 <b>Параметры подписки:</b>
📅 Период: {period_days} дней
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
message_lines.extend(
[
'',
f'💵 <b>{settings.format_price(total_amount)}</b> • {payment_method}',
f'📅 {period_days} дн. • до {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
f'📊 {self._format_traffic(subscription.traffic_limit_gb)} • 📱 {subscription.device_limit} устр.',
f'🌐 {servers_info}',
]
)
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {referrer_info}
# Баланс после покупки
message_lines.append(f'💰 Баланс: {settings.format_price(user.balance_kopeks)}')
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
# Реферер (только если есть)
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 Реф: {referrer_info}')
return await self._send_message(message)
# ID транзакции (только если есть)
if transaction:
message_lines.append(f'🆔 #{transaction.id}')
message_lines.extend(
[
'',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
]
)
return await self._send_message('\n'.join(message_lines))
except Exception as e:
logger.error(f'Ошибка отправки уведомления о покупке: {e}')
return False
async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool:
"""Отправляет уведомление о новых обновлениях"""
"""Отправляет уведомление о новых обновлениях."""
if not self._is_enabled():
return False
try:
from app.utils.markdown_to_telegram import github_markdown_to_telegram_html, truncate_for_blockquote
repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
release_url = f'https://github.com/{repo}/releases/tag/{latest_version.tag_name}'
repo_url = f'https://github.com/{repo}'
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
if latest_version.prerelease:
update_type = '🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ'
type_icon = '🧪'
header = '🧪 <b>Pre-release</b>'
elif latest_version.is_dev:
update_type = '🔧 DEV ВЕРСИЯ'
type_icon = '🔧'
header = '🔧 <b>Dev build</b>'
else:
update_type = '📦 НОВАЯ ВЕРСИЯ'
type_icon = '📦'
header = '🆕 <b>Доступно обновление</b>'
description = latest_version.short_description
if len(description) > 200:
description = description[:197] + '...'
# -- message prefix (everything before blockquote) --
prefix_lines = [
header,
'',
f'<code>{current_version}</code> → <b><a href="{release_url}">{latest_version.tag_name}</a></b>',
f'📅 {latest_version.formatted_date}',
'',
]
message_prefix = '\n'.join(prefix_lines)
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
# -- message suffix (everything after blockquote) --
suffix_lines = ['']
if total_updates > 1:
suffix_lines.append(f'Доступно обновлений: <b>{total_updates}</b>')
suffix_lines.extend(
[
f'<a href="{repo_url}">Репозиторий</a>',
'',
f'<i>{timestamp}</i>',
]
)
message_suffix = '\n'.join(suffix_lines)
📦 <b>Текущая версия:</b> <code>{current_version}</code>
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
# -- description in blockquote --
raw_description = getattr(latest_version, 'full_description', '') or latest_version.short_description
description_html = github_markdown_to_telegram_html(raw_description)
📝 <b>Описание:</b>
{description}
🔢 <b>Всего доступно обновлений:</b> {total_updates}
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
if description_html:
description_html = truncate_for_blockquote(
description_html,
message_prefix=message_prefix,
message_suffix=message_suffix,
)
message = f'{message_prefix}<blockquote expandable>{description_html}</blockquote>{message_suffix}'
else:
message = f'{message_prefix}{message_suffix}'
return await self._send_message(message)
@@ -500,35 +590,81 @@ class AdminNotificationService:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
user_display = self._get_user_display(user)
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
# --- Основной блок ---
message_lines: list[str] = [
'💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>',
'',
f'👤 {user_display} ({user_id_display})',
]
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
{promo_block}
message_lines.append(f'💳 {topup_status}')
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
# Промогруппа -- только название
if promo_group:
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
💰 <b>Баланс:</b>
📉 Было: {settings.format_price(old_balance)}
📈 Стало: {settings.format_price(user.balance_kopeks)}
Изменение: +{settings.format_price(balance_change)}
message_lines.append('')
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {subscription_status}
# --- Детали пополнения ---
message_lines.extend(
[
f'💵 <b>{settings.format_price(transaction.amount_kopeks)}</b> | {payment_method}',
'',
f'📉 {settings.format_price(old_balance)} → 📈 {settings.format_price(user.balance_kopeks)}'
f' (<b>+{settings.format_price(balance_change)}</b>)',
]
)
<i>{timestamp}</i>"""
# --- Подписка ---
message_lines.append(f'📱 Подписка: {subscription_status}')
# --- Реферер (только если есть) ---
if referrer_info and referrer_info != 'Нет':
message_lines.append(f'🔗 Реферер: {referrer_info}')
# --- Expandable blockquote с техническими деталями ---
detail_lines: list[str] = [
f'ID транзакции: {transaction.id}',
f'Способ оплаты: {transaction.payment_method or "balance"}',
]
if transaction.external_id:
detail_lines.append(f'Внешний ID: {transaction.external_id}')
if transaction.description:
desc = transaction.description
if len(desc) > 120:
desc = desc[:117] + '...'
detail_lines.append(f'Описание: {desc}')
if transaction.created_at:
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
if transaction.completed_at:
detail_lines.append(f'Завершена: {format_local_datetime(transaction.completed_at, "%d.%m.%Y %H:%M:%S")}')
if transaction.receipt_uuid:
detail_lines.append(f'Чек UUID: {transaction.receipt_uuid}')
blockquote_body = '\n'.join(detail_lines)
message_lines.extend(
[
'',
f'<blockquote expandable>{blockquote_body}</blockquote>',
]
)
message_lines.append(f'<i>{timestamp}</i>')
return '\n'.join(message_lines)
async def _reload_topup_notification_entities(
self,
@@ -1081,21 +1217,27 @@ class AdminNotificationService:
return self.enabled and bool(self.chat_id)
def _get_payment_method_display(self, payment_method: str | None) -> str:
mulenpay_name = settings.get_mulenpay_display_name()
method_names = {
if not payment_method:
return '💰 С баланса'
method_names: dict[str, str] = {
'telegram_stars': '⭐ Telegram Stars',
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': f'💳 {mulenpay_name} (карта)',
'pal24': '🏦 PayPalych (СБП)',
'mulenpay': f'💳 {settings.get_mulenpay_display_name()} (карта)',
'pal24': f'🏦 {settings.get_pal24_display_name()} (СБП)',
'cryptobot': f'🪙 {settings.get_cryptobot_display_name()} (крипто)',
'heleket': f'🪙 {settings.get_heleket_display_name()} (крипто)',
'wata': f'💳 {settings.get_wata_display_name()}',
'platega': f'💳 {settings.get_platega_display_name()}',
'cloudpayments': f'💳 {settings.get_cloudpayments_display_name()}',
'freekassa': f'💳 {settings.get_freekassa_display_name()}',
'kassa_ai': f'💳 {settings.get_kassa_ai_display_name()}',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса',
}
if not payment_method:
return '💰 С баланса'
return method_names.get(payment_method, '💰 С баланса')
return method_names.get(payment_method, f'💳 {payment_method}')
def _format_traffic(self, traffic_gb: int) -> str:
if traffic_gb == 0:
@@ -1369,65 +1511,81 @@ class AdminNotificationService:
return False
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
update_types = {
'traffic': ('📊 ИЗМЕНЕНИЕ ТРАФИКА', 'трафик'),
'devices': ('📱 ИЗМЕНЕНИЕ УСТРОЙСТВ', 'количество устройств'),
'servers': ('🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ', 'серверы'),
'modem': ('📡 ИЗМЕНЕНИЕ МОДЕМА', 'модем'),
}
title, param_name = update_types.get(update_type, ('⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ', 'параметры'))
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
# Определяем заголовок по типу операции
update_titles = {
'traffic': '📊 ДОКУПКА ТРАФИКА',
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
'servers': '🌐 СМЕНА СЕРВЕРОВ',
'modem': '📡 МОДЕМ',
}
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
# Получаем название тарифа
tariff_name = await self._get_tariff_name(db, subscription)
# Формируем компактное сообщение
message_lines = [
f'{title}',
f'<b>{title}</b>',
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
'',
promo_block,
'',
'🔧 <b>Изменение:</b>',
f'📋 Параметр: {param_name}',
f'👤 {user_display} ({user_id_display})',
]
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
# Тариф (если есть)
if tariff_name:
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
message_lines.append('')
# Форматируем изменение в зависимости от типа
if update_type == 'servers':
old_servers_info = await self._format_servers_detailed(old_value)
new_servers_info = await self._format_servers_detailed(new_value)
message_lines.extend(
[
f'📉 Было: {old_servers_info}',
f'📈 Стало: {new_servers_info}',
]
)
message_lines.append(f'🔄 {old_servers_info}{new_servers_info}')
elif update_type == 'traffic':
old_formatted = self._format_update_value(old_value, update_type)
new_formatted = self._format_update_value(new_value, update_type)
message_lines.append(f'🔄 {old_formatted}{new_formatted}')
elif update_type == 'devices':
message_lines.append(f'🔄 {old_value}{new_value} устр.')
elif update_type == 'modem':
old_state = '✅ Вкл' if old_value else '❌ Выкл'
new_state = '✅ Вкл' if new_value else '❌ Выкл'
message_lines.append(f'🔄 {old_state}{new_state}')
else:
message_lines.extend(
[
f'📉 Было: {self._format_update_value(old_value, update_type)}',
f'📈 Стало: {self._format_update_value(new_value, update_type)}',
]
)
message_lines.append(f'🔄 {old_value}{new_value}')
# Стоимость операции
if price_paid > 0:
message_lines.append(f'💰 Доплачено: {settings.format_price(price_paid)}')
message_lines.append(f'💵 <b>{settings.format_price(price_paid)}</b>')
else:
message_lines.append('💸 Бесплатно')
message_lines.extend(
[
'',
f'📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}',
f'💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}',
f'🔗 <b>Рефер:</b> {referrer_info}',
f'📅 До {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
f'💰 Баланс: {settings.format_price(user.balance_kopeks)}',
]
)
# Реферер (только если есть)
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 Реф: {referrer_info}')
message_lines.extend(
[
'',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
]
)
+19 -2
View File
@@ -5,6 +5,7 @@
import asyncio
import logging
import time
from datetime import datetime, timedelta
import aiohttp
@@ -27,6 +28,9 @@ class BlacklistService:
interval_hours = self.get_blacklist_update_interval_hours()
self.update_interval = timedelta(hours=interval_hours)
self.lock = asyncio.Lock() # Блокировка для предотвращения одновременных обновлений
# Кэш результатов проверки: {telegram_id: (is_blacklisted, reason, timestamp)}
self._check_cache: dict[int, tuple[bool, str | None, float]] = {}
self._cache_ttl = 300 # 5 минут
def is_blacklist_check_enabled(self) -> bool:
"""Проверяет, включена ли проверка черного списка"""
@@ -117,6 +121,7 @@ class BlacklistService:
self.blacklist_data = blacklist_data
self.last_update = datetime.utcnow()
self._check_cache.clear()
logger.info(f'Черный список успешно обновлен. Найдено {len(blacklist_data)} записей')
return True
@@ -141,9 +146,17 @@ class BlacklistService:
if not self.is_blacklist_check_enabled():
return False, None
# Проверяем кэш
now = time.monotonic()
cached = self._check_cache.get(telegram_id)
if cached is not None:
is_bl, reason, ts = cached
if now - ts < self._cache_ttl:
return is_bl, reason
# Проверяем, является ли пользователь администратором и нужно ли его игнорировать
if self.should_ignore_admins() and self.is_admin(telegram_id):
logger.info(f'Пользователь {telegram_id} является администратором, игнорируем проверку черного списка')
self._check_cache[telegram_id] = (False, None, now)
return False, None
# Если черный список пуст или устарел, обновляем его
@@ -156,17 +169,21 @@ class BlacklistService:
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_id == telegram_id:
logger.info(f'Пользователь {telegram_id} найден в черном списке по ID: {bl_reason}')
self._check_cache[telegram_id] = (True, bl_reason, now)
return True, bl_reason
# Проверяем по username, если он передан
if username:
username_lower = username.lower().lstrip('@')
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username and (bl_username == username or bl_username == f'@{username}'):
if bl_username and bl_username.lower().lstrip('@') == username_lower:
logger.info(
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
)
self._check_cache[telegram_id] = (True, bl_reason, now)
return True, bl_reason
self._check_cache[telegram_id] = (False, None, now)
return False, None
async def get_all_blacklisted_users(self) -> list[tuple[int, str, str]]:
+449
View File
@@ -0,0 +1,449 @@
"""
Сервис для проверки пользователей, заблокировавших бота.
Проверяет возможность отправки сообщений пользователям и позволяет
очистить БД и панель Remnawave от неактивных пользователей.
"""
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaignRegistration,
ButtonClickLog,
CabinetRefreshToken,
CloudPaymentsPayment,
ContestAttempt,
CryptoBotPayment,
DiscountOffer,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PollResponse,
PromoCodeUse,
ReferralContestEvent,
ReferralEarning,
SentNotification,
Subscription,
SubscriptionConversion,
SubscriptionEvent,
SubscriptionServer,
Ticket,
TicketMessage,
TicketNotification,
Transaction,
User,
UserPromoGroup,
UserStatus,
WataPayment,
WheelSpin,
WithdrawalRequest,
YooKassaPayment,
)
from app.services.remnawave_service import RemnaWaveService
logger = logging.getLogger(__name__)
class BlockCheckStatus(Enum):
"""Статус проверки блокировки пользователя."""
BLOCKED = 'blocked'
ACTIVE = 'active'
NO_TELEGRAM_ID = 'no_telegram_id'
ERROR = 'error'
class BlockedUserAction(Enum):
"""Действия над заблокированными пользователями."""
DELETE_FROM_DB = 'delete_from_db'
DELETE_FROM_REMNAWAVE = 'delete_from_remnawave'
DELETE_BOTH = 'delete_both'
MARK_AS_BLOCKED = 'mark_as_blocked'
@dataclass
class BlockCheckResult:
"""Результат проверки одного пользователя."""
user_id: int
telegram_id: int | None
username: str | None
full_name: str
status: BlockCheckStatus
error_message: str | None = None
remnawave_uuid: str | None = None
@dataclass
class BlockedUsersScanResult:
"""Результат сканирования пользователей на блокировку."""
total_checked: int = 0
blocked_users: list[BlockCheckResult] = field(default_factory=list)
active_users: int = 0
errors: int = 0
skipped_no_telegram: int = 0
scan_duration_seconds: float = 0.0
@property
def blocked_count(self) -> int:
return len(self.blocked_users)
@dataclass
class CleanupResult:
"""Результат очистки заблокированных пользователей."""
deleted_from_db: int = 0
deleted_from_remnawave: int = 0
marked_as_blocked: int = 0
errors: list[str] = field(default_factory=list)
class BlockedUsersService:
"""Сервис проверки и очистки заблокированных пользователей."""
# Задержка между проверками для избежания rate limit
CHECK_DELAY_SECONDS: float = 0.05
# Максимальное количество параллельных проверок
MAX_CONCURRENT_CHECKS: int = 10
# Задержка между API запросами к Remnawave (rate limit protection)
API_DELAY_SECONDS: float = 0.15
def __init__(self, bot: Bot):
self.bot = bot
self.remnawave_service = RemnaWaveService()
async def check_user_blocked(self, telegram_id: int) -> BlockCheckStatus:
"""
Проверяет, заблокировал ли пользователь бота.
Отправляет ChatAction.TYPING - это не создает видимого сообщения,
но позволяет определить блокировку.
"""
try:
await self.bot.send_chat_action(chat_id=telegram_id, action='typing')
return BlockCheckStatus.ACTIVE
except TelegramForbiddenError:
# Пользователь заблокировал бота
return BlockCheckStatus.BLOCKED
except TelegramBadRequest as e:
error_lower = str(e).lower()
if 'chat not found' in error_lower or 'user not found' in error_lower:
# Пользователь удалил аккаунт или никогда не начинал диалог
return BlockCheckStatus.BLOCKED
logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except TelegramAPIError as e:
logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except Exception as e:
logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
async def _check_single_user(self, user: User) -> BlockCheckResult:
"""Проверяет одного пользователя."""
if not user.telegram_id:
return BlockCheckResult(
user_id=user.id,
telegram_id=None,
username=user.username,
full_name=user.full_name,
status=BlockCheckStatus.NO_TELEGRAM_ID,
remnawave_uuid=user.remnawave_uuid,
)
status = await self.check_user_blocked(user.telegram_id)
return BlockCheckResult(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
full_name=user.full_name,
status=status,
remnawave_uuid=user.remnawave_uuid,
)
async def scan_all_users(
self,
db: AsyncSession,
*,
only_active: bool = True,
batch_size: int = 100,
progress_callback: Callable | None = None,
) -> BlockedUsersScanResult:
"""
Сканирует всех пользователей на предмет блокировки бота.
Args:
db: Сессия БД
only_active: Проверять только активных пользователей
batch_size: Размер батча для загрузки из БД
progress_callback: Callback для отчета о прогрессе (checked, total)
Returns:
Результат сканирования
"""
start_time = datetime.now(tz=UTC)
result = BlockedUsersScanResult()
# Формируем запрос
query = select(User).options(selectinload(User.subscription))
if only_active:
query = query.where(User.status == UserStatus.ACTIVE.value)
query = query.where(User.telegram_id.isnot(None))
# Получаем всех пользователей
users_result = await db.execute(query)
all_users = users_result.scalars().all()
total_users = len(all_users)
logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота')
# Проверяем пользователей батчами с ограничением параллелизма
semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS)
async def check_with_semaphore(user: User) -> BlockCheckResult:
async with semaphore:
check_result = await self._check_single_user(user)
await asyncio.sleep(self.CHECK_DELAY_SECONDS)
return check_result
checked = 0
for i in range(0, total_users, batch_size):
batch = all_users[i : i + batch_size]
tasks = [check_with_semaphore(user) for user in batch]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for check_result in batch_results:
if isinstance(check_result, Exception):
result.errors += 1
logger.error(f'Ошибка при проверке пользователя: {check_result}')
continue
result.total_checked += 1
if check_result.status == BlockCheckStatus.BLOCKED:
result.blocked_users.append(check_result)
elif check_result.status == BlockCheckStatus.ACTIVE:
result.active_users += 1
elif check_result.status == BlockCheckStatus.NO_TELEGRAM_ID:
result.skipped_no_telegram += 1
else:
result.errors += 1
checked += len(batch)
if progress_callback:
await progress_callback(checked, total_users)
result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds()
logger.info(
f'Сканирование завершено: {result.blocked_count} заблокированных '
f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с'
)
return result
async def delete_user_from_remnawave(self, remnawave_uuid: str) -> bool:
"""Удаляет пользователя из панели Remnawave."""
if not remnawave_uuid:
return False
try:
if not self.remnawave_service.is_configured:
logger.warning('Remnawave API не настроен')
return False
async with self.remnawave_service.get_api_client() as api:
await api.delete_user(remnawave_uuid)
logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave')
return True
except Exception as e:
error_msg = str(e).lower()
if 'not found' in error_msg or '404' in error_msg:
logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave')
return True
logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}')
return False
async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool:
"""
Полностью удаляет пользователя из БД со всеми связанными данными.
"""
try:
# Получаем пользователя
user_result = await db.execute(
select(User).options(selectinload(User.subscription)).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
logger.warning(f'Пользователь {user_id} не найден в БД')
return False
user_display = user.telegram_id or user.email or f'#{user.id}'
# Удаляем связанные записи (порядок важен из-за foreign keys)
# 1. Платежные системы (до транзакций, т.к. ссылаются на них)
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user.id))
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user.id))
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user.id))
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user.id))
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user.id))
await db.execute(delete(WataPayment).where(WataPayment.user_id == user.id))
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user.id))
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user.id))
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user.id))
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user.id))
# 2. Транзакции (после платежей)
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
# 3. Подписки
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user.id))
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user.id))
await db.execute(delete(SubscriptionEvent).where(SubscriptionEvent.user_id == user.id))
# 4. Тикеты (сначала зависимые)
await db.execute(delete(TicketNotification).where(TicketNotification.user_id == user.id))
await db.execute(delete(TicketMessage).where(TicketMessage.user_id == user.id))
await db.execute(delete(Ticket).where(Ticket.user_id == user.id))
# 5. Остальные связи
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id))
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
await db.execute(delete(DiscountOffer).where(DiscountOffer.user_id == user.id))
await db.execute(delete(SentNotification).where(SentNotification.user_id == user.id))
await db.execute(delete(PollResponse).where(PollResponse.user_id == user.id))
await db.execute(delete(ContestAttempt).where(ContestAttempt.user_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referrer_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referral_id == user.id))
await db.execute(
delete(AdvertisingCampaignRegistration).where(AdvertisingCampaignRegistration.user_id == user.id)
)
await db.execute(delete(UserPromoGroup).where(UserPromoGroup.user_id == user.id))
await db.execute(delete(CabinetRefreshToken).where(CabinetRefreshToken.user_id == user.id))
await db.execute(delete(ButtonClickLog).where(ButtonClickLog.user_id == user.id))
await db.execute(delete(WheelSpin).where(WheelSpin.user_id == user.id))
# Обнуляем referred_by_id у рефералов этого пользователя
referrals_query = select(User).where(User.referred_by_id == user.id)
referrals_result = await db.execute(referrals_query)
for referral in referrals_result.scalars().all():
referral.referred_by_id = None
# Удаляем пользователя
await db.delete(user)
await db.commit()
logger.info(f'Пользователь {user_display} полностью удален из БД')
return True
except Exception as e:
logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}')
await db.rollback()
return False
async def mark_user_as_blocked(self, db: AsyncSession, user_id: int) -> bool:
"""Помечает пользователя как заблокированного в БД."""
try:
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
return False
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.now(tz=UTC)
await db.commit()
logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный')
return True
except Exception as e:
logger.error(f'Ошибка пометки пользователя {user_id}: {e}')
await db.rollback()
return False
async def cleanup_blocked_users(
self,
db: AsyncSession,
blocked_users: list[BlockCheckResult],
action: BlockedUserAction,
*,
progress_callback: Callable | None = None,
) -> CleanupResult:
"""
Выполняет очистку заблокированных пользователей.
Args:
db: Сессия БД
blocked_users: Список заблокированных пользователей
action: Действие для выполнения
progress_callback: Callback для отчета о прогрессе
Returns:
Результат очистки
"""
result = CleanupResult()
total = len(blocked_users)
for i, user_result in enumerate(blocked_users):
try:
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
if user_result.remnawave_uuid:
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
if success:
result.deleted_from_remnawave += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
# Задержка для избежания rate limit
await asyncio.sleep(self.API_DELAY_SECONDS)
if action in (BlockedUserAction.DELETE_FROM_DB, BlockedUserAction.DELETE_BOTH):
success = await self.delete_user_from_db(db, user_result.user_id)
if success:
result.deleted_from_db += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из БД')
if action == BlockedUserAction.MARK_AS_BLOCKED:
success = await self.mark_user_as_blocked(db, user_result.user_id)
if success:
result.marked_as_blocked += 1
else:
result.errors.append(f'Ошибка пометки {user_result.telegram_id}')
if progress_callback:
await progress_callback(i + 1, total)
except Exception as e:
error_msg = f'Ошибка обработки {user_result.telegram_id}: {e}'
result.errors.append(error_msg)
logger.error(error_msg)
return result
+562 -148
View File
@@ -4,8 +4,10 @@ import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
@@ -18,12 +20,30 @@ from app.handlers.admin.messages import (
)
if TYPE_CHECKING:
from app.cabinet.services.email_service import EmailService
logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
# =========================================================================
# Telegram rate limits: ~30 msg/sec для бота.
# batch_size=25 + 1 sec delay = ~25 msg/sec с запасом.
# =========================================================================
_TG_BATCH_SIZE = 25
_TG_BATCH_DELAY = 1.0 # секунда между батчами
_TG_MAX_RETRIES = 3 # retry при FloodWait / transient errors
# Прогресс обновляется каждые ~500 сообщений ИЛИ раз в 5 секунд (что наступит раньше)
_PROGRESS_UPDATE_MESSAGES = 500
_PROGRESS_MIN_INTERVAL_SEC = 5.0
# Email broadcast rate limiting: max 8 emails per second
EMAIL_RATE_LIMIT = 8
EMAIL_BATCH_SIZE = 50
@dataclass(slots=True)
@@ -42,6 +62,24 @@ class BroadcastConfig:
initiator_name: str | None = None
@dataclass
class EmailBroadcastConfig:
"""Configuration for email broadcast."""
target: str
email_subject: str
email_html_content: str
initiator_name: str | None = None
@dataclass(slots=True)
class _EmailRecipient:
"""Скалярные данные получателя email (без ORM)."""
email: str
user_name: str
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
@@ -117,7 +155,8 @@ class BroadcastService:
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
recipient_ids: list[int] = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
@@ -125,45 +164,35 @@ class BroadcastService:
logger.error('Запись рассылки %s удалена до запуска', broadcast_id)
return
broadcast.total_count = len(recipients)
broadcast.total_count = len(recipient_ids)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
if not recipient_ids:
logger.info('Рассылка %s: получатели не найдены', broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
logger.info('Запускаем стабильный режим рассылки для %s получателей', len(recipients))
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_resilient_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
else:
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_standard_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
logger.info(
'Рассылка %s: начинаем отправку %d получателям (batch=%d, delay=%.1fs)',
broadcast_id,
len(recipient_ids),
_TG_BATCH_SIZE,
_TG_BATCH_DELAY,
)
sent_count, failed_count, cancelled_during_run = await self._send_batched(
broadcast_id,
recipient_ids,
config,
keyboard,
cancel_event,
)
if cancelled_during_run:
logger.info(
@@ -192,140 +221,125 @@ class BroadcastService:
logger.exception('Критическая ошибка при выполнении рассылки %s: %s', broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async def _fetch_recipients(self, target: str) -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
async with AsyncSessionLocal() as session:
if target.startswith('custom_'):
criteria = target[len('custom_') :]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
users_orm = await get_custom_users(session, criteria)
else:
users_orm = await get_target_users(session, target)
async def _run_standard_broadcast(
# Извлекаем telegram_id сразу, пока сессия жива.
# После выхода из блока ORM-объекты станут detached.
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
async def _send_batched(
self,
broadcast_id: int,
recipients: list,
recipient_ids: list[int],
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Базовый режим рассылки для небольших списков."""
"""
Единый метод рассылки для любого количества получателей.
Батчинг по _TG_BATCH_SIZE сообщений с _TG_BATCH_DELAY задержкой.
Прогресс обновляется каждые _PROGRESS_UPDATE_MESSAGES сообщений.
Глобальная пауза при FloodWait.
"""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
# Глобальная пауза при FloodWait — все корутины ждут
flood_wait_until: float = 0.0
last_progress_update: float = 0.0
last_progress_count: int = 0
async def send_single(telegram_id: int) -> bool:
nonlocal flood_wait_until
for attempt in range(_TG_MAX_RETRIES):
# Глобальная пауза при FloodWait
now = asyncio.get_event_loop().time()
if flood_wait_until > now:
await asyncio.sleep(flood_wait_until - now)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, 'telegram_id', None)
if telegram_id is None:
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
return None
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %s: %s',
except TelegramRetryAfter as e:
wait_seconds = e.retry_after + 1
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
logger.warning(
'FloodWait рассылки %s: Telegram просит %d сек (user=%d, попытка %d/%d)',
broadcast_id,
e.retry_after,
telegram_id,
exc,
attempt + 1,
_TG_MAX_RETRIES,
)
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
skipped_count = 0
for i in range(0, len(recipients), batch_size):
except TelegramBadRequest:
return False
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %d (попытка %d/%d): %s',
broadcast_id,
telegram_id,
attempt + 1,
_TG_MAX_RETRIES,
exc,
)
if attempt < _TG_MAX_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return False
for i in range(0, len(recipient_ids), _TG_BATCH_SIZE):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
batch = recipient_ids[i : i + _TG_BATCH_SIZE]
results = await asyncio.gather(
*[send_single(tid) for tid in batch],
return_exceptions=True,
)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Email-пользователи - пропускаем без ошибки
skipped_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent_count, failed_count, False
async def _run_resilient_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(15)
async def send_single_message(user):
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, 'telegram_id', None)
if telegram_id is None:
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
return None
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %s: %s',
broadcast_id,
telegram_id,
exc,
)
return False
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Email-пользователи - пропускаем без ошибки
pass
else:
if isinstance(result, bool):
if result:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
logger.error('Необработанное исключение в рассылке %s: %s', broadcast_id, result)
# Обновляем прогресс в БД периодически
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0:
now = asyncio.get_event_loop().time()
if (
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
or now - last_progress_update >= _PROGRESS_MIN_INTERVAL_SEC
):
await self._update_progress(broadcast_id, sent_count, failed_count)
last_progress_count = processed
last_progress_update = now
await asyncio.sleep(0.1)
# Задержка между батчами для rate limiting
await asyncio.sleep(_TG_BATCH_DELAY)
return sent_count, failed_count, False
@@ -340,37 +354,36 @@ class BroadcastService:
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
) -> None:
"""
Отправляет одно сообщение.
НЕ ловит исключения TelegramRetryAfter, TelegramForbiddenError и др.
обрабатываются в вызывающем коде (_send_batched).
"""
if not self._bot:
raise RuntimeError('Телеграм-бот не инициализирован')
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == 'photo':
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == 'video':
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == 'document':
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
media_methods = {
'photo': ('photo', self._bot.send_photo),
'video': ('video', self._bot.send_video),
'document': ('document', self._bot.send_document),
}
kwarg_name, send_method = media_methods[config.media.type]
await send_method(
chat_id=telegram_id,
**{kwarg_name: config.media.file_id},
caption=caption,
parse_mode='HTML',
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
parse_mode='HTML',
reply_markup=keyboard,
)
@@ -473,3 +486,404 @@ class BroadcastService:
broadcast_service = BroadcastService()
class EmailBroadcastService:
"""Handles email broadcast execution triggered from the admin web API."""
def __init__(self) -> None:
self._email_service: EmailService | None = None
self._tasks: dict[int, _BroadcastTask] = {}
self._lock = asyncio.Lock()
def set_email_service(self, email_service: EmailService) -> None:
"""Set email service instance."""
self._email_service = email_service
def is_running(self, broadcast_id: int) -> bool:
"""Check if broadcast is currently running."""
task_entry = self._tasks.get(broadcast_id)
return bool(task_entry and not task_entry.task.done())
async def start_broadcast(self, broadcast_id: int, config: EmailBroadcastConfig) -> None:
"""Start email broadcast in background."""
if self._email_service is None:
logger.error('Cannot start email broadcast %s: email service not initialized', broadcast_id)
await self._mark_failed(broadcast_id)
return
if not self._email_service.is_configured():
logger.error('Cannot start email broadcast %s: SMTP not configured', broadcast_id)
await self._mark_failed(broadcast_id)
return
cancel_event = asyncio.Event()
async with self._lock:
if broadcast_id in self._tasks and not self._tasks[broadcast_id].task.done():
logger.warning('Email broadcast %s is already running', broadcast_id)
return
task = asyncio.create_task(
self._run_broadcast(broadcast_id, config, cancel_event),
name=f'email-broadcast-{broadcast_id}',
)
self._tasks[broadcast_id] = _BroadcastTask(task=task, cancel_event=cancel_event)
task.add_done_callback(lambda _: self._tasks.pop(broadcast_id, None))
async def request_stop(self, broadcast_id: int) -> bool:
"""Request to stop a running broadcast."""
async with self._lock:
task_entry = self._tasks.get(broadcast_id)
if not task_entry:
return False
task_entry.cancel_event.set()
return True
async def _run_broadcast(
self,
broadcast_id: int,
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> None:
"""Execute email broadcast."""
sent_count = 0
failed_count = 0
try:
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
# Update status to in_progress
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error('Broadcast record %s not found', broadcast_id)
return
broadcast.status = 'in_progress'
broadcast.sent_count = 0
broadcast.failed_count = 0
await session.commit()
# Fetch email recipients
recipients = await self._fetch_email_recipients(config.target)
# Update total count
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
logger.error('Broadcast record %s deleted before start', broadcast_id)
return
broadcast.total_count = len(recipients)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
logger.info('Email broadcast %s: no recipients found', broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
# Send emails with rate limiting
sent_count, failed_count, was_cancelled = await self._send_emails(
broadcast_id,
recipients,
config,
cancel_event,
)
if was_cancelled:
logger.info('Email broadcast %s was cancelled during execution', broadcast_id)
return
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
except asyncio.CancelledError:
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
raise
except Exception as exc:
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_email_recipients(self, target: str) -> list[_EmailRecipient]:
"""
Загружает получателей email-рассылки.
Возвращает список _EmailRecipient (скалярные данные), а не ORM-объектов,
чтобы избежать detached state при долгих рассылках.
"""
from sqlalchemy import select
from app.database.models import Subscription, SubscriptionStatus, User
async with AsyncSessionLocal() as session:
# Base query: verified email users with active status
base_conditions = [
User.email.isnot(None),
User.email_verified == True,
User.status == 'active',
]
if target == 'all_email':
query = select(User).where(*base_conditions)
elif target == 'email_only':
query = select(User).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
query = select(User).where(
*base_conditions,
User.auth_type == 'telegram',
User.telegram_id.isnot(None),
)
elif target == 'active_email':
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
elif target == 'expired_email':
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
.where(
*base_conditions,
Subscription.status.in_(
[
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
]
),
)
)
else:
logger.warning('Unknown email target filter: %s', target)
return []
# Загружаем батчами и извлекаем скаляры сразу
recipients: list[_EmailRecipient] = []
offset = 0
batch_size = 1000
while True:
result = await session.execute(query.offset(offset).limit(batch_size))
batch = result.scalars().all()
if not batch:
break
for user in batch:
email = user.email
if not email:
continue
# Формируем имя пользователя
user_name = user.username
if not user_name:
user_name = user.first_name or ''
if last_name := user.last_name:
user_name = f'{user_name} {last_name}'.strip()
if not user_name:
user_name = email.split('@')[0]
recipients.append(_EmailRecipient(email=email, user_name=user_name))
offset += batch_size
return recipients
async def _send_emails(
self,
broadcast_id: int,
recipients: list[_EmailRecipient],
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""
Отправляет email-рассылку с rate limiting.
Использует run_in_executor для синхронного SMTP, ограничивая
параллельность семафором EMAIL_RATE_LIMIT.
"""
sent_count = 0
failed_count = 0
last_progress_count = 0
last_progress_time: float = 0.0
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
async def send_single_email(recipient: _EmailRecipient) -> bool | None:
"""Отправляет один email."""
async with semaphore:
if cancel_event.is_set():
return None
html_content = self._render_template(config.email_html_content, recipient)
subject = self._render_template(config.email_subject, recipient)
try:
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None,
self._email_service.send_email,
recipient.email,
subject,
html_content,
)
return success
except Exception as exc:
logger.error(
'Ошибка отправки email рассылки %s на %s: %s',
broadcast_id,
recipient.email,
exc,
)
return False
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + EMAIL_BATCH_SIZE]
tasks = [send_single_email(r) for r in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
elif result is None:
pass # Cancelled or skipped
else:
failed_count += 1
# Обновляем прогресс периодически
processed = sent_count + failed_count
now = asyncio.get_event_loop().time()
if (
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
or now - last_progress_time >= _PROGRESS_MIN_INTERVAL_SEC
or i + EMAIL_BATCH_SIZE >= len(recipients)
):
await self._update_progress(broadcast_id, sent_count, failed_count)
last_progress_count = processed
last_progress_time = now
# Rate limiting: ~8 emails/sec
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
return sent_count, failed_count, False
@staticmethod
def _render_template(template: str, recipient: _EmailRecipient) -> str:
"""Подставляет переменные в шаблон email."""
if not template:
return template
result = template.replace('{{user_name}}', recipient.user_name)
result = result.replace('{{email}}', recipient.email)
return result
async def _mark_finished(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
cancelled: bool,
) -> None:
"""Mark broadcast as finished."""
status = 'cancelled' if cancelled else ('completed' if failed_count == 0 else 'partial')
await self._safe_status_update(broadcast_id, sent_count, failed_count, status=status)
async def _mark_cancelled(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Mark broadcast as cancelled."""
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=True)
async def _mark_failed(
self,
broadcast_id: int,
sent_count: int = 0,
failed_count: int = 0,
) -> None:
"""Mark broadcast as failed."""
await self._safe_status_update(broadcast_id, sent_count, failed_count, status='failed')
async def _update_progress(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
) -> None:
"""Update broadcast progress."""
await self._safe_status_update(
broadcast_id,
sent_count,
failed_count,
status='in_progress',
update_completed_at=False,
)
async def _safe_status_update(
self,
broadcast_id: int,
sent_count: int,
failed_count: int,
*,
status: str,
update_completed_at: bool = True,
) -> None:
"""Safely update broadcast status with retry."""
attempts = 0
while attempts < 2:
try:
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
if not broadcast:
return
broadcast.sent_count = sent_count
broadcast.failed_count = failed_count
broadcast.status = status
if update_completed_at:
broadcast.completed_at = datetime.utcnow()
await session.commit()
return
except InterfaceError as exc:
attempts += 1
logger.warning(
'Connection issue updating email broadcast %s: %s. Retry %s/2',
broadcast_id,
exc,
attempts,
)
await asyncio.sleep(0.2)
except SQLAlchemyError:
logger.exception('Failed to update email broadcast status %s', broadcast_id)
return
email_broadcast_service = EmailBroadcastService()
+109
View File
@@ -0,0 +1,109 @@
"""Service for blocking disposable/temporary email domains."""
import asyncio
import logging
from datetime import UTC, datetime
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
class DisposableEmailService:
"""
Downloads and caches a list of disposable email domains from GitHub.
Domains are stored in a frozenset for O(1) thread-safe lookups.
The list is refreshed every 24 hours via an asyncio background task.
If the download fails, the service falls back to an empty set (no blocking).
"""
DOMAINS_URL = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/domains.txt'
UPDATE_INTERVAL_HOURS = 24
def __init__(self) -> None:
self._domains: frozenset[str] = frozenset()
self._task: asyncio.Task[None] | None = None
self._last_updated: datetime | None = None
self._domain_count: int = 0
async def start(self) -> None:
"""Load domains and start periodic refresh task."""
await self._update_domains()
self._task = asyncio.create_task(self._periodic_loop())
logger.info('DisposableEmailService started (%d domains loaded)', self._domain_count)
async def stop(self) -> None:
"""Cancel periodic refresh task."""
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
logger.info('DisposableEmailService stopped')
async def _update_domains(self) -> None:
"""Fetch domains.txt from GitHub and swap the in-memory set."""
try:
async with aiohttp.ClientSession() as session, session.get(self.DOMAINS_URL) as resp:
if resp.status != 200:
logger.error(
'Failed to fetch disposable domains: HTTP %d',
resp.status,
)
return
text = await resp.text()
domains = frozenset(
line.strip().lower() for line in text.splitlines() if line.strip() and not line.startswith('#')
)
self._domains = domains
self._domain_count = len(domains)
self._last_updated = datetime.now(UTC)
logger.info('Disposable email domains updated: %d domains', self._domain_count)
except Exception:
logger.exception('Error updating disposable email domains')
async def _periodic_loop(self) -> None:
"""Sleep then refresh, repeating forever until cancelled."""
while True:
await asyncio.sleep(self.UPDATE_INTERVAL_HOURS * 3600)
await self._update_domains()
def is_disposable(self, email: str) -> bool:
"""Check if the email uses a disposable domain.
Returns False when the feature is disabled via settings.
"""
if not getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True):
return False
if not self._domains:
return False
try:
domain = email.rsplit('@', 1)[1].lower()
except IndexError:
return False
return domain in self._domains
def get_status(self) -> dict:
"""Return service status for monitoring / health checks."""
return {
'enabled': getattr(settings, 'DISPOSABLE_EMAIL_CHECK_ENABLED', True),
'domain_count': self._domain_count,
'last_updated': self._last_updated.isoformat() if self._last_updated else None,
'running': self._task is not None and not self._task.done(),
}
disposable_email_service = DisposableEmailService()
+2 -2
View File
@@ -246,7 +246,7 @@ class KassaAiService:
}
params['signature'] = self._generate_hmac_signature(params)
logger.debug(f'KassaAI get_order_status: order_id={order_id}')
logger.info(f'KassaAI get_order_status: order_id={order_id}')
try:
async with (
@@ -259,7 +259,7 @@ class KassaAiService:
) as response,
):
text = await response.text()
logger.debug(f'KassaAI get_order_status response: {text}')
logger.info(f'KassaAI get_order_status response: {text}')
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f'KassaAI API connection error: {e}')
+2 -11
View File
@@ -219,17 +219,8 @@ class MaintenanceService:
settings.get_maintenance_retry_attempts(),
)
await self._notify_admins(
f"""Мониторинг технических работ запущен
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
Система будет следить за доступностью API.""",
'info',
)
# Сообщение о запуске мониторинга убрано - теперь используется
# единое стартовое уведомление через StartupNotificationService
return True

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