Compare commits

...

77 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
44 changed files with 2213 additions and 113 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.4.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.4.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.4.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
+3 -3
View File
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.4.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.4.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.4.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
+2 -3
View File
@@ -20,6 +20,5 @@ jobs:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: python
extra-files: |
pyproject.toml
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.5.0"
".": "3.7.2"
}
+85
View File
@@ -1,5 +1,90 @@
# 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)
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.4.0"
ARG VERSION="v3.7.2" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+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,
)
+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)
+1 -1
View File
@@ -388,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}')
+5
View File
@@ -336,6 +336,7 @@ async def get_all_tickets(
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),
):
@@ -355,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
+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)')
+164
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,
)
@@ -68,6 +69,9 @@ from ..schemas.users import (
UserAvailableTariffsResponse,
UserDetailResponse,
UserListItem,
UserNodeUsageItem,
UserNodeUsageResponse,
UserPanelInfoResponse,
UserPromoGroupInfo,
UserReferralInfo,
UsersListResponse,
@@ -525,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,
@@ -550,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,
@@ -577,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 ===
+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)
+20
View File
@@ -1070,6 +1070,19 @@ async def get_trial_info(
"""Get trial subscription info and availability."""
await db.refresh(user, ['subscription'])
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
return TrialInfoResponse(
is_available=False,
duration_days=settings.TRIAL_DURATION_DAYS,
traffic_limit_gb=settings.TRIAL_TRAFFIC_LIMIT_GB,
device_limit=settings.TRIAL_DEVICE_LIMIT,
requires_payment=bool(settings.TRIAL_PAYMENT_ENABLED),
price_kopeks=0,
price_rubles=0,
reason_unavailable='Trial is not available for your account type',
)
duration_days = settings.TRIAL_DURATION_DAYS
traffic_limit_gb = settings.TRIAL_TRAFFIC_LIMIT_GB
device_limit = settings.TRIAL_DEVICE_LIMIT
@@ -1148,6 +1161,13 @@ async def activate_trial(
"""Activate trial subscription."""
await db.refresh(user, ['subscription'])
# Проверяем, отключён ли триал для этого типа пользователя
if settings.is_trial_disabled_for_user(getattr(user, 'auth_type', 'telegram')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Trial is not available for your account type',
)
# Check if user already has an active subscription
if user.subscription:
now = datetime.utcnow()
+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
+49
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 ===
+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)
+26 -23
View File
@@ -492,8 +492,8 @@ async def reorder_tariffs(
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)
+98 -1
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
@@ -1235,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)
+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
+6 -7
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
@@ -776,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}')
+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)
+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
View File
@@ -560,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
@@ -752,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
@@ -1384,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
+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()
+17 -1
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,6 +169,7 @@ 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, если он передан
@@ -166,8 +180,10 @@ class BlacklistService:
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]]:
+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()
+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()
+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
+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]] = {
+37 -3
View File
@@ -153,6 +153,26 @@ def github_markdown_to_telegram_html(text: str) -> str:
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,
*,
@@ -191,8 +211,10 @@ def truncate_for_blockquote(
if len(description_html) <= available:
return description_html
# Truncate, trying not to break mid-tag
truncated = description_html[: available - len(ellipsis)]
# 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('<')
@@ -200,4 +222,16 @@ def truncate_for_blockquote(
if last_open > last_close:
truncated = truncated[:last_open]
return truncated.rstrip() + ellipsis
# 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
+3
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
+2 -2
View File
@@ -524,8 +524,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
if success:
return JSONResponse({'status': 'ok'})
order_id = payload.get('order_id', 'unknown')
logger.error('Wata webhook processing failed: order_id=%s', order_id)
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,
+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'
@@ -0,0 +1,45 @@
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'g5b6c7d8e9f0'
down_revision: Union[str, None] = 'f4a5b6c7d8e9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('google_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('yandex_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('discord_id', sa.String(255), nullable=True))
op.add_column('users', sa.Column('vk_id', sa.BigInteger(), nullable=True))
op.create_unique_constraint('uq_users_google_id', 'users', ['google_id'])
op.create_unique_constraint('uq_users_yandex_id', 'users', ['yandex_id'])
op.create_unique_constraint('uq_users_discord_id', 'users', ['discord_id'])
op.create_unique_constraint('uq_users_vk_id', 'users', ['vk_id'])
op.create_index('ix_users_google_id', 'users', ['google_id'])
op.create_index('ix_users_yandex_id', 'users', ['yandex_id'])
op.create_index('ix_users_discord_id', 'users', ['discord_id'])
op.create_index('ix_users_vk_id', 'users', ['vk_id'])
def downgrade() -> None:
op.drop_index('ix_users_vk_id', table_name='users')
op.drop_index('ix_users_discord_id', table_name='users')
op.drop_index('ix_users_yandex_id', table_name='users')
op.drop_index('ix_users_google_id', table_name='users')
op.drop_constraint('uq_users_vk_id', 'users', type_='unique')
op.drop_constraint('uq_users_discord_id', 'users', type_='unique')
op.drop_constraint('uq_users_yandex_id', 'users', type_='unique')
op.drop_constraint('uq_users_google_id', 'users', type_='unique')
op.drop_column('users', 'vk_id')
op.drop_column('users', 'discord_id')
op.drop_column('users', 'yandex_id')
op.drop_column('users', 'google_id')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.5.0"
version = "3.7.2"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+5 -1
View File
@@ -6,7 +6,11 @@
"bump-patch-for-minor-pre-major": true,
"include-component-in-tag": false,
"extra-files": [
"pyproject.toml"
{
"type": "generic",
"path": "Dockerfile",
"glob": false
}
],
"changelog-sections": [
{ "type": "feat", "section": "New Features" },
Generated
+1 -2
View File
@@ -576,7 +576,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
@@ -1150,7 +1149,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.4.0"
version = "3.6.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },