Compare commits

...

285 Commits

Author SHA1 Message Date
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
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
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
Mikhail b7af25644a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 16:40:25 +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
106 changed files with 10925 additions and 1609 deletions
+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.7.2"
}
+110
View File
@@ -0,0 +1,110 @@
# Changelog
## [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.7.2" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+1 -1
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)** - Для общения, вопросов, предложений
+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)
+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
+4
View File
@@ -17,6 +17,7 @@ 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_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 +27,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 +47,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 +86,7 @@ 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_traffic_router)
# WebSocket route
router.include_router(websocket_router)
+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,
+55
View File
@@ -472,6 +472,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'])
+18 -1
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}')
+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(),
)
+509
View File
@@ -0,0 +1,509 @@
"""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 select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.database.models import Subscription, User
from app.services.remnawave_service import RemnaWaveService
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.traffic import (
ExportCsvRequest,
ExportCsvResponse,
TrafficNodeInfo,
TrafficUsageResponse,
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'})
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
if search_lower:
if search_lower not in (full_name or '').lower() and search_lower not in (username 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,
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 + 'node_<uuid>' for dynamic node columns
is_node_sort = sort_by.startswith('node_') and sort_by[5:] in all_node_uuids
if sort_by not in _SORT_FIELDS and not is_node_sort:
sort_by = 'total_bytes'
items = _build_traffic_items(
user_traffic, user_map, nodes_info, search, sort_by, sort_desc, tariff_filter, status_filter, node_filter
)
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,
)
@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()))
# 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 '',
'Full Name': item.full_name,
'Tariff': item.tariff_name or '',
'Status': item.subscription_status or '',
'Traffic Limit (GB)': item.traffic_limit_gb,
'Devices': item.device_limit,
}
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)')
+411
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import Integer, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.campaign import get_campaign_registration_by_user
from app.database.crud.subscription import (
extend_subscription,
)
@@ -38,9 +39,17 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
DeleteUserRequest,
DeleteUserResponse,
DisableUserRequest,
DisableUserResponse,
FullDeleteUserRequest,
FullDeleteUserResponse,
PanelSyncStatusResponse,
PanelUserInfo,
PeriodPriceInfo,
ResetSubscriptionRequest,
ResetSubscriptionResponse,
ResetTrialRequest,
ResetTrialResponse,
SortByEnum,
SyncFromPanelRequest,
SyncFromPanelResponse,
@@ -60,6 +69,9 @@ from ..schemas.users import (
UserAvailableTariffsResponse,
UserDetailResponse,
UserListItem,
UserNodeUsageItem,
UserNodeUsageResponse,
UserPanelInfoResponse,
UserPromoGroupInfo,
UserReferralInfo,
UsersListResponse,
@@ -517,6 +529,14 @@ async def get_user_detail(
for t in transactions
]
# Get campaign info
campaign_name = None
campaign_id = None
campaign_reg = await get_campaign_registration_by_user(db, user.id)
if campaign_reg and campaign_reg.campaign:
campaign_name = campaign_reg.campaign.name
campaign_id = campaign_reg.campaign.id
return UserDetailResponse(
id=user.id,
telegram_id=user.telegram_id,
@@ -542,6 +562,8 @@ async def get_user_detail(
used_promocodes=user.used_promocodes,
has_had_paid_subscription=user.has_had_paid_subscription,
lifetime_used_traffic_bytes=user.lifetime_used_traffic_bytes or 0,
campaign_name=campaign_name,
campaign_id=campaign_id,
restriction_topup=user.restriction_topup,
restriction_subscription=user.restriction_subscription,
restriction_reason=user.restriction_reason,
@@ -569,6 +591,156 @@ async def get_user_by_telegram(
return await get_user_detail(user.id, admin, db)
# === Panel Info ===
@router.get('/{user_id}/panel-info', response_model=UserPanelInfoResponse)
async def get_user_panel_info(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user panel info from Remnawave (config links, traffic, connection data)."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured or not user.telegram_id:
return UserPanelInfoResponse(found=False)
async with service.get_api_client() as api:
panel_users = await api.get_user_by_telegram_id(user.telegram_id)
if not panel_users:
return UserPanelInfoResponse(found=False)
panel_user = panel_users[0]
# Resolve last connected node name via accessible nodes (lighter than get_all_nodes)
last_node_name = None
last_node_uuid = None
if panel_user.user_traffic and panel_user.user_traffic.last_connected_node_uuid:
last_node_uuid = panel_user.user_traffic.last_connected_node_uuid
try:
accessible = await api.get_user_accessible_nodes(panel_user.uuid)
for node in accessible:
if node.uuid == last_node_uuid:
last_node_name = node.node_name
break
except Exception:
logger.warning(f'Failed to resolve node name for user {user_id}')
return UserPanelInfoResponse(
found=True,
trojan_password=panel_user.trojan_password,
vless_uuid=panel_user.vless_uuid,
ss_password=panel_user.ss_password,
subscription_url=panel_user.subscription_url,
happ_link=panel_user.happ_link,
used_traffic_bytes=panel_user.used_traffic_bytes,
lifetime_used_traffic_bytes=panel_user.lifetime_used_traffic_bytes,
traffic_limit_bytes=panel_user.traffic_limit_bytes,
first_connected_at=panel_user.first_connected_at,
online_at=panel_user.online_at,
last_connected_node_uuid=last_node_uuid,
last_connected_node_name=last_node_name,
)
except Exception as e:
logger.error(f'Error getting panel info for user {user_id}: {e}')
return UserPanelInfoResponse(found=False)
@router.get('/{user_id}/node-usage', response_model=UserNodeUsageResponse)
async def get_user_node_usage(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get user per-node traffic usage (always 30 days with daily breakdown)."""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
if not user.remnawave_uuid:
return UserNodeUsageResponse(items=[])
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
if not service.is_configured:
return UserNodeUsageResponse(items=[])
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=30)
start_str = start_date.strftime('%Y-%m-%d')
end_str = end_date.strftime('%Y-%m-%d')
async with service.get_api_client() as api:
# Get user's accessible nodes (1 API call)
accessible_nodes = await api.get_user_accessible_nodes(user.remnawave_uuid)
# Get user bandwidth stats (1 API call)
# Response: {categories: [dates], series: [{uuid, name, countryCode, total, data: [daily]}, ...]}
stats = await api.get_bandwidth_stats_user(user.remnawave_uuid, start_str, end_str)
categories: list[str] = []
series_map: dict[str, dict] = {}
if isinstance(stats, dict):
categories = stats.get('categories', [])
for s in stats.get('series', []):
series_map[s['uuid']] = {
'name': s.get('name', ''),
'country_code': s.get('countryCode', ''),
'total': int(s.get('total', 0)),
'daily': [int(v) for v in s.get('data', [])],
}
# Build items: accessible nodes + any extra from stats
items = []
seen_uuids: set[str] = set()
for node in accessible_nodes:
seen_uuids.add(node.uuid)
sr = series_map.get(node.uuid)
items.append(
UserNodeUsageItem(
node_uuid=node.uuid,
node_name=sr['name'] if sr else node.node_name,
country_code=sr['country_code'] if sr else node.country_code,
total_bytes=sr['total'] if sr else 0,
daily_bytes=sr['daily'] if sr else [],
)
)
for nid, sr in series_map.items():
if nid not in seen_uuids:
items.append(
UserNodeUsageItem(
node_uuid=nid,
node_name=sr['name'],
country_code=sr['country_code'],
total_bytes=sr['total'],
daily_bytes=sr['daily'],
)
)
items.sort(key=lambda x: x.total_bytes, reverse=True)
return UserNodeUsageResponse(items=items, categories=categories)
except Exception as e:
logger.error(f'Error getting node usage for user {user_id}: {e}')
return UserNodeUsageResponse(items=[])
# === Balance Management ===
@@ -1195,6 +1367,245 @@ async def delete_user(
)
@router.delete('/{user_id}/full', response_model=FullDeleteUserResponse)
async def full_delete_user(
user_id: int,
request: FullDeleteUserRequest = FullDeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Full user deletion - removes from bot database AND Remnawave panel.
Uses UserService.delete_user_account() which handles:
- Deleting/disabling user in Remnawave panel
- Removing all related records (payments, transactions, etc.)
- Removing user from database
"""
from app.services.user_service import UserService
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
panel_error: str | None = None
deleted_from_panel = False
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
return FullDeleteUserResponse(
success=success,
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
deleted_from_bot=success,
deleted_from_panel=deleted_from_panel,
panel_error=panel_error,
)
@router.post('/{user_id}/reset-trial', response_model=ResetTrialResponse)
async def reset_user_trial(
user_id: int,
request: ResetTrialRequest = ResetTrialRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Reset user trial - allows user to activate trial again.
Actions:
- Delete current subscription if exists
- Reset has_used_trial flag to False
- User can now activate a new trial
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deleted = False
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
except Exception as e:
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
# Delete subscription from database
from sqlalchemy import delete
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
return ResetTrialResponse(
success=True,
message='Trial reset successfully. User can now activate a new trial.',
subscription_deleted=subscription_deleted,
has_used_trial_reset=True,
)
@router.post('/{user_id}/reset-subscription', response_model=ResetSubscriptionResponse)
async def reset_user_subscription(
user_id: int,
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Reset user subscription - removes/deactivates subscription.
Actions:
- Delete subscription from bot database
- Optionally deactivate in Remnawave panel
- User will have no active subscription
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deleted = False
panel_deactivated = False
panel_error: str | None = None
if not user.subscription:
return ResetSubscriptionResponse(
success=True,
message='User has no subscription to reset',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
# Delete subscription from database
from sqlalchemy import delete
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
return ResetSubscriptionResponse(
success=True,
message='Subscription reset successfully',
subscription_deleted=subscription_deleted,
panel_deactivated=panel_deactivated,
panel_error=panel_error,
)
@router.post('/{user_id}/disable', response_model=DisableUserResponse)
async def disable_user(
user_id: int,
request: DisableUserRequest = DisableUserRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Disable user - deactivates subscription and blocks access.
Actions:
- Deactivate subscription in bot database
- Deactivate in Remnawave panel
- Block user account
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deactivated = False
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user: {e}')
# Deactivate subscription in bot database
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
subscription_deactivated = True
logger.info(f'Deactivated subscription for user {user_id}')
# Block user account
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
return DisableUserResponse(
success=True,
message='User disabled successfully',
subscription_deactivated=subscription_deactivated,
panel_deactivated=panel_deactivated,
user_blocked=True,
panel_error=panel_error,
)
# === User Referrals ===
+22
View File
@@ -22,6 +22,7 @@ from app.database.crud.user import (
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
@@ -385,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():
@@ -478,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():
@@ -971,6 +986,13 @@ async def request_email_change(
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(
+12 -15
View File
@@ -23,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 (
@@ -341,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',
}
@@ -358,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,
+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
+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."""
+49
View File
@@ -0,0 +1,49 @@
"""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
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 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
+118
View File
@@ -189,9 +189,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 ===
@@ -498,3 +547,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
+68 -2
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
@@ -695,6 +698,23 @@ class Settings(BaseSettings):
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
@@ -1031,8 +1051,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}'
@@ -1309,6 +1330,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,
@@ -2515,6 +2547,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)
+13 -1
View File
@@ -258,6 +258,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 +714,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 +730,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)
+102 -4
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
@@ -407,16 +407,17 @@ 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_id:
# Загружаем тариф явно, чтобы избежать lazy loading
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
@@ -1234,3 +1235,100 @@ async def clear_email_change_pending(db: AsyncSession, user: User) -> None:
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)
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=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}')
+10
View File
@@ -995,6 +995,11 @@ class User(Base):
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)
@@ -1055,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:
+73
View File
@@ -5094,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:
@@ -7045,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'))
@@ -7157,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')
@@ -7288,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("""
@@ -7358,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():
+78 -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)
+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,
)
+169 -1
View File
@@ -1015,6 +1015,10 @@ async def show_virtual_participants(
text=' Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
types.InlineKeyboardButton(
text='🎭 Массовка',
callback_data=f'admin_contest_vp_mass_{contest_id}',
),
],
]
if vps:
@@ -1160,7 +1164,10 @@ async def delete_virtual_participant_handler(
lines.append('Пока нет виртуальных участников.')
rows = [
[types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
[
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:
@@ -1180,6 +1187,164 @@ async def delete_virtual_participant_handler(
)
@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(
@@ -1282,7 +1447,10 @@ def register_handlers(dp: Dispatcher):
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')
+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
+137 -188
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)
@@ -5483,26 +5450,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 +5614,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()
+60 -60
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
@@ -35,7 +35,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 +308,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
@@ -774,12 +776,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 +991,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 +1149,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 +1241,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 +1423,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:
@@ -1725,6 +1698,7 @@ async def required_sub_channel_check(
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,
@@ -1780,16 +1754,31 @@ async def required_sub_channel_check(
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
logger.info(
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
campaign.id,
# Обрабатываем 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:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
' CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
)
await state.set_data(state_data)
@@ -1829,6 +1818,12 @@ 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)
@@ -1911,6 +1906,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:
+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)
+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)
+93 -12
View File
@@ -186,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',
@@ -276,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:
@@ -473,13 +496,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(
@@ -510,7 +561,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:
@@ -521,16 +572,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)
@@ -570,9 +639,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',
'ℹ️ Возврат средств не производится',
@@ -1121,6 +1190,18 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
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'
+4
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'])
+23 -55
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 'Действие ограничено администратором'
@@ -4134,24 +4120,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:
+43 -1
View File
@@ -2242,6 +2242,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 +2275,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 +2409,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 +2421,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 +2454,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 +3000,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 +3034,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}')
+22 -1
View File
@@ -484,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,
@@ -540,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,
@@ -580,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:
+12 -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', '📢 По кампании'),
+15 -18
View File
@@ -1860,17 +1860,8 @@ 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} мес)'
if not packages:
return InlineKeyboardMarkup(
inline_keyboard=[
@@ -1889,21 +1880,23 @@ 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
period_text = ' /мес' if language == 'ru' else ' /mo'
if language == 'ru':
text = f'📊 +{gb} ГБ трафика - {total_price // 100}{period_text}'
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:
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
@@ -1966,7 +1959,11 @@ def get_change_devices_keyboard(
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):
@@ -2065,7 +2062,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',
)
]
)
+3 -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",
@@ -1652,6 +1653,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",
+8 -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",
@@ -1669,6 +1674,7 @@
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Заблокировавшие бота",
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
"CONTEST_PRIZE": "Приз",
"DAYS": "дн. подписки",
+2 -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": "Повернення коштів не здійснюється",
+2 -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":"不退款",
+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
+2 -2
View File
@@ -396,10 +396,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированных пользователей
# НЕ реактивируем подписку заблокированным пользователям
if user.status == UserStatus.BLOCKED.value:
logger.info(
'🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
'🚫 Пропуск реактивации для заблокированного пользователя %s',
telegram_id,
)
return
+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
+201 -199
View File
@@ -7,6 +7,7 @@ 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
@@ -27,8 +28,18 @@ 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
@@ -61,6 +72,14 @@ class EmailBroadcastConfig:
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
@@ -136,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)
@@ -144,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(
@@ -211,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
@@ -359,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,
)
@@ -617,8 +611,13 @@ class EmailBroadcastService:
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:
"""Fetch email recipients based on target filter."""
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
@@ -632,18 +631,15 @@ class EmailBroadcastService:
]
if target == 'all_email':
# All users with verified email
query = select(User).where(*base_conditions)
elif target == 'email_only':
# Only email-registered users (no telegram)
query = select(User).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
# Telegram users who also have email
query = select(User).where(
*base_conditions,
User.auth_type == 'telegram',
@@ -651,7 +647,6 @@ class EmailBroadcastService:
)
elif target == 'active_email':
# Email users with active subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
@@ -662,7 +657,6 @@ class EmailBroadcastService:
)
elif target == 'expired_email':
# Email users with expired subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
@@ -681,8 +675,8 @@ class EmailBroadcastService:
logger.warning('Unknown email target filter: %s', target)
return []
# Load users in batches
users: list = []
# Загружаем батчами и извлекаем скаляры сразу
recipients: list[_EmailRecipient] = []
offset = 0
batch_size = 1000
@@ -693,108 +687,116 @@ class EmailBroadcastService:
if not batch:
break
users.extend(batch)
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 users
return recipients
async def _send_emails(
self,
broadcast_id: int,
recipients: list,
recipients: list[_EmailRecipient],
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Send emails with rate limiting."""
"""
Отправляет 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 for rate limiting (max EMAIL_RATE_LIMIT concurrent sends)
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
async def send_single_email(user) -> bool | None:
"""Send single email with rate limiting."""
async def send_single_email(recipient: _EmailRecipient) -> bool | None:
"""Отправляет один email."""
async with semaphore:
if cancel_event.is_set():
return None
email = getattr(user, 'email', None)
if not email:
return None
# Render template with variables
html_content = self._render_template(config.email_html_content, user)
subject = self._render_template(config.email_subject, user)
html_content = self._render_template(config.email_html_content, recipient)
subject = self._render_template(config.email_subject, recipient)
try:
# Run sync email send in executor to not block event loop
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None,
self._email_service.send_email,
email,
recipient.email,
subject,
html_content,
)
return success
except Exception as exc:
logger.error(
'Error sending email broadcast %s to %s: %s',
'Ошибка отправки email рассылки %s на %s: %s',
broadcast_id,
email,
recipient.email,
exc,
)
return False
# Process in batches
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(user) for user in batch]
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:
# Skipped (cancelled or no email)
pass
pass # Cancelled or skipped
else:
failed_count += 1
# Update progress periodically
# Обновляем прогресс периодически
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0 or i + EMAIL_BATCH_SIZE >= len(recipients):
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 delay between batches (ensure ~8 emails/sec)
# Rate limiting: ~8 emails/sec
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
return sent_count, failed_count, False
def _render_template(self, template: str, user) -> str:
"""Render template with user variables."""
@staticmethod
def _render_template(template: str, recipient: _EmailRecipient) -> str:
"""Подставляет переменные в шаблон email."""
if not template:
return template
# Get user name
user_name = getattr(user, 'username', None)
if not user_name:
user_name = getattr(user, 'first_name', None) or ''
if last_name := getattr(user, 'last_name', None):
user_name = f'{user_name} {last_name}'.strip()
if not user_name:
user_name = getattr(user, 'email', '').split('@')[0] if getattr(user, 'email', None) else 'User'
email = getattr(user, 'email', '') or ''
# Replace template variables
result = template.replace('{{user_name}}', user_name)
result = result.replace('{{email}}', email)
result = template.replace('{{user_name}}', recipient.user_name)
result = result.replace('{{email}}', recipient.email)
return result
async def _mark_finished(
+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
+60 -7
View File
@@ -61,6 +61,7 @@ from app.services.notification_settings_service import NotificationSettingsServi
from app.services.payment_service import PaymentService
from app.services.promo_offer_service import promo_offer_service
from app.services.subscription_service import SubscriptionService
from app.utils.cache import cache
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import apply_percentage_discount
from app.utils.subscription_utils import (
@@ -69,6 +70,10 @@ from app.utils.subscription_utils import (
from app.utils.timezone import format_local_datetime
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
logger = logging.getLogger(__name__)
@@ -966,7 +971,8 @@ class MonitoringService:
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
),
selectinload(Subscription.tariff),
)
.where(
and_(
@@ -980,6 +986,16 @@ class MonitoringService:
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
# Суточные подписки имеют свой собственный механизм продления
# (DailySubscriptionService), глобальный autopay на них не распространяется
if sub.tariff and getattr(sub.tariff, 'is_daily', False):
logger.debug(
'Пропускаем суточную подписку %s (тариф %s) в глобальном autopay',
sub.id,
sub.tariff.name,
)
continue
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= min(sub.autopay_days_before, 3):
autopay_subscriptions.append(sub)
@@ -1056,13 +1072,50 @@ class MonitoringService:
logger.warning(f'💳 Ошибка списания средств для автопродления пользователя {user_identifier}')
else:
failed_count += 1
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Недостаточно средств на балансе',
# Проверяем кулдаун уведомления через Redis, чтобы не спамить
# при каждом срабатывании мониторинга
cooldown_key = f'autopay_insufficient_balance_notified:{user.id}'
should_notify = True
try:
if await cache.exists(cooldown_key):
should_notify = False
logger.debug(
'💳 Пропуск уведомления о недостаточном балансе для пользователя %s — кулдаун активен',
user_identifier,
)
except Exception as redis_err:
# Fallback: если Redis недоступен — отправляем уведомление
logger.warning(
'⚠️ Ошибка проверки кулдауна в Redis для пользователя %s: %s. Отправляем уведомление.',
user_identifier,
redis_err,
)
if should_notify:
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Недостаточно средств на балансе',
)
# Ставим ключ кулдауна после отправки
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'⚠️ Не удалось установить кулдаун в Redis для пользователя %s: %s',
user_identifier,
redis_err,
)
logger.warning(f'💳 Недостаточно средств для автопродления у пользователя {user_identifier}')
if processed_count > 0 or failed_count > 0:
+21 -9
View File
@@ -223,8 +223,8 @@ class CloudPaymentsPaymentMixin:
logger.error('Пользователь не найден: id=%s', payment.user_id)
return False
# Add balance
await add_user_balance(db, user.id, amount_kopeks)
# Add balance (без автоматической транзакции - создадим ниже с external_id)
await add_user_balance(db, user, amount_kopeks, create_transaction=False)
# Create transaction record
from app.database.crud.transaction import create_transaction
@@ -232,7 +232,7 @@ class CloudPaymentsPaymentMixin:
transaction = await create_transaction(
db=db,
user_id=user.id,
type_=TransactionType.DEPOSIT,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
payment_method=PaymentMethod.CLOUDPAYMENTS,
@@ -350,11 +350,17 @@ class CloudPaymentsPaymentMixin:
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from app.bot import bot
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
from app.localization.texts import get_texts
if not bot:
return
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
# Skip email-only users (no telegram_id)
if not user.telegram_id:
@@ -400,10 +406,16 @@ class CloudPaymentsPaymentMixin:
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from app.bot import bot
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
if not bot:
return
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
+95 -2
View File
@@ -69,8 +69,13 @@ class KassaAiPaymentMixin:
)
return None
# Генерируем уникальный order_id
order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}'
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.KASSA_AI_CURRENCY
@@ -487,3 +492,91 @@ class KassaAiPaymentMixin:
except Exception as e:
logger.exception('KassaAI: ошибка проверки статуса: %s', e)
return None
async def get_kassa_ai_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> dict[str, Any] | None:
"""
Проверяет статус платежа KassaAI по локальному ID через API.
Если платёж оплачен автоматически начисляет баланс.
"""
logger.info('KassaAI: checking payment status for id=%s', local_payment_id)
kassa_ai_crud = import_module('app.database.crud.kassa_ai')
payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id)
if not payment:
logger.warning('KassaAI payment not found: id=%s', local_payment_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
if not settings.KASSA_AI_API_KEY:
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
try:
# Запрашиваем статус заказа в KassaAI (api.fk.life)
response = await kassa_ai_service.get_order_status(payment.order_id)
# KassaAI возвращает список заказов (как Freekassa)
orders = response.get('orders', [])
target_order = None
# Ищем наш заказ в списке
for order in orders:
order_key = str(order.get('merchant_order_id') or order.get('paymentId'))
if order_key == str(payment.order_id):
target_order = order
break
if target_order:
# Статус 1 = Оплачен (как в Freekassa)
kai_status = int(target_order.get('status', 0))
if kai_status == 1:
logger.info('KassaAI payment %s confirmed via API', payment.order_id)
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Обновляем статус
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=kai_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking KassaAI payment status: %s', e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
+11
View File
@@ -100,6 +100,17 @@ class WataPaymentMixin:
payment_module = import_module('app.services.payment_service')
# Добавляем идентификатор плательщика (telegram_id или email) в описание
try:
user = await payment_module.get_user_by_id(db, user_id)
if user:
if user.telegram_id:
description = f'{description} | ID: {user.telegram_id}'
elif user.email:
description = f'{description} | {user.email}'
except Exception as error:
logger.debug('Не удалось получить данные пользователя для описания WATA: %s', error)
order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}'
try:
+21
View File
@@ -1328,6 +1328,27 @@ class YooKassaPaymentMixin:
)
return None
# Verify user exists before creating FK-linked record
try:
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.warning(
'Webhook YooKassa %s: user_id=%s не найден в БД, пропускаем восстановление платежа',
yookassa_payment_id,
user_id,
)
return None
except Exception as e:
logger.warning(
'Webhook YooKassa %s: не удалось проверить user_id=%s: %s',
yookassa_payment_id,
user_id,
e,
)
return None
amount_info = event_object.get('amount') or {}
amount_value = amount_info.get('value')
currency = (amount_info.get('currency') or 'RUB').upper()
+64 -28
View File
@@ -164,42 +164,78 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None:
"""Initialize payment method configs if they don't exist yet.
Called on startup to seed defaults from env vars.
Also adds any missing methods that were added after initial setup.
"""
count_result = await db.execute(select(func.count()).select_from(PaymentMethodConfig))
count = count_result.scalar() or 0
# Get existing method IDs
existing_result = await db.execute(select(PaymentMethodConfig.method_id))
existing_method_ids = set(existing_result.scalars().all())
if count > 0:
return # Already initialized
if not existing_method_ids:
# First-time initialization
logger.info('Initializing payment method configurations from env vars...')
defaults = _get_method_defaults()
logger.info('Initializing payment method configurations from env vars...')
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
# Enable all sub-options by default
sub_options = {opt['id']: True for opt in available}
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
await db.commit()
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
return
# Add missing methods (for cases when new methods are added to code)
defaults = _get_method_defaults()
missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids]
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
# Enable all sub-options by default
sub_options = {opt['id']: True for opt in available}
if missing_methods:
logger.info(f'Adding missing payment methods: {missing_methods}')
# Get max sort_order to append new methods at the end
max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order)))
max_order = max_order_result.scalar() or 0
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
for idx, method_id in enumerate(missing_methods, start=max_order + 1):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
sub_options = {opt['id']: True for opt in available}
await db.commit()
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
await db.commit()
logger.info(f'Added {len(missing_methods)} missing payment method(s).')
# ============ CRUD ============
@@ -71,6 +71,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
}
)
@@ -87,6 +88,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# WATA removed - API returns 429 "Use webhook polling is rate-limited".
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
}
)
@@ -955,6 +957,9 @@ async def run_manual_check(
elif method == PaymentMethod.FREEKASSA:
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
elif method == PaymentMethod.KASSA_AI:
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
else:
logger.warning('Manual check requested for unsupported method %s', method)
return None
+51 -29
View File
@@ -123,7 +123,15 @@ async def broadcast_pinned_message(
db: AsyncSession,
pinned_message: PinnedMessage,
) -> tuple[int, int]:
users: list[User] = []
"""
Рассылает закреплённое сообщение всем активным пользователям.
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой рассылки,
чтобы избежать обращения к ORM-объектам после истечения таймаута
соединения с БД.
"""
# Собираем telegram_id всех активных пользователей
recipient_telegram_ids: list[int] = []
offset = 0
batch_size = 5000
@@ -138,27 +146,26 @@ async def broadcast_pinned_message(
if not batch:
break
users.extend(batch)
# Извлекаем только telegram_id, фильтруем email-only пользователей
for user in batch:
if user.telegram_id is not None:
recipient_telegram_ids.append(user.telegram_id)
offset += batch_size
sent_count = 0
failed_count = 0
semaphore = asyncio.Semaphore(3)
async def send_to_user(user: User) -> None:
async def send_to_telegram_id(telegram_id: int) -> None:
nonlocal sent_count, failed_count
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
async with semaphore:
for attempt in range(3):
try:
success = await _send_and_pin_message(
bot,
user.telegram_id,
telegram_id,
pinned_message,
)
if success:
@@ -170,22 +177,22 @@ async def broadcast_pinned_message(
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter for user %s, waiting %s seconds',
user.telegram_id,
telegram_id,
delay,
)
await asyncio.sleep(delay)
except Exception as send_error:
logger.error(
'Ошибка отправки закрепленного сообщения пользователю %s: %s',
user.telegram_id,
telegram_id,
send_error,
)
failed_count += 1
break
for i in range(0, len(users), 30):
batch = users[i : i + 30]
tasks = [send_to_user(user) for user in batch]
for i in range(0, len(recipient_telegram_ids), 30):
batch = recipient_telegram_ids[i : i + 30]
tasks = [send_to_telegram_id(tid) for tid in batch]
await asyncio.gather(*tasks)
await asyncio.sleep(0.05)
@@ -196,11 +203,19 @@ async def unpin_active_pinned_message(
bot: Bot,
db: AsyncSession,
) -> tuple[int, int, bool]:
"""
Открепляет активное сообщение у всех пользователей.
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой операции,
чтобы избежать обращения к ORM-объектам после истечения таймаута
соединения с БД.
"""
pinned_message = await deactivate_active_pinned_message(db)
if not pinned_message:
return 0, 0, False
users: list[User] = []
# Собираем telegram_id всех активных пользователей
recipient_telegram_ids: list[int] = []
offset = 0
batch_size = 5000
@@ -215,24 +230,23 @@ async def unpin_active_pinned_message(
if not batch:
break
users.extend(batch)
# Извлекаем только telegram_id, фильтруем email-only пользователей
for user in batch:
if user.telegram_id is not None:
recipient_telegram_ids.append(user.telegram_id)
offset += batch_size
unpinned_count = 0
failed_count = 0
semaphore = asyncio.Semaphore(5)
async def unpin_for_user(user: User) -> None:
async def unpin_for_telegram_id(telegram_id: int) -> None:
nonlocal unpinned_count, failed_count
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
async with semaphore:
try:
success = await _unpin_message_for_user(bot, user.telegram_id)
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
@@ -241,22 +255,30 @@ async def unpin_active_pinned_message(
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter while unpinning for user %s, waiting %s seconds',
user.telegram_id,
telegram_id,
delay,
)
await asyncio.sleep(delay)
await unpin_for_user(user)
# Повторная попытка после ожидания
try:
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
failed_count += 1
except Exception:
failed_count += 1
except Exception as error:
logger.error(
'Ошибка открепления сообщения у пользователя %s: %s',
user.telegram_id,
telegram_id,
error,
)
failed_count += 1
for i in range(0, len(users), 40):
batch = users[i : i + 40]
tasks = [unpin_for_user(user) for user in batch]
for i in range(0, len(recipient_telegram_ids), 40):
batch = recipient_telegram_ids[i : i + 40]
tasks = [unpin_for_telegram_id(tid) for tid in batch]
await asyncio.gather(*tasks)
await asyncio.sleep(0.05)
+100
View File
@@ -7,6 +7,7 @@ from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.promocode import (
check_user_promocode_usage,
create_promocode_use,
get_active_discount_promocode_for_user,
get_promocode_by_code,
)
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
@@ -300,3 +301,102 @@ class PromoCodeService:
effects.append('ℹ️ У вас уже есть активная подписка')
return '\n'.join(effects) if effects else '✅ Промокод активирован'
async def deactivate_discount_promocode(
self,
db: AsyncSession,
user_id: int,
*,
admin_initiated: bool = False,
) -> dict[str, Any]:
"""
Деактивирует активный промокод на процентную скидку у пользователя.
Действия:
- Сбрасывает promo_offer_discount_percent / source / expires_at на пользователе
- Удаляет запись PromoCodeUse (чтобы промокод мог быть повторно использован, если max_uses > current_uses)
- Декрементирует current_uses на промокоде
- Если промокод назначил промогруппу -- снимает её с пользователя
Args:
db: Сессия БД
user_id: ID пользователя
admin_initiated: True если деактивацию инициировал админ
Returns:
dict с ключами success, error (опционально), deactivated_code (опционально)
"""
try:
user = await get_user_by_id(db, user_id)
if not user:
return {'success': False, 'error': 'user_not_found'}
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
source = getattr(user, 'promo_offer_discount_source', None)
if current_discount <= 0 or not source or not source.startswith('promocode:'):
return {'success': False, 'error': 'no_active_discount_promocode'}
from datetime import datetime
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
# Если скидка уже истекла по времени -- тоже нечего деактивировать
if expires_at is not None and expires_at <= datetime.utcnow():
# Просто зачистим протухшие данные
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
return {'success': False, 'error': 'discount_already_expired'}
promocode, promo_use = await get_active_discount_promocode_for_user(db, user_id)
deactivated_code = source.split(':', 1)[1]
# 1. Сбрасываем скидку на пользователе
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
# 2. Откатываем использование промокода (если нашли запись)
if promocode and promo_use:
await db.delete(promo_use)
if promocode.current_uses > 0:
promocode.current_uses -= 1
promocode.updated_at = datetime.utcnow()
# 3. Если промокод назначал промогруппу -- снимаем её
if promocode.promo_group_id:
from app.database.crud.user_promo_group import (
has_user_promo_group,
remove_user_from_promo_group,
)
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
if has_group:
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
logger.info(
f'Снята промогруппа ID {promocode.promo_group_id} у пользователя '
f'{self._format_user_log(user)} при деактивации промокода {deactivated_code}'
)
await db.commit()
initiator = 'администратором' if admin_initiated else 'пользователем'
logger.info(
f'Промокод {deactivated_code} (скидка {current_discount}%) деактивирован '
f'{initiator} для пользователя {self._format_user_log(user)}'
)
return {
'success': True,
'deactivated_code': deactivated_code,
'discount_percent': current_discount,
}
except Exception as e:
logger.error(f'Ошибка деактивации промокода для пользователя {user_id}: {e}')
await db.rollback()
return {'success': False, 'error': 'server_error'}
File diff suppressed because it is too large Load Diff
+24 -20
View File
@@ -151,19 +151,20 @@ class RemnaWaveService:
elif not api_key:
self._config_error = 'REMNAWAVE_API_KEY не настроен'
self.api: RemnaWaveAPI | None
if self._config_error:
self.api = None
else:
self.api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=auth_params.get('secret_key'),
username=auth_params.get('username'),
password=auth_params.get('password'),
caddy_token=auth_params.get('caddy_token'),
auth_type=auth_params.get('auth_type') or 'api_key',
)
# Сохраняем параметры для создания новых экземпляров API клиента
# (каждый вызов get_api_client создаёт свой экземпляр, чтобы
# параллельные корутины не перезаписывали друг другу aiohttp-сессию)
self._api_kwargs: dict | None = None
if not self._config_error:
self._api_kwargs = {
'base_url': base_url,
'api_key': api_key,
'secret_key': auth_params.get('secret_key'),
'username': auth_params.get('username'),
'password': auth_params.get('password'),
'caddy_token': auth_params.get('caddy_token'),
'auth_type': auth_params.get('auth_type') or 'api_key',
}
@property
def is_configured(self) -> bool:
@@ -174,7 +175,7 @@ class RemnaWaveService:
return self._config_error
def _ensure_configured(self) -> None:
if not self.is_configured or self.api is None:
if not self.is_configured or self._api_kwargs is None:
raise RemnaWaveConfigurationError(self._config_error or 'RemnaWave API не настроен')
def _ensure_user_remnawave_uuid(
@@ -228,8 +229,9 @@ class RemnaWaveService:
@asynccontextmanager
async def get_api_client(self):
self._ensure_configured()
assert self.api is not None
async with self.api as api:
assert self._api_kwargs is not None
api = RemnaWaveAPI(**self._api_kwargs)
async with api:
yield api
def _now_utc(self) -> datetime:
@@ -1439,12 +1441,14 @@ class RemnaWaveService:
# Используем один API клиент для всех операций сброса HWID
hwid_api_client = None
hwid_api_cm = None
try:
hwid_api_client = self.get_api_client()
await hwid_api_client.__aenter__()
hwid_api_cm = self.get_api_client()
hwid_api_client = await hwid_api_cm.__aenter__()
except Exception as api_init_error:
logger.warning(f'⚠️ Не удалось создать API клиент для сброса HWID: {api_init_error}')
hwid_api_client = None
hwid_api_cm = None
try:
for telegram_id, db_user in users_to_deactivate:
@@ -1565,9 +1569,9 @@ class RemnaWaveService:
finally:
# Закрываем API клиент
if hwid_api_client:
if hwid_api_cm:
try:
await hwid_api_client.__aexit__(None, None, None)
await hwid_api_cm.__aexit__(None, None, None)
except Exception:
pass
@@ -0,0 +1,452 @@
"""
Сервис стартового уведомления бота.
Отправляет красивое сообщение с информацией о системе при запуске бота.
"""
import logging
import os
from datetime import datetime
from typing import Final
from aiogram import Bot
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import func, select
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import Subscription, SubscriptionStatus, Ticket, TicketStatus, User, UserStatus
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
# Константы
VERSION_ENV_VAR: Final[str] = 'VERSION'
DEFAULT_VERSION: Final[str] = 'dev'
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
# Форматирование
KOPEKS_IN_RUBLE: Final[int] = 100
MILLION: Final[int] = 1_000_000
THOUSAND: Final[int] = 1_000
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
REPORT_SEPARATOR_WIDTH: Final[int] = 50
# Лимиты сообщений
CRASH_ERROR_MESSAGE_MAX_LENGTH: Final[int] = 1000
CRASH_ERROR_PREVIEW_LENGTH: Final[int] = 200
# URL-ы
GITHUB_BOT_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot'
GITHUB_CABINET_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/bedolaga-cabinet'
COMMUNITY_URL: Final[str] = 'https://t.me/+wTdMtSWq8YdmZmVi'
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
# Ключевые слова для определения типа ошибки
WEBHOOK_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('webhook', 'failed to resolve host')
DATABASE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('database', 'postgres', 'connection refused')
REDIS_ERROR_KEYWORD: Final[str] = 'redis'
REMNAWAVE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('remnawave', 'panel')
AUTH_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('unauthorized', 'bot token')
INLINE_BUTTON_URL_ERROR_KEYWORDS: Final[tuple[str, ...]] = (
'web app url',
'url host is empty',
'unsupported url protocol',
'button url',
)
class StartupNotificationService:
"""Сервис для отправки стартового уведомления в админский чат."""
def __init__(self, bot: Bot) -> None:
self.bot = bot
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
def _get_version(self) -> str:
"""Получает версию из переменной окружения VERSION."""
version = os.getenv(VERSION_ENV_VAR, '').strip()
if version:
return version
return DEFAULT_VERSION
async def _get_users_count(self) -> int:
"""Получает количество активных пользователей в базе."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества пользователей: %s', e)
return 0
async def _get_total_balance(self) -> int:
"""Получает сумму балансов всех пользователей в копейках."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.coalesce(func.sum(User.balance_kopeks), 0)).where(
User.status == UserStatus.ACTIVE.value
)
)
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения суммы балансов: %s', e)
return 0
async def _get_open_tickets_count(self) -> int:
"""Получает количество открытых тикетов."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества открытых тикетов: %s', e)
return 0
async def _get_paid_subscriptions_count(self) -> int:
"""Получает количество платных подписок (не триальных, активных)."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial == False,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества платных подписок: %s', e)
return 0
async def _get_trial_subscriptions_count(self) -> int:
"""Получает количество триальных подписок."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial == True))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества триальных подписок: %s', e)
return 0
async def _check_remnawave_connection(self) -> tuple[bool, str]:
"""
Проверяет соединение с панелью Remnawave.
Returns:
Tuple[bool, str]: (is_connected, status_message)
"""
try:
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get('base_url') or '').strip()
api_key = (auth_params.get('api_key') or '').strip()
if not base_url or not api_key:
return False, 'Не настроен'
secret_key = (auth_params.get('secret_key') or '').strip() or None
username = (auth_params.get('username') or '').strip() or None
password = (auth_params.get('password') or '').strip() or None
caddy_token = (auth_params.get('caddy_token') or '').strip() or None
auth_type = (auth_params.get('auth_type') or DEFAULT_AUTH_TYPE).strip()
api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
caddy_token=caddy_token,
auth_type=auth_type,
)
async with api:
is_connected = await test_api_connection(api)
if is_connected:
return True, 'Подключено'
return False, 'Недоступна'
except Exception as e:
logger.error('Ошибка проверки соединения с Remnawave: %s', e)
return False, 'Ошибка подключения'
def _format_balance(self, kopeks: int) -> str:
"""Форматирует баланс в рублях."""
rubles = kopeks / KOPEKS_IN_RUBLE
if rubles >= MILLION:
return f'{rubles / MILLION:.2f}M RUB'
if rubles >= THOUSAND:
return f'{rubles / THOUSAND:.1f}K RUB'
return f'{rubles:.2f} RUB'
async def send_startup_notification(self) -> bool:
"""
Отправляет стартовое уведомление в админский чат.
Returns:
bool: True если сообщение отправлено успешно
"""
if not self.enabled or not self.chat_id:
logger.debug('Стартовое уведомление отключено или chat_id не задан')
return False
try:
version = self._get_version()
users_count = await self._get_users_count()
total_balance_kopeks = await self._get_total_balance()
open_tickets_count = await self._get_open_tickets_count()
paid_subscriptions_count = await self._get_paid_subscriptions_count()
trial_subscriptions_count = await self._get_trial_subscriptions_count()
remnawave_connected, remnawave_status = await self._check_remnawave_connection()
# Иконка статуса Remnawave
remnawave_icon = '🟢' if remnawave_connected else '🔴'
# Формируем системную информацию для blockquote
system_info_lines = [
f'Версия: {version}',
f'Пользователей: {users_count:,}'.replace(',', ' '),
f'Сумма балансов: {self._format_balance(total_balance_kopeks)}',
f'Платных подписок: {paid_subscriptions_count:,}'.replace(',', ' '),
f'Триальных подписок: {trial_subscriptions_count:,}'.replace(',', ' '),
f'Открытых тикетов: {open_tickets_count:,}'.replace(',', ' '),
f'{remnawave_icon} Remnawave: {remnawave_status}',
]
system_info = '\n'.join(system_info_lines)
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
message = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'✅ Бот успешно запущен\n\n'
f'<blockquote expandable>{system_info}</blockquote>\n\n'
f'<i>{timestamp}</i>'
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='Поставить звезду',
url=GITHUB_BOT_URL,
),
],
[
InlineKeyboardButton(
text='Вебкабинет',
url=GITHUB_CABINET_URL,
),
],
[
InlineKeyboardButton(
text='Сообщество',
url=COMMUNITY_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': self.chat_id,
'text': message,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
'disable_web_page_preview': True,
}
if self.topic_id:
message_kwargs['message_thread_id'] = self.topic_id
await self.bot.send_message(**message_kwargs)
logger.info('Стартовое уведомление отправлено в чат %s', self.chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки стартового уведомления: %s', e)
return False
async def send_bot_startup_notification(bot: Bot) -> bool:
"""
Удобная функция для отправки стартового уведомления.
Args:
bot: Экземпляр бота aiogram
Returns:
bool: True если уведомление отправлено успешно
"""
service = StartupNotificationService(bot)
return await service.send_startup_notification()
def _get_error_recommendations(error_message: str) -> str | None:
"""
Возвращает рекомендации по исправлению ошибки на основе текста ошибки.
Args:
error_message: Текст ошибки
Returns:
Рекомендации в формате HTML blockquote или None
"""
error_lower = error_message.lower()
# Ошибки вебхука
if any(keyword in error_lower for keyword in WEBHOOK_ERROR_KEYWORDS):
tips = [
'• Проверьте WEBHOOK_HOST в .env',
'• Убедитесь что домен доступен извне',
'• Проверьте SSL сертификат (должен быть валидный)',
'• Проверьте reverse proxy (nginx/caddy)',
'• Проверьте сеть Docker (docker network)',
'• Попробуйте: docker compose restart',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки подключения к БД
if any(keyword in error_lower for keyword in DATABASE_ERROR_KEYWORDS):
tips = [
'• Проверьте что PostgreSQL запущен',
'• Проверьте DATABASE_URL в .env',
'• Проверьте сеть Docker между контейнерами',
'• Попробуйте: docker compose restart db',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Redis
if REDIS_ERROR_KEYWORD in error_lower:
tips = [
'• Проверьте что Redis запущен',
'• Проверьте REDIS_URL в .env',
'• Попробуйте: docker compose restart redis',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Remnawave API
if any(keyword in error_lower for keyword in REMNAWAVE_ERROR_KEYWORDS):
tips = [
'• Проверьте REMNAWAVE_API_URL в .env',
'• Проверьте REMNAWAVE_API_KEY',
'• Убедитесь что панель Remnawave доступна',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки токена бота
if any(keyword in error_lower for keyword in AUTH_ERROR_KEYWORDS):
tips = [
'• Проверьте BOT_TOKEN в .env',
'• Убедитесь что токен актуален (@BotFather)',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки inline-кнопок с URL (WebApp, кастомные протоколы)
if any(keyword in error_lower for keyword in INLINE_BUTTON_URL_ERROR_KEYWORDS):
tips = [
'• Проверьте MINIAPP_CUSTOM_URL в .env',
'• Проверьте HAPP_CRYPTOLINK_REDIRECT_TEMPLATE',
'• Telegram не поддерживает кастомные схемы (happ://, v2ray://, ss://, и т.д.) в inline-кнопках',
'• Используйте HTTPS редирект для диплинков',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
return None
async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str) -> bool:
"""
Отправляет уведомление о падении бота с лог-файлом.
Args:
bot: Экземпляр бота aiogram
error: Исключение, вызвавшее падение
traceback_str: Строка с полным traceback
Returns:
bool: True если уведомление отправлено успешно
"""
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:
logger.debug('Уведомление о падении отключено или chat_id не задан')
return False
try:
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
error_type = type(error).__name__
error_message = str(error)[:CRASH_ERROR_MESSAGE_MAX_LENGTH]
separator = '=' * REPORT_SEPARATOR_WIDTH
# Формируем содержимое лог-файла
log_content = (
f'CRASH REPORT\n'
f'{separator}\n\n'
f'Timestamp: {timestamp}\n'
f'Error Type: {error_type}\n'
f'Error Message: {error_message}\n\n'
f'{separator}\n'
f'TRACEBACK\n'
f'{separator}\n\n'
f'{traceback_str}\n'
)
# Создаем файл для отправки
file_name = f'crash_report_{datetime.utcnow().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> <code>{error_message[:CRASH_ERROR_PREVIEW_LENGTH]}</code>\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)
return False
+6
View File
@@ -465,6 +465,12 @@ class BotConfigurationService:
ChoiceOption('ERROR', '❌ Error'),
ChoiceOption('CRITICAL', '🔥 Critical'),
],
'TRIAL_DISABLED_FOR': [
ChoiceOption('none', '✅ Включён для всех'),
ChoiceOption('email', '📧 Отключён для Email'),
ChoiceOption('telegram', '📱 Отключён для Telegram'),
ChoiceOption('all', '🚫 Отключён для всех'),
],
}
SETTING_HINTS: dict[str, dict[str, str]] = {
+2 -2
View File
@@ -198,7 +198,7 @@ class TributeService:
break
except Exception as e:
logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
async def _handle_failed_payment(self, payment_data: dict[str, Any]):
try:
@@ -466,7 +466,7 @@ class TributeService:
return True
except Exception as e:
logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True)
logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True)
return False
async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None:
+121 -12
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from typing import Any
from aiogram import Bot, types
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -30,9 +30,11 @@ from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
BroadcastHistory,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
@@ -345,6 +347,72 @@ class UserService:
'total_count': 0,
}
async def get_potential_customers(
self,
db: AsyncSession,
min_balance_kopeks: int,
page: int = 1,
limit: int = 10,
) -> dict[str, Any]:
"""Возвращает пользователей без активной подписки с достаточным балансом."""
try:
offset = (page - 1) * limit
# Фильтры: нет активной подписки И баланс >= порога
base_filters = [
User.balance_kopeks >= min_balance_kopeks,
]
# Основной запрос с LEFT JOIN для поддержки пользователей без подписки
query = (
select(User)
.options(selectinload(User.subscription))
.outerjoin(Subscription, Subscription.user_id == User.id)
.where(
*base_filters,
or_(
User.subscription == None,
~Subscription.status.in_(['active', 'trial']),
),
)
.order_by(User.balance_kopeks.desc(), User.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
users = result.scalars().unique().all()
# Запрос для подсчета общего количества
count_query = (
select(func.count(User.id))
.outerjoin(Subscription, Subscription.user_id == User.id)
.where(
*base_filters,
or_(
User.subscription == None,
~Subscription.status.in_(['active', 'trial']),
),
)
)
total_count = (await db.execute(count_query)).scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 0
return {
'users': users,
'current_page': page,
'total_pages': total_pages,
'total_count': total_count,
}
except Exception as e:
logger.error(f'Ошибка получения потенциальных клиентов: {e}')
return {
'users': [],
'current_page': 1,
'total_pages': 1,
'total_count': 0,
}
async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]:
try:
return await get_users_spending_stats(db, user_ids)
@@ -891,6 +959,58 @@ class UserService:
except Exception as e:
logger.error(f'❌ Ошибка удаления Freekassa платежей: {e}')
# Удаляем Wata платежи (до транзакций, т.к. wata_payments.transaction_id -> transactions.id)
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
await db.execute(
update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
# Удаляем CloudPayments платежи
try:
cloudpayments_result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)
)
cloudpayments_payments = cloudpayments_result.scalars().all()
if cloudpayments_payments:
logger.info(f'🔄 Удаляем {len(cloudpayments_payments)} CloudPayments платежей')
await db.execute(
update(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления CloudPayments платежей: {e}')
# Удаляем KassaAi платежи
try:
kassa_ai_result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
kassa_ai_payments = kassa_ai_result.scalars().all()
if kassa_ai_payments:
logger.info(f'🔄 Удаляем {len(kassa_ai_payments)} KassaAi платежей')
await db.execute(
update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления KassaAi платежей: {e}')
try:
transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id))
transactions = transactions_result.scalars().all()
@@ -990,17 +1110,6 @@ class UserService:
except Exception as e:
logger.error(f'❌ Ошибка удаления подписки: {e}')
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
try:
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
+7
View File
@@ -51,6 +51,13 @@ class VersionInfo:
return description
@property
def full_description(self) -> str:
"""Full release body text, stripped of leading/trailing whitespace."""
if not self.body:
return ''
return self.body.strip()
class VersionService:
def __init__(self, bot=None):
+8 -4
View File
@@ -118,6 +118,9 @@ class AdminStates(StatesGroup):
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
# Массовое создание виртуальных участников (массовка)
adding_mass_virtual_count = State() # Сколько призраков создать
adding_mass_virtual_referrals = State() # По сколько рефералов у каждого
editing_daily_contest_field = State()
editing_daily_contest_value = State()
@@ -132,6 +135,10 @@ class AdminStates(StatesGroup):
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
# Диагностика рефералов
referral_diagnostics_period = State()
waiting_for_log_file = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
@@ -173,12 +180,9 @@ class AdminStates(StatesGroup):
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
viewing_user_from_traffic_list = State()
viewing_user_from_last_activity_list = State()
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
viewing_user_from_potential_customers_list = State()
# Состояния для управления тарифами
creating_tariff_name = State()
+11 -9
View File
@@ -89,15 +89,16 @@ def error_handler(func: Callable) -> Callable:
await event.answer()
except TelegramBadRequest as answer_error:
if 'query is too old' not in str(answer_error).lower():
logger.error(f'Ошибка при ответе на callback: {answer_error}')
logger.error(f'Ошибка при ответе на callback в {func.__name__}: {answer_error}')
return None
logger.error(f'Telegram API error в {func.__name__}: {e}')
await _send_error_message(args, kwargs, e)
# Уведомление отправляется в _send_error_message
await _send_error_message(args, kwargs, e, func.__name__)
except Exception as e:
logger.error(f'Ошибка в {func.__name__}: {e}', exc_info=True)
await _send_error_message(args, kwargs, e)
await _send_error_message(args, kwargs, e, func.__name__)
return wrapper
@@ -109,11 +110,12 @@ def _extract_event(args) -> types.TelegramObject:
return None
async def _send_error_message(args, kwargs, original_error):
try:
event = _extract_event(args)
db_user = kwargs.get('db_user')
async def _send_error_message(args, kwargs, original_error, func_name: str = 'unknown'):
event = _extract_event(args)
db_user = kwargs.get('db_user')
# Отправляем сообщение пользователю
try:
if not event:
return
@@ -128,9 +130,9 @@ async def _send_error_message(args, kwargs, original_error):
if 'query is too old' in str(e).lower():
logger.warning('Не удалось отправить сообщение об ошибке - callback query устарел')
else:
logger.error(f'Ошибка при отправке сообщения об ошибке: {e}')
logger.warning(f'Ошибка при отправке сообщения об ошибке: {e}')
except Exception as e:
logger.error(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
logger.warning(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
def state_cleanup(func: Callable) -> Callable:
+237
View File
@@ -0,0 +1,237 @@
"""Converts GitHub-flavored Markdown to Telegram-compatible HTML.
Telegram supports a limited subset of HTML tags:
<b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>, <tg-spoiler>.
This module strips everything else and maps common Markdown constructs
to the supported tags.
"""
import re
# HTML tags that Telegram Bot API supports (case-insensitive tag names)
_ALLOWED_TAGS: frozenset[str] = frozenset(
{
'b',
'strong',
'i',
'em',
'u',
'ins',
's',
'strike',
'del',
'code',
'pre',
'a',
'blockquote',
'tg-spoiler',
'tg-emoji',
}
)
# Regex to match any HTML tag (opening, closing, or self-closing)
_HTML_TAG_RE: re.Pattern[str] = re.compile(r'<(/?)(\w[\w-]*)((?:\s+[^>]*)?)(/?)>', re.IGNORECASE)
def _strip_unsupported_html(text: str) -> str:
"""Remove HTML tags that Telegram does not support, keeping only allowed ones."""
def _replace_tag(match: re.Match[str]) -> str:
tag_name = match.group(2).lower()
if tag_name in _ALLOWED_TAGS:
return match.group(0)
return ''
return _HTML_TAG_RE.sub(_replace_tag, text)
def _escape_html(text: str) -> str:
"""Escape characters that conflict with Telegram HTML parsing.
Only escapes `&`, `<`, `>` that are NOT already part of allowed HTML tags.
We run this BEFORE markdown conversion so markdown symbols are still intact.
"""
# Escape ampersands that are not already HTML entities
text = re.sub(r'&(?!amp;|lt;|gt;|quot;|#\d+;)', '&amp;', text)
return text
def github_markdown_to_telegram_html(text: str) -> str:
"""Convert GitHub-flavored Markdown to Telegram HTML.
Handles:
- ``## Header`` -> ``<b>Header</b>``
- ``**bold**`` / ``__bold__`` -> ``<b>bold</b>``
- ``*italic*`` / ``_italic_`` -> ``<i>italic</i>``
- `` `code` `` -> ``<code>code</code>``
- ``- item`` / ``* item`` -> ``bullet item``
- ``[text](url)`` -> ``<a href="url">text</a>``
- Strips unsupported HTML tags
"""
if not text:
return ''
# Escape HTML-sensitive chars first (but preserve existing tags for later stripping)
# We do a targeted escape: only bare < > that are NOT part of tags
result = text
# --- Code blocks (``` ... ```) -- protect from further processing ---
code_blocks: list[str] = []
def _save_code_block(match: re.Match[str]) -> str:
lang = match.group(1) or ''
code = match.group(2)
# Escape HTML inside code
code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
placeholder = f'\x00CODEBLOCK{len(code_blocks)}\x00'
if lang:
code_blocks.append(f'<pre><code class="language-{lang}">{code}</code></pre>')
else:
code_blocks.append(f'<pre>{code}</pre>')
return placeholder
result = re.sub(r'```(\w+)?\n(.*?)```', _save_code_block, result, flags=re.DOTALL)
# --- Inline code (`...`) -- protect from further processing ---
inline_codes: list[str] = []
def _save_inline_code(match: re.Match[str]) -> str:
code = match.group(1)
code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
placeholder = f'\x00INLINECODE{len(inline_codes)}\x00'
inline_codes.append(f'<code>{code}</code>')
return placeholder
result = re.sub(r'`([^`]+)`', _save_inline_code, result)
# --- Escape remaining bare HTML entities ---
result = _escape_html(result)
# --- Headers: ## Header -> <b>Header</b> ---
result = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', result, flags=re.MULTILINE)
# --- Bold: **text** or __text__ -> <b>text</b> ---
result = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', result)
result = re.sub(r'__(.+?)__', r'<b>\1</b>', result)
# --- Italic: *text* or _text_ -> <i>text</i> ---
# Negative lookbehind/lookahead to avoid matching inside words with underscores
result = re.sub(r'(?<!\w)\*([^*]+?)\*(?!\w)', r'<i>\1</i>', result)
result = re.sub(r'(?<!\w)_([^_]+?)_(?!\w)', r'<i>\1</i>', result)
# --- Strikethrough: ~~text~~ -> <s>text</s> ---
result = re.sub(r'~~(.+?)~~', r'<s>\1</s>', result)
# --- Links: [text](url) -> <a href="url">text</a> ---
result = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', result)
# --- Unordered lists: - item or * item -> bullet ---
_BULLET = '\u2022'
result = re.sub(r'^[\s]*[-*]\s+', f' {_BULLET} ', result, flags=re.MULTILINE)
# --- Horizontal rules: --- or *** or ___ ---
result = re.sub(r'^[-*_]{3,}\s*$', '', result, flags=re.MULTILINE)
# --- Images: ![alt](url) -> just alt text ---
result = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', result)
# --- Strip unsupported HTML tags ---
result = _strip_unsupported_html(result)
# --- Restore code blocks ---
for i, block in enumerate(code_blocks):
result = result.replace(f'\x00CODEBLOCK{i}\x00', block)
for i, code in enumerate(inline_codes):
result = result.replace(f'\x00INLINECODE{i}\x00', code)
# --- Clean up excessive blank lines (max 2 consecutive) ---
result = re.sub(r'\n{3,}', '\n\n', result)
return result.strip()
def _close_open_tags(html: str) -> str:
"""Find unclosed HTML tags and append closing tags in reverse order."""
open_tags: list[str] = []
for match in _HTML_TAG_RE.finditer(html):
is_closing = match.group(1) == '/'
is_self_closing = match.group(4) == '/'
tag_name = match.group(2).lower()
if is_self_closing:
continue
if is_closing:
if open_tags and open_tags[-1] == tag_name:
open_tags.pop()
else:
open_tags.append(tag_name)
# Close remaining open tags in reverse order
for tag in reversed(open_tags):
html += f'</{tag}>'
return html
def truncate_for_blockquote(
description_html: str,
*,
message_prefix: str,
message_suffix: str,
max_message_length: int = 4096,
ellipsis: str = '...',
) -> str:
"""Truncate description HTML to fit within Telegram message limit inside a blockquote.
Calculates available space by subtracting prefix/suffix lengths and blockquote
tag overhead from the total message limit.
Args:
description_html: The already-converted HTML description.
message_prefix: Everything before the blockquote in the message.
message_suffix: Everything after the blockquote in the message.
max_message_length: Telegram message character limit (default 4096).
ellipsis: String to append when truncating.
Returns:
The (possibly truncated) description HTML ready to be placed inside
``<blockquote expandable>...</blockquote>``.
"""
blockquote_open = '<blockquote expandable>'
blockquote_close = '</blockquote>'
overhead = len(blockquote_open) + len(blockquote_close)
available = max_message_length - len(message_prefix) - len(message_suffix) - overhead
# Leave a small safety margin for any off-by-one with Telegram entity counting
available -= 20
if available <= 0:
return ellipsis
if len(description_html) <= available:
return description_html
# Reserve space for ellipsis, then iteratively truncate until
# the result (with closing tags) fits within the budget.
budget = available - len(ellipsis)
truncated = description_html[:budget]
# If we broke an HTML tag, backtrack to before it
last_open = truncated.rfind('<')
last_close = truncated.rfind('>')
if last_open > last_close:
truncated = truncated[:last_open]
# Close any unclosed HTML tags to avoid Telegram parse errors
closed = _close_open_tags(truncated)
# If closing tags pushed us over budget, trim more text
while len(closed) + len(ellipsis) > available and len(truncated) > 0:
truncated = truncated[:-20] if len(truncated) > 20 else ''
last_open = truncated.rfind('<')
last_close = truncated.rfind('>')
if last_open > last_close:
truncated = truncated[:last_open]
closed = _close_open_tags(truncated)
return closed.rstrip() + ellipsis
+18
View File
@@ -3047,6 +3047,9 @@ def _is_trial_available_for_user(user: User) -> bool:
if settings.TRIAL_DURATION_DAYS <= 0:
return False
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
return False
if getattr(user, 'has_had_paid_subscription', False):
return False
@@ -3698,7 +3701,22 @@ async def update_subscription_autopay_endpoint(
subscription = _ensure_paid_subscription(user)
_validate_subscription_id(payload.subscription_id, subscription)
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
target_enabled = bool(payload.enabled) if payload.enabled is not None else bool(subscription.autopay_enabled)
if target_enabled:
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
'code': 'autopay_not_available_for_daily',
'message': 'Autopay is not available for daily subscriptions',
},
)
requested_days = payload.days_before
normalized_days = _normalize_autopay_days(requested_days)
+181 -102
View File
@@ -50,7 +50,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error('%s secret key is not configured', display_name)
logger.warning('%s secret key is not configured', display_name)
return False
signature = _extract_header(
@@ -95,7 +95,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')):
return True
logger.error('Неверная подпись %s webhook', display_name)
logger.warning('Неверная подпись %s webhook', display_name)
return False
authorization_header = request.headers.get('Authorization')
@@ -107,7 +107,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if scheme_lower in {'bearer', 'token'}:
if hmac.compare_digest(token, secret_key):
return True
logger.error('Неверный %s токен %s webhook', scheme, display_name)
logger.warning('Неверный %s токен %s webhook', scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
@@ -124,7 +124,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.error('Отсутствует подпись %s webhook', display_name)
logger.warning('Отсутствует подпись %s webhook', display_name)
return False
@@ -217,14 +217,23 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({'status': 'ok', 'result': result})
try:
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({'status': 'ok', 'result': result})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
error = ValueError('Tribute webhook processing returned empty result')
logger.error('Tribute webhook processing failed: %s', error)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Tribute webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -256,18 +265,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_mulenpay_callback',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_mulenpay_callback',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
logger.error('MulenPay webhook processing failed: %s', payload)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('MulenPay webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -311,18 +328,29 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_cryptobot_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_cryptobot_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
logger.error(
'CryptoBot webhook processing failed: invoice_id=%s',
payload.get('payload', {}).get('invoice_id'),
)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('CryptoBot webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -416,18 +444,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
}:
return JSONResponse({'status': 'ok', 'ignored': event_type})
success = await _process_payment_service_callback(
payment_service,
webhook_data,
'process_yookassa_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
webhook_data,
'process_yookassa_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
payment_id = webhook_data.get('object', {}).get('id', 'unknown')
logger.error('YooKassa webhook processing failed: payment_id=%s', payment_id)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception as e:
logger.exception('YooKassa webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
routes_registered = True
@@ -478,18 +515,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_wata_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_wata_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
order_id = payload.get('orderId') or payload.get('order_id') or 'unknown'
logger.error('Wata webhook processing failed: order_id=%s, payload=%s', order_id, payload)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Wata webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -533,18 +579,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_heleket_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_heleket_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
uuid_val = payload.get('uuid', 'unknown')
logger.error('Heleket webhook processing failed: uuid=%s', uuid_val)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Heleket webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -595,18 +650,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
'process_pal24_callback',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
'process_pal24_callback',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
bill_id = parsed_payload.get('bill_id', 'unknown')
logger.error('Pal24 webhook processing failed: bill_id=%s', bill_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Pal24 webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -640,18 +704,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_platega_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_platega_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
transaction_id = payload.get('transactionId', 'unknown')
logger.error('Platega webhook processing failed: transactionId=%s', transaction_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Platega webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -902,8 +975,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error('Freekassa webhook: не удалось прочитать данные формы')
except Exception as form_error:
logger.error('Freekassa webhook: не удалось прочитать данные формы: %s', form_error)
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры
@@ -945,17 +1018,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
cur_id=cur_id_int,
client_ip=client_ip,
)
if success:
return Response('YES', status_code=status.HTTP_200_OK)
logger.error('Freekassa webhook processing failed: order_id=%s, intid=%s', order_id, intid)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.exception('Freekassa webhook processing error: %s', e)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response('YES', status_code=status.HTTP_200_OK)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
# KassaAI webhook
@@ -976,8 +1052,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error('KassaAI webhook: не удалось прочитать данные формы')
except Exception as form_error:
logger.error('KassaAI webhook: не удалось прочитать данные формы: %s', form_error)
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры (те же что и у Freekassa)
@@ -1017,17 +1093,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
intid=intid,
cur_id=cur_id_int,
)
if success:
return Response('YES', status_code=status.HTTP_200_OK)
logger.error('KassaAI webhook processing failed: order_id=%s, intid=%s', order_id, intid)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.exception('KassaAI webhook processing error: %s', e)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response('YES', status_code=status.HTTP_200_OK)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
+9
View File
@@ -10,6 +10,7 @@ from fastapi.staticfiles import StaticFiles
from app.cabinet.routes import router as cabinet_router
from app.config import settings
from app.services.disposable_email_service import disposable_email_service
from app.services.payment_service import PaymentService
from app.webapi.app import create_web_api_app
from app.webapi.docs import add_redoc_endpoint
@@ -144,6 +145,14 @@ def create_unified_app(
else:
telegram_processor = None
@app.on_event('startup')
async def start_disposable_email_service() -> None: # pragma: no cover - event hook
await disposable_email_service.start()
@app.on_event('shutdown')
async def stop_disposable_email_service() -> None: # pragma: no cover - event hook
await disposable_email_service.stop()
miniapp_mounted, miniapp_path = _mount_miniapp_static(app)
unified_health_path = '/health/unified' if settings.is_web_api_enabled() else '/health'

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