Compare commits

...

208 Commits

Author SHA1 Message Date
c0mrade 8afe613451 Merge pull request #2541 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.5.0
2026-02-06 23:44:40 +03:00
github-actions[bot] 8de9c6e532 chore(main): release 3.5.0 2026-02-06 20:42:00 +00:00
Egor b69fcbde11 Merge pull request #2540 from BEDOLAGA-DEV/dev
Release 3.4.1
2026-02-06 23:33:38 +03:00
Fringg 44d6b6b266 chore: bump version to 3.4.1 2026-02-06 23:31:46 +03:00
c0mrade 4234769e92 revert: remove signature pop from HMAC validation
Telegram includes signature in the hash computation, so removing it
from the data-check-string breaks HMAC validation for all users.
2026-02-06 22:27:57 +03:00
c0mrade c2cabbee09 fix: restore unquote for user data parsing in telegram auth
parse_qsl does not fully decode nested URL-encoded JSON in the user
field, so unquote() is still needed before json.loads().
2026-02-06 22:13:32 +03:00
c0mrade 067b1b6716 chore: remove unused unquote import 2026-02-06 21:55:45 +03:00
c0mrade 5b64046137 fix: exclude signature field from Telegram initData HMAC validation
Telegram Bot API 8.0+ adds a `signature` field to WebApp initData.
Per the official spec, both `hash` and `signature` must be excluded
from the data-check-string before HMAC verification. Without this,
users with newer Telegram clients get a hash mismatch and 401.

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

Fixes: InterfaceError "connection is closed" and MissingGreenlet errors
during mass message broadcasts
2026-02-05 05:42:31 +03:00
Egor cf10eeda53 Merge pull request #2528 from BEDOLAGA-DEV/main
Update docker-registry.yml
2026-02-05 05:04:32 +03:00
Egor 3cfac7e2dc Update docker-registry.yml 2026-02-05 05:04:00 +03:00
Egor 39e111c91b Merge pull request #2527 from BEDOLAGA-DEV/main
W
2026-02-05 04:55:04 +03:00
Egor ba42517808 Update README.md 2026-02-05 00:25:48 +03:00
Egor 13846d621a Update README.md 2026-02-05 00:25:07 +03:00
Egor e612e2f383 Merge pull request #2526 from BEDOLAGA-DEV/dev
Dev
2026-02-04 05:02:03 +03:00
Egor 1f524ccd80 Add files via upload 2026-02-04 04:51:35 +03:00
Egor 37bde2985e Add files via upload 2026-02-04 04:51:12 +03:00
Egor c6a5e0d4be Add files via upload 2026-02-04 04:50:38 +03:00
Egor 3985053636 Add files via upload 2026-02-04 04:49:37 +03:00
Egor 4cfb1dd38f Add files via upload 2026-02-04 04:49:14 +03:00
Egor 117a417ce0 Add files via upload 2026-02-04 04:48:49 +03:00
Egor a2e0474572 Add files via upload 2026-02-04 04:48:29 +03:00
Egor e992891691 Add files via upload 2026-02-04 04:48:01 +03:00
Egor 57cf8687d4 Add files via upload 2026-02-04 04:47:39 +03:00
Egor 27870bbdcb Update main.py 2026-02-04 04:47:24 +03:00
Egor 21d48078ed Merge pull request #2525 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-04 03:58:14 +03:00
Egor afb4f162d0 Update admin_notification_service.py 2026-02-04 03:55:59 +03:00
Egor 96d479780f Merge pull request #2524 from BEDOLAGA-DEV/dev
Update admin_promo_offers.py
2026-02-04 03:23:53 +03:00
Egor 2e0cd5d54c Update admin_promo_offers.py 2026-02-04 03:23:35 +03:00
Egor c4374ce483 Merge pull request #2523 from BEDOLAGA-DEV/dev
Dev
2026-02-04 03:05:46 +03:00
Egor bd1a0d4a4e Update wata.py 2026-02-04 03:05:22 +03:00
Egor 97f4cc0f7c Update subscription.py 2026-02-04 02:57:41 +03:00
Egor 3ebbb42096 Update inline.py 2026-02-04 02:57:12 +03:00
Egor 07ae7c2a7f Update traffic.py 2026-02-04 02:56:47 +03:00
Egor 5c3505aec9 Merge pull request #2522 from BEDOLAGA-DEV/dev
Update menu.py
2026-02-04 02:16:23 +03:00
Egor fffa231b7e Update menu.py 2026-02-04 02:15:45 +03:00
Egor f8db099d0f Merge pull request #2521 from BEDOLAGA-DEV/dev
Dev
2026-02-04 02:10:58 +03:00
Egor 9483517258 Add files via upload 2026-02-04 02:08:18 +03:00
Egor 0c0ab58236 Update promocode.py 2026-02-04 02:07:27 +03:00
Egor 92ec1219fa Add files via upload 2026-02-04 02:06:47 +03:00
Egor bf72e81d55 Update promocode_service.py 2026-02-04 02:06:13 +03:00
Egor 5a008e59a2 Merge pull request #2520 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:57:22 +03:00
Egor 61b8d586d3 Update subscription.py 2026-02-03 23:56:55 +03:00
Egor df62e2bd96 Merge pull request #2519 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 23:45:08 +03:00
Egor 07e8990ddb Update subscription.py 2026-02-03 23:44:15 +03:00
Egor 40fc4d7267 Merge pull request #2518 from BEDOLAGA-DEV/dev
Dev
2026-02-03 04:38:57 +03:00
Egor 5ffd3093ea Update users.py 2026-02-03 04:38:01 +03:00
Egor 01ac2c7ed0 Update admin_users.py 2026-02-03 04:37:25 +03:00
Egor 937a25aafd Update pyproject.toml 2026-02-03 04:29:27 +03:00
Egor aeee018a53 Merge pull request #2517 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-03 04:14:40 +03:00
Egor 5cef11f32b Update subscription.py 2026-02-03 04:14:15 +03:00
Egor 467f67907f Merge pull request #2516 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:58:59 +03:00
Egor 6d38531f42 Add files via upload 2026-02-03 03:58:31 +03:00
Egor 2dd057a911 Update subscription.py 2026-02-03 03:56:27 +03:00
Egor 4178e4b024 Add files via upload 2026-02-03 03:53:18 +03:00
Egor 21bcde26e5 Add files via upload 2026-02-03 03:52:56 +03:00
Egor 878606d745 Add files via upload 2026-02-03 03:52:29 +03:00
Egor 45a90876da Add files via upload 2026-02-03 03:52:08 +03:00
Egor bb4f496b21 Add files via upload 2026-02-03 03:50:33 +03:00
Egor 47c1de1cc8 Add files via upload 2026-02-03 03:50:05 +03:00
Egor f69156d7ee Update subscription.py 2026-02-03 03:43:51 +03:00
Egor c9d559e3f2 Update subscription.py 2026-02-03 03:41:18 +03:00
Egor e28a48853d Add files via upload 2026-02-03 03:40:08 +03:00
Egor b13da1f2e8 Add files via upload 2026-02-03 03:39:45 +03:00
Egor ba1bf677d6 Add files via upload 2026-02-03 03:39:21 +03:00
Egor ccdff05dca Add files via upload 2026-02-03 03:38:51 +03:00
Egor 03875b593e Add files via upload 2026-02-03 03:38:14 +03:00
Egor 06224d798d Add files via upload 2026-02-03 03:37:43 +03:00
Egor 966f436723 Merge pull request #2514 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:32:10 +03:00
Egor 4941fe9469 Update cloudpayments.py 2026-02-03 03:31:48 +03:00
Egor fe56078481 Merge pull request #2513 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:28:13 +03:00
Egor 39742499b8 Update cloudpayments.py 2026-02-03 03:27:53 +03:00
Egor d8207fa1f0 Merge pull request #2512 from BEDOLAGA-DEV/dev
Update cloudpayments.py
2026-02-03 03:23:48 +03:00
Egor 7eb302aab0 Update cloudpayments.py 2026-02-03 03:23:23 +03:00
Egor 8cb5da4f74 Merge pull request #2511 from BEDOLAGA-DEV/dev
Dev
2026-02-03 03:17:18 +03:00
Egor cad786f6ee Add files via upload 2026-02-03 03:15:34 +03:00
Egor 0adc7145a1 Update user_service.py 2026-02-03 03:14:39 +03:00
Egor d35f54a4db Merge pull request #2510 from BEDOLAGA-DEV/main
w
2026-02-03 03:14:01 +03:00
c0mrade 000d670869 Merge pull request #2506 from BEDOLAGA-DEV/fix/ticket-settings-route-order
fix: move /settings routes before /{ticket_id} to fix route matching
2026-02-02 09:01:30 +03:00
c0mrade 0c9b69deb0 fix: move /settings routes before /{ticket_id} to fix route matching
Static routes must be defined before dynamic routes in FastAPI.
Previously /settings was matched as ticket_id parameter causing parsing error.
2026-02-02 08:58:07 +03:00
Egor 63e31e84fc Merge pull request #2505 from BEDOLAGA-DEV/dev
Dev
2026-02-02 05:27:02 +03:00
Egor 1bd301f21a Add files via upload 2026-02-02 05:26:41 +03:00
Egor 3dbaf99733 Update inline.py 2026-02-02 05:26:09 +03:00
Egor 4380611bee Merge pull request #2504 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 05:18:52 +03:00
Egor 5aca466d72 Update subscription.py 2026-02-02 05:18:11 +03:00
Egor 86970f0398 Update subscription.py 2026-02-02 05:16:58 +03:00
c0mrade 733be09658 Merge pull request #2503 from BEDOLAGA-DEV/fix/promo-groups-async
fix: add refresh before assigning promo_groups to avoid async lazy lo…
2026-02-02 04:58:00 +03:00
c0mrade 5e75210c8b fix: add refresh before assigning promo_groups to avoid async lazy load error 2026-02-02 04:55:27 +03:00
Egor cb0ccc9c77 Merge pull request #2502 from BEDOLAGA-DEV/dev
Dev
2026-02-02 03:37:47 +03:00
Egor 078eebfbb1 Update tariffs.py 2026-02-02 03:37:21 +03:00
Egor 4049e0d9ff Update decorators.py 2026-02-02 03:36:38 +03:00
Egor 7bf7b942ba Merge pull request #2501 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:19:08 +03:00
Egor 4f1c14fda0 Update blocked_users_service.py 2026-02-02 03:18:46 +03:00
Egor 7bd6ae3c26 Merge pull request #2500 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 03:10:50 +03:00
Egor caeafc7abd Update blocked_users_service.py 2026-02-02 03:10:27 +03:00
Egor 4d249a3bc9 Merge pull request #2499 from BEDOLAGA-DEV/dev
Update blocked_users.py
2026-02-02 03:02:20 +03:00
Egor 1f26b522b4 Update blocked_users.py 2026-02-02 03:01:56 +03:00
Egor d1ed6c1b18 Merge pull request #2498 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:59:40 +03:00
Egor d4d89ec20d Update blocked_users.py 2026-02-02 02:59:17 +03:00
Egor 6a2fd5a7de Add files via upload 2026-02-02 02:58:22 +03:00
Egor f41c5a10b6 Update blocked_users.py 2026-02-02 02:57:29 +03:00
Egor 3e873a05a4 Update blocked_users_service.py 2026-02-02 02:56:58 +03:00
Egor c22f41cbf2 Merge pull request #2497 from BEDOLAGA-DEV/dev
Update blocked_users_service.py
2026-02-02 02:53:39 +03:00
Egor f3851b9ecc Update blocked_users_service.py 2026-02-02 02:53:10 +03:00
Egor 9a258744f2 Merge pull request #2496 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:50:14 +03:00
Egor 8780ae9407 Update blocked_users_service.py 2026-02-02 02:49:23 +03:00
Egor 3f886df44f Update blocked_users.py 2026-02-02 02:48:57 +03:00
Egor e3c6d4a5a1 Add files via upload 2026-02-02 02:47:49 +03:00
Egor c3215a1e54 Update bot.py 2026-02-02 02:47:00 +03:00
Egor fdd5a8aa6e Update admin.py 2026-02-02 02:46:39 +03:00
Egor c1792f487a Add files via upload 2026-02-02 02:46:03 +03:00
Egor f27f0f8bca Merge pull request #2495 from BEDOLAGA-DEV/dev
Update startup_notification_service.py
2026-02-02 02:21:02 +03:00
Egor c70ddfe157 Update startup_notification_service.py 2026-02-02 02:20:41 +03:00
Egor 554d776b77 Merge pull request #2494 from BEDOLAGA-DEV/dev
Dev
2026-02-02 02:08:12 +03:00
Egor 34ac9eb6ed Update global_error.py 2026-02-02 02:05:50 +03:00
Egor 6cc48d2872 Update startup_notification_service.py 2026-02-02 02:05:28 +03:00
Egor 9fc31c25b2 Update startup_notification_service.py 2026-02-02 02:00:01 +03:00
Egor 73585ebc82 Update global_error.py 2026-02-02 01:59:25 +03:00
Egor e9d3e9a2be Merge pull request #2493 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:43:28 +03:00
Egor 3e6e2f577c Update global_error.py 2026-02-02 01:43:05 +03:00
Egor 39c644b505 Merge pull request #2492 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:36:39 +03:00
Egor 62500a6369 Update startup_notification_service.py 2026-02-02 01:34:40 +03:00
Egor 9e16b56d9a Update global_error.py 2026-02-02 01:34:06 +03:00
Egor 9dfadcda72 Merge pull request #2491 from BEDOLAGA-DEV/dev
Update global_error.py
2026-02-02 01:19:08 +03:00
Egor e2afdc28f3 Update global_error.py 2026-02-02 01:18:28 +03:00
Egor 86bd24edd4 Merge pull request #2490 from BEDOLAGA-DEV/dev
Dev
2026-02-02 01:07:29 +03:00
Egor 2bda556c4b Update main.py 2026-02-02 01:07:06 +03:00
Egor 60724a0354 Update startup_notification_service.py 2026-02-02 01:05:30 +03:00
Egor 250edec20e Merge pull request #2489 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:56:21 +03:00
Egor 6b1e78f990 Update maintenance_service.py 2026-02-02 00:55:56 +03:00
Egor 56f784c8bf Update maintenance_service.py 2026-02-02 00:54:10 +03:00
Egor ce822ead2b Update maintenance_service.py 2026-02-02 00:51:13 +03:00
Egor e606c1d4d5 Update startup_notification_service.py 2026-02-02 00:50:51 +03:00
Egor c204194b8b Update main.py 2026-02-02 00:50:01 +03:00
Egor 5a878239f3 Add files via upload 2026-02-02 00:49:29 +03:00
Egor 08aa8dabfd Merge pull request #2488 from BEDOLAGA-DEV/dev
Update subscription.py
2026-02-02 00:34:18 +03:00
Egor 20ed6071e2 Update subscription.py 2026-02-02 00:33:59 +03:00
Egor 8d34a4b3d2 Merge pull request #2487 from BEDOLAGA-DEV/dev
Dev
2026-02-02 00:28:21 +03:00
Egor 94a9528397 Update subscription.py 2026-02-02 00:27:51 +03:00
Egor 403052a840 Update tariff_purchase.py 2026-02-02 00:27:21 +03:00
Egor bb8e5bb6ca Update admin_notification_service.py 2026-02-02 00:26:53 +03:00
Egor 11ee8764a6 Merge pull request #2486 from BEDOLAGA-DEV/dev
Update admin_notification_service.py
2026-02-02 00:18:02 +03:00
Egor f9be0e6315 Update admin_notification_service.py 2026-02-02 00:17:20 +03:00
Egor 9e56c56528 Update admin_notification_service.py 2026-02-02 00:11:28 +03:00
Egor 85cf96b813 Merge pull request #2485 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:34:17 +03:00
Egor c16eee4ef2 Add files via upload 2026-02-01 18:33:12 +03:00
Egor b6bd2625c2 Update subscription.py 2026-02-01 18:32:29 +03:00
Egor 020343cdf8 Merge pull request #2484 from BEDOLAGA-DEV/dev
Dev
2026-02-01 18:11:23 +03:00
Egor c07fffd809 Update tariff_purchase.py 2026-02-01 18:10:59 +03:00
Egor afaeeaf7f1 Update subscription.py 2026-02-01 18:10:22 +03:00
Egor e946cc7354 Merge pull request #2483 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:28:42 +03:00
Egor bf6a966668 Update subscription.py 2026-02-01 17:28:21 +03:00
Egor 7f12fa7003 Update devices.py 2026-02-01 17:27:44 +03:00
Egor 5f6ef5993c Merge pull request #2482 from BEDOLAGA-DEV/dev
Dev
2026-02-01 17:22:07 +03:00
Egor f1ac67e511 Update devices.py 2026-02-01 17:18:00 +03:00
Egor 48f9f606aa Update subscription.py 2026-02-01 17:17:02 +03:00
Egor 7d9d1b0a6f Update inline.py 2026-02-01 17:16:28 +03:00
Egor afadf7160c Merge pull request #2481 from BEDOLAGA-DEV/main
w
2026-02-01 16:50:38 +03:00
Egor b546fbd1cc Merge pull request #2452 from Gy9vin/main
реф система и другое
2026-02-01 16:45:33 +03:00
Egor b8f1785783 Delete migrations/.DS_Store 2026-02-01 16:44:15 +03:00
gy9vin 7ee8c8ff4d Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 14:41:56 +03:00
gy9vin 551112d2d9 make fix 2026-02-01 14:41:45 +03:00
Mikhail e9dc9630b2 Merge branch 'BEDOLAGA-DEV:main' into main 2026-02-01 14:38:39 +03:00
Egor b068c1fb12 Merge pull request #2480 from BEDOLAGA-DEV/dev
Dev
2026-02-01 12:34:47 +03:00
Egor 9f66e176f7 Add files via upload 2026-02-01 12:34:24 +03:00
Egor c66bad99f0 Update devices.py 2026-02-01 12:33:57 +03:00
Egor f6a29760a9 Merge pull request #2479 from BEDOLAGA-DEV/dev
Dev
2026-02-01 11:29:14 +03:00
Egor d7e1b8fd5d Update user.py 2026-02-01 11:28:36 +03:00
Egor 54e9175bdf Update Dockerfile 2026-02-01 11:25:42 +03:00
gy9vin f581b10e19 fix 2026-02-01 11:23:42 +03:00
gy9vin 1ae6ea18b7 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-02-01 11:18:59 +03:00
gy9vin bea6c02d89 kassa ai 2026-02-01 11:18:54 +03:00
Mikhail 153083d791 Merge branch 'main' into main 2026-02-01 11:11:23 +03:00
Egor 80538b39ac Update Dockerfile 2026-02-01 01:10:51 +03:00
Egor 205f9e8e3c Update docker-registry.yml 2026-02-01 01:10:17 +03:00
Egor 97ccfd3af0 Update docker-hub.yml 2026-02-01 01:10:03 +03:00
gy9vin 4f77ece187 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 23:43:29 +03:00
gy9vin 56a69fa1ba правки 2026-01-30 23:43:26 +03:00
Mikhail 5c94bda60a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 23:41:34 +03:00
gy9vin b8d0e6eefb Новый фильтр и кричиеский баг
Теперь при подписке на канал:
  -  Обычные пользователи — подписка реактивируется
  - 🚫 Заблокированные — пропуск с логом, подписка НЕ активируется
2026-01-30 23:40:46 +03:00
Mikhail b7af25644a Merge branch 'BEDOLAGA-DEV:main' into main 2026-01-30 16:40:25 +03:00
Mikhail e312767247 Merge branch 'main' into main 2026-01-30 09:36:50 +03:00
gy9vin 1dfa243736 Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot 2026-01-30 09:35:23 +03:00
gy9vin e0d667df28 fix реф системы! фишки конкурсной систем! проверка логов по рефералам и начисления бонусов 2026-01-30 09:35:17 +03:00
84 changed files with 8731 additions and 1515 deletions
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+4 -4
View File
@@ -14,7 +14,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: fr1ngg/remnawave-bedolaga-telegram-bot
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@@ -49,13 +49,13 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Building release version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v3.2.0-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-$(git rev-parse --short HEAD)"
echo "🚀 Building main version: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v3.2.0-dev-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-dev-$(git rev-parse --short HEAD)"
echo "🧪 Building dev version: $VERSION"
else
VERSION="v3.2.0-pr-$(git rev-parse --short HEAD)"
VERSION="v3.4.0-pr-$(git rev-parse --short HEAD)"
echo "🔀 Building PR version: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
+25
View File
@@ -0,0 +1,25 @@
name: Release Please
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
release-type: python
extra-files: |
pyproject.toml
+168
View File
@@ -0,0 +1,168 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
lint:
uses: ./.github/workflows/lint.yml
release:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get previous tag
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 ${{ github.ref_name }}^ 2>/dev/null || echo "")
echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Generate changelog
id: changelog
run: |
TAG="${{ github.ref_name }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
if [ -z "$PREV_TAG" ]; then
RANGE="$TAG"
else
RANGE="${PREV_TAG}..${TAG}"
fi
# Collect commits by category
FEATURES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^feat" || true)
FIXES=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^fix" || true)
PERF=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^perf|^refactor" || true)
DOCS=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^docs|^style" || true)
CHORE=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -iE "^chore|^ci|^build|^test" || true)
OTHER=$(git log $RANGE --pretty=format:"%s|%an|%h" --no-merges | grep -ivE "^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)" || true)
# Collect unique contributors
CONTRIBUTORS=$(git log $RANGE --pretty=format:"%an" --no-merges | sort -u)
# Stats
TOTAL_COMMITS=$(git log $RANGE --oneline --no-merges | wc -l | tr -d ' ')
FILES_CHANGED=$(git diff --stat $RANGE 2>/dev/null | tail -1 || echo "N/A")
# Format function
format_section() {
local commits="$1"
if [ -n "$commits" ]; then
echo "$commits" | while IFS='|' read -r msg author hash; do
# Clean conventional commit prefix
clean_msg=$(echo "$msg" | sed -E 's/^(feat|fix|perf|refactor|docs|style|chore|ci|build|test)(\([^)]*\))?:\s*//')
echo "- ${clean_msg} (\`${hash}\`) — @${author}"
done
fi
}
# Build changelog
{
echo "changelog<<CHANGELOG_EOF"
if [ -n "$FEATURES" ]; then
echo "### New Features"
echo ""
format_section "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### Bug Fixes"
echo ""
format_section "$FIXES"
echo ""
fi
if [ -n "$PERF" ]; then
echo "### Performance & Refactoring"
echo ""
format_section "$PERF"
echo ""
fi
if [ -n "$DOCS" ]; then
echo "### Documentation & Style"
echo ""
format_section "$DOCS"
echo ""
fi
if [ -n "$CHORE" ]; then
echo "### Maintenance"
echo ""
format_section "$CHORE"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### Other Changes"
echo ""
format_section "$OTHER"
echo ""
fi
echo "---"
echo ""
echo "### Contributors"
echo ""
if [ -n "$CONTRIBUTORS" ]; then
echo "$CONTRIBUTORS" | while read -r name; do
echo "- @${name}"
done
fi
echo ""
echo "### Stats"
echo ""
echo "- **Commits:** ${TOTAL_COMMITS}"
echo "- **Changes:** ${FILES_CHANGED}"
if [ -n "$PREV_TAG" ]; then
echo "- **Full diff:** [\`${PREV_TAG}...${TAG}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${TAG})"
fi
echo "CHANGELOG_EOF"
} >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
body: |
## What's Changed
${{ steps.changelog.outputs.changelog }}
---
### Docker
```bash
# Docker Hub
docker pull fr1ngg/remnawave-bedolaga-telegram-bot:${{ github.ref_name }}
# GitHub Container Registry
docker pull ghcr.io/${{ github.repository }}:${{ github.ref_name }}
```
### Update
```bash
# Docker Compose
docker compose pull && docker compose up -d
# Or with Make
make reload
```
draft: false
prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') || contains(github.ref_name, 'dev') }}
generate_release_notes: false
+85 -32
View File
@@ -1,49 +1,102 @@
# Игнорируем все файлы и папки по умолчанию
*
docker-compose.override.yml
# Исключения: разрешаем только нужные файлы
# ========== WHITELIST: разрешённые файлы ==========
# Конфигурация проекта
!.dockerignore
!.env.example
!install_bot.sh
!.gitignore
!.python-version
!Dockerfile
!app-config.json
!main.py
!requirements.txt
!docker-compose.yml
!docker-compose.local.yml
!Makefile
!pyproject.toml
!uv.lock
!.python-version
!docs/
!docs/**
!migrations/
!migrations/**
!requirements.txt
!alembic.ini
!app-config.json
!release-please-config.json
!.release-please-manifest.json
# Документация
!README.md
!LICENSE
!CONTRIBUTING.md
!SECURITY.md
# Скрипты
!install_bot.sh
!main.py
# Статические файлы
!vpn_logo.png
# ========== WHITELIST: разрешённые папки ==========
# Разрешаем папку app/ и все её содержимое рекурсивно
!app/
!app/**
!tests/
!tests/**
# Дополнительно разрешаем README и лицензию (опционально)
!README.md
!LICENSE
# Разрешаем .gitignore чтобы он попал в репозиторий
!.gitignore
# Разрешаем .github/ (workflows, pre-commit и т.д.)
!migrations/
!migrations/**
!docs/
!docs/**
!assets/
!assets/**
!locales/
!locales/**
!.github/
!.github/**
# Разрешаем Makefile
!Makefile
# ========== BLACKLIST: игнорируемые внутри папок ==========
# Внутри разрешенных папок игнорируем служебные файлы
app/__pycache__/
app/**/__pycache__/
app/**/*.pyc
app/**/*.pyo
app/**/*.pyd
*.pyc
*.pyo
*.pyd
# Python
__pycache__/
**/__pycache__/
*.py[cod]
*$py.class
*.so
# Virtual environments
.venv/
venv/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Build/dist
build/
dist/
*.egg-info/
.eggs/
# Testing/coverage
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Local overrides (не коммитить!)
docker-compose.override.yml
.env
.env.local
.env.*.local
# Runtime data
logs/
data/
*.log
*.db
*.sqlite3
# OS files
.DS_Store
Thumbs.db
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.5.0"
}
+25
View File
@@ -0,0 +1,25 @@
# Changelog
## [3.5.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.4.0...v3.5.0) (2026-02-06)
### Features
* add tariff reorder API endpoint ([4c2e11e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4c2e11e64bed41592f5a12061dcca74ce43e0806))
* pass platform-level fields from RemnaWave config to frontend ([095bc00](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/095bc00b33d7082558a8b7252906db2850dce9da))
* serve original RemnaWave config from app-config endpoint ([43762ce](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/43762ce8f4fa7142a1ca62a92b97a027dab2564d))
* tariff reorder API endpoint ([085a617](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/085a61721a8175b3f4fd744614c446d73346f2b7))
### Bug Fixes
* enforce blacklist via middleware ([561708b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/561708b7772ec5b84d6ee049aeba26dc70675583))
* enforce blacklist via middleware instead of per-handler checks ([966a599](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/966a599c2c778dce9eea3c61adf6067fb33119f6))
* exclude signature field from Telegram initData HMAC validation ([5b64046](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5b6404613772610c595e55bde1249cdf6ec3269d))
* improve button URL resolution and pass uiConfig to frontend ([0ed98c3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0ed98c39b6c95911a38a26a32d0ffbcf9cfd7c80))
* restore unquote for user data parsing in telegram auth ([c2cabbe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/c2cabbee097a41a95d16c34d43ab7e70d076c4dc))
### Reverts
* remove signature pop from HMAC validation ([4234769](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4234769e92104a6c4f8f1d522e1fca25bc7b20d0))
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.13-slim
ARG VERSION="v3.2.0"
ARG VERSION="v3.4.0"
ARG BUILD_DATE
ARG VCS_REF
+1 -1
View File
@@ -37,7 +37,7 @@ _Полнофункциональное решение с управлением
---
## 🧪 [Тестирование бота](https://t.me/FringVPN_bot)
## 🧪 [Тестирование бота](https://t.me/zero_ping_vpn_bot?start=Git)
## 💬 **[Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi)** - Для общения, вопросов, предложений
+7
View File
@@ -24,6 +24,7 @@ from app.handlers import (
from app.handlers.admin import (
backup as admin_backup,
blacklist as admin_blacklist,
blocked_users as admin_blocked_users,
bot_configuration as admin_bot_configuration,
bulk_ban as admin_bulk_ban,
campaigns as admin_campaigns,
@@ -60,6 +61,7 @@ from app.handlers.admin import (
)
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
from app.middlewares.button_stats import ButtonStatsMiddleware
from app.middlewares.display_name_restriction import DisplayNameRestrictionMiddleware
from app.middlewares.global_error import GlobalErrorMiddleware
@@ -118,6 +120,10 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(LoggingMiddleware())
dp.message.middleware(MaintenanceMiddleware())
dp.callback_query.middleware(MaintenanceMiddleware())
blacklist_middleware = BlacklistMiddleware()
dp.message.middleware(blacklist_middleware)
dp.callback_query.middleware(blacklist_middleware)
dp.pre_checkout_query.middleware(blacklist_middleware)
display_name_middleware = DisplayNameRestrictionMiddleware()
dp.message.middleware(display_name_middleware)
dp.callback_query.middleware(display_name_middleware)
@@ -189,6 +195,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_tariffs.register_handlers(dp)
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
+13
View File
@@ -12,6 +12,7 @@ from app.config import settings
from app.database.crud.user import get_user_by_id
from app.database.database import AsyncSessionLocal
from app.database.models import User
from app.services.blacklist_service import blacklist_service
from app.services.maintenance_service import maintenance_service
from .auth.jwt_handler import get_token_payload
@@ -104,6 +105,18 @@ async def get_current_cabinet_user(
detail='User account is not active',
)
# Check blacklist
if user.telegram_id is not None:
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.telegram_id, user.username)
if is_blacklisted:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'blacklisted',
'message': reason or 'Доступ запрещен',
},
)
# Check maintenance mode (allow admins to pass)
if maintenance_service.is_maintenance_active():
# Проверяем админа по telegram_id ИЛИ email
+16 -1
View File
@@ -575,9 +575,24 @@ async def broadcast_offer(
notifications_failed = 0
if payload.send_notification and offers_to_notify:
# Render placeholders in custom message text
rendered_message_text = payload.message_text
if rendered_message_text:
extra = payload.extra_data or {}
try:
rendered_message_text = rendered_message_text.format(
discount_percent=payload.discount_percent,
valid_hours=payload.valid_hours,
active_discount_hours=extra.get('active_discount_hours') or payload.valid_hours,
test_duration_hours=extra.get('test_duration_hours') or 0,
server_name=extra.get('server_name', ''),
)
except (KeyError, ValueError, IndexError):
logger.warning('Failed to render promo message placeholders')
notifications_sent, notifications_failed = await _send_promo_notifications(
offers_to_notify=offers_to_notify,
message_text=payload.message_text,
message_text=rendered_message_text,
button_text=payload.button_text,
discount_percent=payload.discount_percent,
bonus_amount_kopeks=payload.bonus_amount_kopeks,
+55
View File
@@ -472,6 +472,61 @@ async def delete_promocode_endpoint(
return Response(status_code=status.HTTP_204_NO_CONTENT)
class DeactivateDiscountResponse(BaseModel):
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
user_id: int
@router.post('/deactivate-discount/{user_id}', response_model=DeactivateDiscountResponse)
async def admin_deactivate_discount_promocode(
user_id: int,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> DeactivateDiscountResponse:
"""Admin: deactivate a user's active discount promo code."""
from app.database.crud.user import get_user_by_id as get_user
target_user = await get_user(db, user_id)
if not target_user:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User not found')
from app.services.promocode_service import PromoCodeService
service = PromoCodeService()
result = await service.deactivate_discount_promocode(
db=db,
user_id=user_id,
admin_initiated=True,
)
if result['success']:
return DeactivateDiscountResponse(
success=True,
message=f'Discount promo code deactivated for user {user_id}',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
user_id=user_id,
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'User has no active discount from a promo code',
'discount_already_expired': 'Discount has already expired (cleaned up)',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
# ============== PromoGroup Endpoints ==============
promo_groups_router = APIRouter(prefix='/admin/promo-groups', tags=['Admin Promo Groups'])
+17
View File
@@ -14,6 +14,7 @@ from app.database.crud.tariff import (
get_tariff_by_id,
get_tariff_subscriptions_count,
load_period_prices_from_db,
reorder_tariffs,
set_tariff_promo_groups,
update_tariff,
)
@@ -29,6 +30,7 @@ from ..schemas.tariffs import (
TariffDetailResponse,
TariffListItem,
TariffListResponse,
TariffSortOrderRequest,
TariffStatsResponse,
TariffToggleResponse,
TariffTrialResponse,
@@ -157,6 +159,21 @@ async def get_available_servers(
]
@router.put('/order')
async def update_tariff_order(
request: TariffSortOrderRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update the display order of tariffs."""
await reorder_tariffs(db, request.tariff_ids)
await db.commit()
logger.info(f'Admin {admin.id} updated tariff order: {request.tariff_ids}')
return {'message': 'Tariff order updated successfully'}
@router.get('/{tariff_id}', response_model=TariffDetailResponse)
async def get_tariff(
tariff_id: int,
+110 -110
View File
@@ -220,6 +220,116 @@ async def get_ticket_stats(
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.get('', response_model=AdminTicketListResponse)
async def get_all_tickets(
page: int = Query(1, ge=1, description='Page number'),
@@ -490,113 +600,3 @@ async def update_ticket_priority(
user=user_info,
messages=messages_response,
)
@router.get('/settings', response_model=TicketSettingsResponse)
async def get_ticket_settings(
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Get ticket system settings."""
from app.services.support_settings_service import SupportSettingsService
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
@router.patch('/settings', response_model=TicketSettingsResponse)
async def update_ticket_settings(
request: TicketSettingsUpdateRequest,
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""Update ticket system settings."""
from pathlib import Path
from app.services.support_settings_service import SupportSettingsService
# Validate support_system_mode
if request.support_system_mode is not None:
mode = request.support_system_mode.strip().lower()
if mode not in {'tickets', 'contact', 'both'}:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid support_system_mode. Must be: tickets, contact, or both',
)
# Update in-memory settings
if request.sla_enabled is not None:
settings.SUPPORT_TICKET_SLA_ENABLED = request.sla_enabled
if request.sla_minutes is not None:
settings.SUPPORT_TICKET_SLA_MINUTES = request.sla_minutes
if request.sla_check_interval_seconds is not None:
settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS = request.sla_check_interval_seconds
if request.sla_reminder_cooldown_minutes is not None:
settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES = request.sla_reminder_cooldown_minutes
if request.support_system_mode is not None:
settings.SUPPORT_SYSTEM_MODE = request.support_system_mode.strip().lower()
# Update cabinet notification settings
if request.cabinet_user_notifications_enabled is not None:
SupportSettingsService.set_cabinet_user_notifications_enabled(request.cabinet_user_notifications_enabled)
if request.cabinet_admin_notifications_enabled is not None:
SupportSettingsService.set_cabinet_admin_notifications_enabled(request.cabinet_admin_notifications_enabled)
# Try to persist to .env file
try:
env_file = Path('.env')
if env_file.exists():
lines = env_file.read_text().splitlines()
updates = {}
if request.sla_enabled is not None:
updates['SUPPORT_TICKET_SLA_ENABLED'] = str(request.sla_enabled).lower()
if request.sla_minutes is not None:
updates['SUPPORT_TICKET_SLA_MINUTES'] = str(request.sla_minutes)
if request.sla_check_interval_seconds is not None:
updates['SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS'] = str(request.sla_check_interval_seconds)
if request.sla_reminder_cooldown_minutes is not None:
updates['SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES'] = str(request.sla_reminder_cooldown_minutes)
if request.support_system_mode is not None:
updates['SUPPORT_SYSTEM_MODE'] = request.support_system_mode.strip().lower()
new_lines = []
updated_keys = set()
for line in lines:
updated = False
for key, value in updates.items():
if line.startswith(f'{key}='):
new_lines.append(f'{key}={value}')
updated_keys.add(key)
updated = True
break
if not updated:
new_lines.append(line)
# Add any keys that weren't found
for key, value in updates.items():
if key not in updated_keys:
new_lines.append(f'{key}={value}')
env_file.write_text('\n'.join(new_lines) + '\n')
logger.info('Updated ticket settings in .env file')
except Exception as e:
logger.warning(f'Failed to update .env file: {e}')
return TicketSettingsResponse(
sla_enabled=settings.SUPPORT_TICKET_SLA_ENABLED,
sla_minutes=settings.SUPPORT_TICKET_SLA_MINUTES,
sla_check_interval_seconds=settings.SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS,
sla_reminder_cooldown_minutes=settings.SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES,
support_system_mode=settings.get_support_system_mode(),
cabinet_user_notifications_enabled=SupportSettingsService.get_cabinet_user_notifications_enabled(),
cabinet_admin_notifications_enabled=SupportSettingsService.get_cabinet_admin_notifications_enabled(),
)
+247
View File
@@ -38,9 +38,17 @@ from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.users import (
DeleteUserRequest,
DeleteUserResponse,
DisableUserRequest,
DisableUserResponse,
FullDeleteUserRequest,
FullDeleteUserResponse,
PanelSyncStatusResponse,
PanelUserInfo,
PeriodPriceInfo,
ResetSubscriptionRequest,
ResetSubscriptionResponse,
ResetTrialRequest,
ResetTrialResponse,
SortByEnum,
SyncFromPanelRequest,
SyncFromPanelResponse,
@@ -1195,6 +1203,245 @@ async def delete_user(
)
@router.delete('/{user_id}/full', response_model=FullDeleteUserResponse)
async def full_delete_user(
user_id: int,
request: FullDeleteUserRequest = FullDeleteUserRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Full user deletion - removes from bot database AND Remnawave panel.
Uses UserService.delete_user_account() which handles:
- Deleting/disabling user in Remnawave panel
- Removing all related records (payments, transactions, etc.)
- Removing user from database
"""
from app.services.user_service import UserService
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
panel_error: str | None = None
deleted_from_panel = False
# UserService.delete_user_account handles both bot DB and Remnawave panel
user_service = UserService()
success = await user_service.delete_user_account(db, user_id, admin.id)
if success:
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} fully deleted user {user_id}{reason_text}')
return FullDeleteUserResponse(
success=success,
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
deleted_from_bot=success,
deleted_from_panel=deleted_from_panel,
panel_error=panel_error,
)
@router.post('/{user_id}/reset-trial', response_model=ResetTrialResponse)
async def reset_user_trial(
user_id: int,
request: ResetTrialRequest = ResetTrialRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Reset user trial - allows user to activate trial again.
Actions:
- Delete current subscription if exists
- Reset has_used_trial flag to False
- User can now activate a new trial
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deleted = False
# Delete subscription if exists
if user.subscription:
# Deactivate in Remnawave panel first
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for trial reset')
except Exception as e:
logger.warning(f'Failed to disable Remnawave user during trial reset: {e}')
# Delete subscription from database
from sqlalchemy import delete
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
# Reset trial flag
user.has_used_trial = False
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset trial for user {user_id}{reason_text}')
return ResetTrialResponse(
success=True,
message='Trial reset successfully. User can now activate a new trial.',
subscription_deleted=subscription_deleted,
has_used_trial_reset=True,
)
@router.post('/{user_id}/reset-subscription', response_model=ResetSubscriptionResponse)
async def reset_user_subscription(
user_id: int,
request: ResetSubscriptionRequest = ResetSubscriptionRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Reset user subscription - removes/deactivates subscription.
Actions:
- Delete subscription from bot database
- Optionally deactivate in Remnawave panel
- User will have no active subscription
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deleted = False
panel_deactivated = False
panel_error: str | None = None
if not user.subscription:
return ResetSubscriptionResponse(
success=True,
message='User has no subscription to reset',
subscription_deleted=False,
panel_deactivated=False,
)
# Deactivate in Remnawave panel if requested
if request.deactivate_in_panel and user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid} for subscription reset')
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user during subscription reset: {e}')
# Delete subscription from database
from sqlalchemy import delete
await db.execute(delete(Subscription).where(Subscription.user_id == user_id))
subscription_deleted = True
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} reset subscription for user {user_id}{reason_text}')
return ResetSubscriptionResponse(
success=True,
message='Subscription reset successfully',
subscription_deleted=subscription_deleted,
panel_deactivated=panel_deactivated,
panel_error=panel_error,
)
@router.post('/{user_id}/disable', response_model=DisableUserResponse)
async def disable_user(
user_id: int,
request: DisableUserRequest = DisableUserRequest(),
admin: User = Depends(get_current_admin_user),
db: AsyncSession = Depends(get_cabinet_db),
):
"""
Disable user - deactivates subscription and blocks access.
Actions:
- Deactivate subscription in bot database
- Deactivate in Remnawave panel
- Block user account
"""
user = await get_user_by_id(db, user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='User not found',
)
subscription_deactivated = False
panel_deactivated = False
panel_error: str | None = None
# Deactivate subscription in panel
if user.remnawave_uuid:
try:
from app.services.subscription_service import SubscriptionService
subscription_service = SubscriptionService()
panel_deactivated = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
if panel_deactivated:
logger.info(f'Disabled Remnawave user {user.remnawave_uuid}')
except Exception as e:
panel_error = str(e)
logger.warning(f'Failed to disable Remnawave user: {e}')
# Deactivate subscription in bot database
if user.subscription:
from app.database.crud.subscription import deactivate_subscription
await deactivate_subscription(db, user.subscription)
subscription_deactivated = True
logger.info(f'Deactivated subscription for user {user_id}')
# Block user account
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.utcnow()
await db.commit()
reason_text = f' (reason: {request.reason})' if request.reason else ''
logger.info(f'Admin {admin.id} disabled user {user_id}{reason_text}')
return DisableUserResponse(
success=True,
message='User disabled successfully',
subscription_deactivated=subscription_deactivated,
panel_deactivated=panel_deactivated,
user_blocked=True,
panel_error=panel_error,
)
# === User Referrals ===
+49
View File
@@ -33,6 +33,15 @@ class PromocodeActivateResponse(BaseModel):
bonus_description: str | None = None
class PromocodeDeactivateResponse(BaseModel):
"""Response after deactivating a discount promo code."""
success: bool
message: str
deactivated_code: str | None = None
discount_percent: int = 0
@router.post('/activate', response_model=PromocodeActivateResponse)
async def activate_promocode(
request: PromocodeActivateRequest,
@@ -62,6 +71,8 @@ async def activate_promocode(
'expired': 'Promo code has expired',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
'not_first_purchase': 'This promo code is only available for first purchase',
'user_not_found': 'User not found',
'server_error': 'Server error occurred',
}
@@ -73,3 +84,41 @@ async def activate_promocode(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
@router.post('/deactivate-discount', response_model=PromocodeDeactivateResponse)
async def deactivate_discount_promocode(
user: User = Depends(get_current_cabinet_user),
db: AsyncSession = Depends(get_cabinet_db),
) -> PromocodeDeactivateResponse:
"""Deactivate the currently active discount promo code for the current user."""
promocode_service = PromoCodeService()
result = await promocode_service.deactivate_discount_promocode(
db=db,
user_id=user.id,
admin_initiated=False,
)
if result['success']:
return PromocodeDeactivateResponse(
success=True,
message='Discount promo code deactivated successfully',
deactivated_code=result.get('deactivated_code'),
discount_percent=result.get('discount_percent', 0),
)
error_messages = {
'user_not_found': 'User not found',
'no_active_discount_promocode': 'No active discount promo code found',
'discount_already_expired': 'Discount has already expired',
'server_error': 'Server error occurred',
}
error_code = result.get('error', 'server_error')
error_message = error_messages.get(error_code, 'Failed to deactivate promo code')
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=error_message,
)
File diff suppressed because it is too large Load Diff
+6
View File
@@ -194,6 +194,12 @@ class TariffUpdateRequest(BaseModel):
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
class TariffSortOrderRequest(BaseModel):
"""Request to reorder tariffs."""
tariff_ids: list[int] = Field(..., min_length=1, description='Ordered list of tariff IDs')
class TariffToggleResponse(BaseModel):
"""Response after toggling tariff."""
+69
View File
@@ -498,3 +498,72 @@ class PanelSyncStatusResponse(BaseModel):
# Differences
has_differences: bool = False
differences: list[str] = []
# === Admin User Management Actions ===
class FullDeleteUserRequest(BaseModel):
"""Request for full user deletion (bot + panel)."""
delete_from_panel: bool = Field(default=True, description='Also delete user from Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for deletion')
class FullDeleteUserResponse(BaseModel):
"""Response after full user deletion."""
success: bool
message: str
deleted_from_bot: bool = False
deleted_from_panel: bool = False
panel_error: str | None = None
class ResetTrialRequest(BaseModel):
"""Request to reset user trial."""
reason: str | None = Field(None, max_length=500, description='Reason for trial reset')
class ResetTrialResponse(BaseModel):
"""Response after trial reset."""
success: bool
message: str
subscription_deleted: bool = False
has_used_trial_reset: bool = False
class ResetSubscriptionRequest(BaseModel):
"""Request to reset user subscription."""
deactivate_in_panel: bool = Field(default=True, description='Also deactivate in Remnawave panel')
reason: str | None = Field(None, max_length=500, description='Reason for subscription reset')
class ResetSubscriptionResponse(BaseModel):
"""Response after subscription reset."""
success: bool
message: str
subscription_deleted: bool = False
panel_deactivated: bool = False
panel_error: str | None = None
class DisableUserRequest(BaseModel):
"""Request to disable user."""
reason: str | None = Field(None, max_length=500, description='Reason for disabling')
class DisableUserResponse(BaseModel):
"""Response after user disable."""
success: bool
message: str
subscription_deactivated: bool = False
panel_deactivated: bool = False
user_blocked: bool = False
panel_error: str | None = None
+30
View File
@@ -192,6 +192,36 @@ async def delete_promocode(db: AsyncSession, promocode: PromoCode) -> bool:
return False
async def get_active_discount_promocode_for_user(
db: AsyncSession, user_id: int
) -> tuple[PromoCode | None, PromoCodeUse | None]:
"""
Находит активный промокод на скидку, который сейчас действует у пользователя.
Ищет по source-полю пользователя (формат 'promocode:CODE').
Returns:
Кортеж (PromoCode, PromoCodeUse) или (None, None) если ничего не найдено.
"""
from app.database.models import User as UserModel
user_result = await db.execute(select(UserModel).where(UserModel.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
return None, None
source = getattr(user, 'promo_offer_discount_source', None)
if not source or not source.startswith('promocode:'):
return None, None
code = source.split(':', 1)[1]
promocode = await get_promocode_by_code(db, code)
if not promocode:
return None, None
use = await get_promocode_use_by_user_and_code(db, user_id, promocode.id)
return promocode, use
async def get_promocode_statistics(db: AsyncSession, promocode_id: int) -> dict:
total_uses_result = await db.execute(
select(func.count(PromoCodeUse.id)).where(PromoCodeUse.promocode_id == promocode_id)
+13 -1
View File
@@ -258,6 +258,10 @@ async def replace_subscription(
await db.commit()
await db.refresh(subscription)
# Очищаем старые записи об отправленных уведомлениях при замене подписки
# (аналогично extend_subscription), чтобы новые уведомления отправлялись корректно
await clear_notifications(db, subscription.id)
if update_server_counters:
try:
from app.database.crud.server_squad import (
@@ -710,7 +714,10 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.status == SubscriptionStatus.ACTIVE.value,
@@ -723,6 +730,11 @@ async def get_subscriptions_for_autopay(db: AsyncSession) -> list[Subscription]:
ready_for_autopay = []
for subscription in all_autopay_subscriptions:
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay на них не распространяется
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
continue
days_until_expiry = (subscription.end_date - current_time).days
if days_until_expiry <= subscription.autopay_days_before and subscription.end_date > current_time:
+2 -2
View File
@@ -233,6 +233,8 @@ async def create_tariff(
if promo_group_ids:
promo_groups_result = await db.execute(select(PromoGroup).where(PromoGroup.id.in_(promo_group_ids)))
promo_groups = promo_groups_result.scalars().all()
# Refresh чтобы избежать lazy load в async контексте
await db.refresh(tariff, ['allowed_promo_groups'])
tariff.allowed_promo_groups = list(promo_groups)
await db.commit()
@@ -484,8 +486,6 @@ async def reorder_tariffs(
for order, tariff_id in enumerate(tariff_order):
await db.execute(update(Tariff).where(Tariff.id == tariff_id).values(display_order=order))
await db.commit()
logger.info('Изменен порядок тарифов: %s', tariff_order)
+4 -3
View File
@@ -407,16 +407,17 @@ async def add_user_balance(
# Автоматическое возобновление приостановленной суточной подписки
try:
from app.database.crud.subscription import resume_daily_subscription
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
from app.database.crud.tariff import get_tariff_by_id
from app.database.models import SubscriptionStatus
subscription = user.subscription
# Загружаем подписку явно, чтобы избежать lazy loading
subscription = await get_subscription_by_user_id(db, user.id)
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
# Проверяем что это суточный тариф
is_daily = getattr(subscription, 'is_daily_tariff', False)
if is_daily and subscription.tariff_id:
# Загружаем тариф явно, чтобы избежать lazy loading
# Загружаем тариф явно
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
+1
View File
@@ -2,6 +2,7 @@
from . import (
backup,
blacklist,
blocked_users,
bot_configuration,
bulk_ban,
campaigns,
+728
View File
@@ -0,0 +1,728 @@
"""
Хендлеры админ-панели для управления заблокированными пользователями.
Позволяет сканировать пользователей, выявлять тех, кто заблокировал бота,
и выполнять очистку БД и панели Remnawave.
"""
import logging
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.services.blocked_users_service import (
BlockCheckResult,
BlockedUserAction,
BlockedUsersService,
)
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
# =============================================================================
# Enums для текстов и callback_data
# =============================================================================
class BlockedUsersText(Enum):
"""Тексты для сообщений модуля заблокированных пользователей."""
MENU_TITLE = '🔒 <b>Проверка заблокированных пользователей</b>'
MENU_DESCRIPTION = (
'\n\nЗдесь вы можете проверить, какие пользователи заблокировали бота, '
'и очистить их из базы данных и панели Remnawave.\n\n'
'<b>Как это работает:</b>\n'
'1. Сканирование отправляет тестовый запрос каждому пользователю\n'
'2. Если пользователь заблокировал бота - получаем ошибку\n'
'3. Можно удалить таких пользователей из БД и/или Remnawave'
)
SCAN_STARTED = '🔄 <b>Сканирование запущено...</b>\n\nЭто может занять несколько минут.'
SCAN_PROGRESS = '🔄 <b>Сканирование:</b> {checked}/{total} ({percent}%)'
SCAN_COMPLETE = (
'✅ <b>Сканирование завершено</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Проверено: {total_checked}\n'
'• Заблокировали бота: {blocked_count}\n'
'• Активных: {active_users}\n'
'• Ошибок: {errors}\n'
'• Без Telegram ID: {skipped}\n\n'
'⏱ Время сканирования: {duration:.1f}с'
)
SCAN_NO_BLOCKED = '✅ <b>Отлично!</b>\n\nНе найдено пользователей, заблокировавших бота.'
BLOCKED_LIST_TITLE = '🔒 <b>Заблокированные пользователи</b> ({count})\n\n'
BLOCKED_USER_ROW = '{name} (ID: <code>{telegram_id}</code>)\n'
CLEANUP_CONFIRM_TITLE = '⚠️ <b>Подтверждение действия</b>\n\n'
CLEANUP_CONFIRM_DELETE_DB = (
'Вы собираетесь <b>удалить из БД</b> {count} пользователей.\n'
'Это действие необратимо!\n\n'
'Будут удалены:\n'
'• Профили пользователей\n'
'• Подписки\n'
'• Транзакции\n'
'• Реферальные данные'
)
CLEANUP_CONFIRM_DELETE_REMNAWAVE = (
'Вы собираетесь <b>удалить из Remnawave</b> {count} пользователей.\nИх VPN доступ будет полностью отключен.'
)
CLEANUP_CONFIRM_DELETE_BOTH = (
'Вы собираетесь <b>полностью удалить</b> {count} пользователей:\n'
'• Из базы данных бота\n'
'• Из панели Remnawave\n\n'
'Это действие необратимо!'
)
CLEANUP_CONFIRM_MARK = (
'Вы собираетесь <b>пометить как заблокированных</b> {count} пользователей.\n'
'Они останутся в БД, но будут помечены статусом "blocked".'
)
CLEANUP_PROGRESS = '🗑 <b>Очистка:</b> {processed}/{total}'
CLEANUP_COMPLETE = (
'✅ <b>Очистка завершена</b>\n\n'
'📊 <b>Результаты:</b>\n'
'• Удалено из БД: {deleted_db}\n'
'• Удалено из Remnawave: {deleted_remnawave}\n'
'• Помечено как заблокированные: {marked}\n'
'• Ошибок: {errors}'
)
BUTTON_START_SCAN = '🔍 Начать сканирование'
BUTTON_VIEW_BLOCKED = '👥 Список заблокированных ({count})'
BUTTON_DELETE_DB = '🗑 Удалить из БД'
BUTTON_DELETE_REMNAWAVE = '🌐 Удалить из Remnawave'
BUTTON_DELETE_BOTH = '💀 Удалить везде'
BUTTON_MARK_BLOCKED = '🚫 Пометить как заблокированных'
BUTTON_CONFIRM = '✅ Подтвердить'
BUTTON_CANCEL = '❌ Отмена'
BUTTON_BACK = '⬅️ Назад'
BUTTON_BACK_TO_USERS = '⬅️ К пользователям'
class BlockedUsersCallback(Enum):
"""Callback data для кнопок модуля."""
MENU = 'admin_blocked_users'
START_SCAN = 'admin_blocked_scan'
VIEW_LIST = 'admin_blocked_list'
VIEW_LIST_PAGE = 'admin_blocked_list_page_'
ACTION_DELETE_DB = 'admin_blocked_action_db'
ACTION_DELETE_REMNAWAVE = 'admin_blocked_action_rw'
ACTION_DELETE_BOTH = 'admin_blocked_action_both'
ACTION_MARK = 'admin_blocked_action_mark'
CONFIRM_PREFIX = 'admin_blocked_confirm_'
CANCEL = 'admin_blocked_cancel'
# =============================================================================
# FSM States
# =============================================================================
class BlockedUsersStates(StatesGroup):
"""Состояния FSM для модуля заблокированных пользователей."""
scanning = State()
viewing_results = State()
confirming_action = State()
processing_cleanup = State()
# =============================================================================
# Keyboards
# =============================================================================
def get_blocked_users_menu_keyboard(
scan_result: dict[str, Any] | None = None,
) -> InlineKeyboardMarkup:
"""Клавиатура главного меню модуля."""
buttons = [
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_START_SCAN.value,
callback_data=BlockedUsersCallback.START_SCAN.value,
)
]
]
blocked_count = scan_result.get('blocked_count', 0) if scan_result else 0
if blocked_count > 0:
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_VIEW_BLOCKED.value.format(count=blocked_count),
callback_data=BlockedUsersCallback.VIEW_LIST.value,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK_TO_USERS.value,
callback_data='admin_users',
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_blocked_list_keyboard(
page: int = 1,
total_pages: int = 1,
has_blocked: bool = True,
) -> InlineKeyboardMarkup:
"""Клавиатура списка заблокированных пользователей."""
buttons = []
# Пагинация
if total_pages > 1:
nav_row = []
if page > 1:
nav_row.append(
InlineKeyboardButton(
text='⬅️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page - 1}',
)
)
nav_row.append(
InlineKeyboardButton(
text=f'{page}/{total_pages}',
callback_data='noop',
)
)
if page < total_pages:
nav_row.append(
InlineKeyboardButton(
text='➡️',
callback_data=f'{BlockedUsersCallback.VIEW_LIST_PAGE.value}{page + 1}',
)
)
buttons.append(nav_row)
# Действия
if has_blocked:
buttons.extend(
[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_DB.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_DB.value,
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_REMNAWAVE.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_DELETE_BOTH.value,
callback_data=BlockedUsersCallback.ACTION_DELETE_BOTH.value,
),
],
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_MARK_BLOCKED.value,
callback_data=BlockedUsersCallback.ACTION_MARK.value,
),
],
]
)
buttons.append(
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_BACK.value,
callback_data=BlockedUsersCallback.MENU.value,
)
]
)
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_confirm_keyboard(action: BlockedUserAction) -> InlineKeyboardMarkup:
"""Клавиатура подтверждения действия."""
action_map = {
BlockedUserAction.DELETE_FROM_DB: 'db',
BlockedUserAction.DELETE_FROM_REMNAWAVE: 'rw',
BlockedUserAction.DELETE_BOTH: 'both',
BlockedUserAction.MARK_AS_BLOCKED: 'mark',
}
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CONFIRM.value,
callback_data=f'{BlockedUsersCallback.CONFIRM_PREFIX.value}{action_map[action]}',
),
InlineKeyboardButton(
text=BlockedUsersText.BUTTON_CANCEL.value,
callback_data=BlockedUsersCallback.CANCEL.value,
),
]
]
)
# =============================================================================
# Handlers
# =============================================================================
@admin_required
@error_handler
async def show_blocked_users_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Показывает главное меню модуля заблокированных пользователей."""
data = await state.get_data()
scan_result = data.get('blocked_users_scan_result')
text = BlockedUsersText.MENU_TITLE.value + BlockedUsersText.MENU_DESCRIPTION.value
if scan_result:
text += (
f'\n\n📊 <b>Последнее сканирование:</b>\n'
f'• Заблокированных: {scan_result.get("blocked_count", 0)}\n'
f'• Активных: {scan_result.get("active_users", 0)}'
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result),
)
await callback.answer()
@admin_required
@error_handler
async def start_scan(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Запускает сканирование пользователей."""
await state.set_state(BlockedUsersStates.scanning)
# Отправляем начальное сообщение
await callback.message.edit_text(
BlockedUsersText.SCAN_STARTED.value,
parse_mode=ParseMode.HTML,
)
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(checked: int, total: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
# Обновляем сообщение не чаще раза в 3 секунды
if (now - last_update_time).total_seconds() >= 3:
last_update_time = now
percent = int(checked / total * 100) if total > 0 else 0
try:
await callback.message.edit_text(
BlockedUsersText.SCAN_PROGRESS.value.format(
checked=checked,
total=total,
percent=percent,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass # Игнорируем ошибки обновления сообщения
# Выполняем сканирование
result = await service.scan_all_users(
db,
only_active=True,
progress_callback=progress_callback,
)
# Сериализуем результат в dict для Redis и keyboard
scan_result_dict = {
'total_checked': result.total_checked,
'blocked_count': result.blocked_count,
'active_users': result.active_users,
'errors': result.errors,
'skipped_no_telegram': result.skipped_no_telegram,
'scan_duration_seconds': result.scan_duration_seconds,
}
# Сохраняем результат в state
await state.update_data(
blocked_users_scan_result=scan_result_dict,
blocked_users_list=[
{
'user_id': u.user_id,
'telegram_id': u.telegram_id,
'username': u.username,
'full_name': u.full_name,
'remnawave_uuid': u.remnawave_uuid,
}
for u in result.blocked_users
],
)
await state.set_state(BlockedUsersStates.viewing_results)
# Формируем итоговое сообщение
if result.blocked_count == 0:
text = BlockedUsersText.SCAN_NO_BLOCKED.value
else:
text = BlockedUsersText.SCAN_COMPLETE.value.format(
total_checked=result.total_checked,
blocked_count=result.blocked_count,
active_users=result.active_users,
errors=result.errors,
skipped=result.skipped_no_telegram,
duration=result.scan_duration_seconds,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(scan_result_dict),
)
await callback.answer()
@admin_required
@error_handler
async def show_blocked_list(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
page: int = 1,
) -> None:
"""Показывает список заблокированных пользователей."""
data = await state.get_data()
blocked_list: list[dict[str, Any]] = data.get('blocked_users_list', [])
if not blocked_list:
await callback.answer('Нет заблокированных пользователей', show_alert=True)
return
# Пагинация
per_page = 15
total_pages = (len(blocked_list) + per_page - 1) // per_page
page = max(1, min(page, total_pages))
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
page_users = blocked_list[start_idx:end_idx]
text = BlockedUsersText.BLOCKED_LIST_TITLE.value.format(count=len(blocked_list))
for user_data in page_users:
name = user_data.get('full_name') or user_data.get('username') or 'Без имени'
telegram_id = user_data.get('telegram_id', '?')
text += BlockedUsersText.BLOCKED_USER_ROW.value.format(
name=name,
telegram_id=telegram_id,
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_list_keyboard(page, total_pages, bool(blocked_list)),
)
await callback.answer()
@admin_required
@error_handler
async def handle_blocked_list_pagination(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает пагинацию списка заблокированных."""
try:
page = int(callback.data.split('_')[-1])
except (ValueError, IndexError):
page = 1
await show_blocked_list(callback, db_user, state, page)
@admin_required
@error_handler
async def show_action_confirm(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
action: BlockedUserAction,
) -> None:
"""Показывает подтверждение действия."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
count = len(blocked_list)
if count == 0:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.confirming_action)
await state.update_data(pending_action=action.value)
text = BlockedUsersText.CLEANUP_CONFIRM_TITLE.value
if action == BlockedUserAction.DELETE_FROM_DB:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_DB.value.format(count=count)
elif action == BlockedUserAction.DELETE_FROM_REMNAWAVE:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_REMNAWAVE.value.format(count=count)
elif action == BlockedUserAction.DELETE_BOTH:
text += BlockedUsersText.CLEANUP_CONFIRM_DELETE_BOTH.value.format(count=count)
elif action == BlockedUserAction.MARK_AS_BLOCKED:
text += BlockedUsersText.CLEANUP_CONFIRM_MARK.value.format(count=count)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_confirm_keyboard(action),
)
await callback.answer()
@admin_required
@error_handler
async def handle_action_delete_db(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из БД."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_DB)
@admin_required
@error_handler
async def handle_action_delete_remnawave(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор удаления из Remnawave."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_FROM_REMNAWAVE)
@admin_required
@error_handler
async def handle_action_delete_both(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор полного удаления."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.DELETE_BOTH)
@admin_required
@error_handler
async def handle_action_mark(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Обрабатывает выбор пометки как заблокированных."""
await show_action_confirm(callback, db_user, state, BlockedUserAction.MARK_AS_BLOCKED)
@admin_required
@error_handler
async def handle_confirm_action(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
bot: Bot,
) -> None:
"""Выполняет подтвержденное действие."""
data = await state.get_data()
blocked_list = data.get('blocked_users_list', [])
# Определяем действие из callback_data
action_code = callback.data.replace(BlockedUsersCallback.CONFIRM_PREFIX.value, '')
action_map = {
'db': BlockedUserAction.DELETE_FROM_DB,
'rw': BlockedUserAction.DELETE_FROM_REMNAWAVE,
'both': BlockedUserAction.DELETE_BOTH,
'mark': BlockedUserAction.MARK_AS_BLOCKED,
}
action = action_map.get(action_code)
if not action:
await callback.answer('Неизвестное действие', show_alert=True)
return
if not blocked_list:
await callback.answer('Нет пользователей для обработки', show_alert=True)
return
await state.set_state(BlockedUsersStates.processing_cleanup)
# Преобразуем обратно в BlockCheckResult
blocked_results = [
BlockCheckResult(
user_id=u['user_id'],
telegram_id=u['telegram_id'],
username=u['username'],
full_name=u['full_name'],
status=None, # type: ignore
remnawave_uuid=u['remnawave_uuid'],
)
for u in blocked_list
]
service = BlockedUsersService(bot)
last_update_time = datetime.now(tz=UTC)
async def progress_callback(processed: int, total_count: int) -> None:
nonlocal last_update_time
now = datetime.now(tz=UTC)
if (now - last_update_time).total_seconds() >= 2:
last_update_time = now
try:
await callback.message.edit_text(
BlockedUsersText.CLEANUP_PROGRESS.value.format(
processed=processed,
total=total_count,
),
parse_mode=ParseMode.HTML,
)
except Exception:
pass
# Выполняем очистку
result = await service.cleanup_blocked_users(
db,
blocked_results,
action,
progress_callback=progress_callback,
)
# Очищаем сохраненные данные
await state.update_data(
blocked_users_scan_result=None,
blocked_users_list=[],
pending_action=None,
)
await state.set_state(None)
# Показываем результат
text = BlockedUsersText.CLEANUP_COMPLETE.value.format(
deleted_db=result.deleted_from_db,
deleted_remnawave=result.deleted_from_remnawave,
marked=result.marked_as_blocked,
errors=len(result.errors),
)
await callback.message.edit_text(
text,
parse_mode=ParseMode.HTML,
reply_markup=get_blocked_users_menu_keyboard(),
)
logger.info(
f'Очистка заблокированных пользователей завершена: '
f'DB={result.deleted_from_db}, RW={result.deleted_from_remnawave}, '
f'marked={result.marked_as_blocked}, errors={len(result.errors)}'
)
await callback.answer()
@admin_required
@error_handler
async def handle_cancel(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
) -> None:
"""Отменяет текущее действие и возвращает в меню."""
await state.update_data(pending_action=None)
await state.set_state(BlockedUsersStates.viewing_results)
await show_blocked_users_menu(callback, db_user, state)
# =============================================================================
# Registration
# =============================================================================
def register_handlers(dp: Dispatcher) -> None:
"""Регистрирует хендлеры модуля заблокированных пользователей."""
# Главное меню
dp.callback_query.register(
show_blocked_users_menu,
F.data == BlockedUsersCallback.MENU.value,
)
# Сканирование
dp.callback_query.register(
start_scan,
F.data == BlockedUsersCallback.START_SCAN.value,
)
# Список заблокированных
dp.callback_query.register(
show_blocked_list,
F.data == BlockedUsersCallback.VIEW_LIST.value,
)
# Пагинация списка
dp.callback_query.register(
handle_blocked_list_pagination,
F.data.startswith(BlockedUsersCallback.VIEW_LIST_PAGE.value),
)
# Выбор действий
dp.callback_query.register(
handle_action_delete_db,
F.data == BlockedUsersCallback.ACTION_DELETE_DB.value,
)
dp.callback_query.register(
handle_action_delete_remnawave,
F.data == BlockedUsersCallback.ACTION_DELETE_REMNAWAVE.value,
)
dp.callback_query.register(
handle_action_delete_both,
F.data == BlockedUsersCallback.ACTION_DELETE_BOTH.value,
)
dp.callback_query.register(
handle_action_mark,
F.data == BlockedUsersCallback.ACTION_MARK.value,
)
# Подтверждение действий
dp.callback_query.register(
handle_confirm_action,
F.data.startswith(BlockedUsersCallback.CONFIRM_PREFIX.value),
)
# Отмена
dp.callback_query.register(
handle_cancel,
F.data == BlockedUsersCallback.CANCEL.value,
)
+169 -1
View File
@@ -1015,6 +1015,10 @@ async def show_virtual_participants(
text=' Добавить',
callback_data=f'admin_contest_vp_add_{contest_id}',
),
types.InlineKeyboardButton(
text='🎭 Массовка',
callback_data=f'admin_contest_vp_mass_{contest_id}',
),
],
]
if vps:
@@ -1160,7 +1164,10 @@ async def delete_virtual_participant_handler(
lines.append('Пока нет виртуальных участников.')
rows = [
[types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}')],
[
types.InlineKeyboardButton(text=' Добавить', callback_data=f'admin_contest_vp_add_{contest_id}'),
types.InlineKeyboardButton(text='🎭 Массовка', callback_data=f'admin_contest_vp_mass_{contest_id}'),
],
]
if vps:
for v in vps:
@@ -1180,6 +1187,164 @@ async def delete_virtual_participant_handler(
)
@admin_required
@error_handler
async def start_mass_virtual_participants(
callback: types.CallbackQuery,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Начинает массовое создание виртуальных участников (массовка)."""
contest_id = int(callback.data.split('_')[-1])
await state.set_state(AdminStates.adding_mass_virtual_count)
await state.update_data(mass_vp_contest_id=contest_id)
text = """
🎭 <b>Массовка массовое создание виртуальных участников</b>
<i>Для чего это нужно?</i>
Виртуальные участники (призраки) позволяют создать видимость активности в конкурсе. Они отображаются в таблице лидеров наравне с реальными участниками, но помечаются значком 👻.
Это помогает:
Мотивировать реальных участников соревноваться
Задать планку для участия
Сделать конкурс более живым
<b>Введите количество призраков для создания:</b>
<i>(от 1 до 50)</i>
"""
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_mass_virtual_count(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Обрабатывает количество призраков для массового создания."""
try:
count = int(message.text.strip())
if count < 1 or count > 50:
await message.answer(
'❌ Введите число от 1 до 50:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
]
),
)
return
except ValueError:
await message.answer(
'❌ Введите корректное число от 1 до 50:',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_contests_ref')],
]
),
)
return
await state.update_data(mass_vp_count=count)
await state.set_state(AdminStates.adding_mass_virtual_referrals)
data = await state.get_data()
contest_id = data.get('mass_vp_contest_id')
await message.answer(
f'✅ Будет создано <b>{count}</b> призраков.\n\n'
f'<b>Введите количество рефералов у каждого:</b>\n'
f'<i>(от 1 до 100)</i>',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=f'admin_contest_vp_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def process_mass_virtual_referrals(
message: types.Message,
db_user,
db: AsyncSession,
state: FSMContext,
):
"""Создаёт массовку призраков с рандомными именами."""
import random
import string
try:
referrals_count = int(message.text.strip())
if referrals_count < 1 or referrals_count > 100:
await message.answer('❌ Введите число от 1 до 100:')
return
except ValueError:
await message.answer('❌ Введите корректное число от 1 до 100:')
return
data = await state.get_data()
contest_id = data.get('mass_vp_contest_id')
ghost_count = data.get('mass_vp_count', 1)
await state.clear()
# Генерируем и создаём призраков
created = []
for _ in range(ghost_count):
# Рандомное имя до 5 символов (буквы + цифры)
name_length = random.randint(3, 5)
name = ''.join(random.choices(string.ascii_letters + string.digits, k=name_length))
vp = await add_virtual_participant(db, contest_id, name, referrals_count)
created.append(vp)
# Показываем результат
text = f"""
<b>Массовка создана!</b>
📊 <b>Результат:</b>
Создано призраков: {len(created)}
Рефералов у каждого: {referrals_count}
Всего виртуальных рефералов: {len(created) * referrals_count}
👻 <b>Созданные призраки:</b>
"""
for vp in created[:10]:
text += f'{vp.display_name}{vp.referral_count} реф.\n'
if len(created) > 10:
text += f'<i>... и ещё {len(created) - 10}</i>\n'
await message.answer(
text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='👻 К списку призраков', callback_data=f'admin_contest_vp_{contest_id}'
)
],
[types.InlineKeyboardButton(text='⬅️ К конкурсу', callback_data=f'admin_contest_view_{contest_id}')],
]
),
)
@admin_required
@error_handler
async def start_edit_virtual_participant(
@@ -1282,7 +1447,10 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(start_add_virtual_participant, F.data.startswith('admin_contest_vp_add_'))
dp.callback_query.register(delete_virtual_participant_handler, F.data.startswith('admin_contest_vp_del_'))
dp.callback_query.register(start_edit_virtual_participant, F.data.startswith('admin_contest_vp_edit_'))
dp.callback_query.register(start_mass_virtual_participants, F.data.startswith('admin_contest_vp_mass_'))
dp.callback_query.register(show_virtual_participants, F.data.regexp(r'^admin_contest_vp_\d+$'))
dp.message.register(process_virtual_participant_name, AdminStates.adding_virtual_participant_name)
dp.message.register(process_virtual_participant_count, AdminStates.adding_virtual_participant_count)
dp.message.register(process_edit_virtual_participant_count, AdminStates.editing_virtual_participant_count)
dp.message.register(process_mass_virtual_count, AdminStates.adding_mass_virtual_count)
dp.message.register(process_mass_virtual_referrals, AdminStates.adding_mass_virtual_referrals)
+295 -143
View File
@@ -127,58 +127,78 @@ def create_broadcast_keyboard(selected_buttons: list, language: str = 'ru') -> t
async def _persist_broadcast_result(
db: AsyncSession,
broadcast_history: BroadcastHistory,
broadcast_id: int,
sent_count: int,
failed_count: int,
status: str,
) -> None:
"""Сохраняет результаты рассылки с повторной попыткой при обрыве соединения."""
"""
Сохраняет результаты рассылки в НОВОЙ сессии.
# Сохраняем ID и время завершения в локальные переменные ДО операций с БД,
# чтобы избежать обращения к атрибутам отсоединенного объекта при потере соединения
broadcast_id = broadcast_history.id
ВАЖНО: Используем свежую сессию вместо переданной, потому что за время
долгой рассылки (минуты/часы) оригинальное соединение гарантированно
закроется по таймауту PostgreSQL (idle_in_transaction_session_timeout).
Args:
broadcast_id: ID записи BroadcastHistory (не ORM-объект!)
sent_count: Количество успешно отправленных сообщений
failed_count: Количество неудачных отправок
status: Финальный статус рассылки ('completed', 'partial', 'failed')
"""
completed_at = datetime.utcnow()
max_retries = 3
retry_delay = 1.0
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
for attempt in range(1, max_retries + 1):
try:
async with AsyncSessionLocal() as session:
broadcast_history = await session.get(BroadcastHistory, broadcast_id)
if not broadcast_history:
logger.critical(
'Не удалось найти запись BroadcastHistory #%s для записи результатов',
broadcast_id,
)
return
try:
await db.commit()
return
except InterfaceError as error:
logger.warning(
'Соединение с БД потеряно при сохранении результатов рассылки, пробуем еще раз',
exc_info=error,
)
await db.rollback()
broadcast_history.sent_count = sent_count
broadcast_history.failed_count = failed_count
broadcast_history.status = status
broadcast_history.completed_at = completed_at
await session.commit()
try:
async with AsyncSessionLocal() as retry_session:
retry_history = await retry_session.get(BroadcastHistory, broadcast_id)
if not retry_history:
logger.critical(
'Не удалось найти запись BroadcastHistory #%s для повторной записи результатов',
logger.info(
'Результаты рассылки сохранены (id=%s, sent=%d, failed=%d, status=%s)',
broadcast_id,
sent_count,
failed_count,
status,
)
return
retry_history.sent_count = sent_count
retry_history.failed_count = failed_count
retry_history.status = status
retry_history.completed_at = completed_at
await retry_session.commit()
logger.info(
'Результаты рассылки успешно сохранены после повторного подключения к БД (id=%s)',
broadcast_id,
except InterfaceError as error:
logger.warning(
'Ошибка соединения при сохранении результатов рассылки (попытка %d/%d): %s',
attempt,
max_retries,
error,
)
except Exception as retry_error:
logger.critical(
'Не удалось сохранить результаты рассылки после восстановления подключения',
exc_info=retry_error,
)
if attempt < max_retries:
await asyncio.sleep(retry_delay)
retry_delay *= 2
else:
logger.critical(
'Не удалось сохранить результаты рассылки после %d попыток (id=%s)',
max_retries,
broadcast_id,
)
except Exception as error:
logger.critical(
'Неожиданная ошибка при сохранении результатов рассылки (id=%s)',
broadcast_id,
exc_info=error,
)
return
@admin_required
@@ -1113,15 +1133,38 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
media_file_id = data.get('media_file_id')
media_caption = data.get('media_caption')
# =========================================================================
# КРИТИЧНО: Извлекаем ВСЕ скалярные значения из ORM-объектов СЕЙЧАС,
# пока сессия активна. После начала рассылки соединение с БД может
# закрыться по таймауту, и любое обращение к атрибутам ORM вызовет:
# - MissingGreenlet (lazy loading вне async контекста)
# - InterfaceError (соединение закрыто)
# =========================================================================
admin_id: int = db_user.id
admin_name: str = db_user.full_name # property, читает first_name/last_name
admin_telegram_id: int | None = db_user.telegram_id
admin_language: str = db_user.language
await safe_edit_or_send_text(
callback, '📨 Начинаю рассылку...\n\n⏳ Это может занять несколько минут.', reply_markup=None, parse_mode='HTML'
callback,
'📨 <b>Подготовка рассылки...</b>\n\n⏳ Загружаю список получателей...',
reply_markup=None,
parse_mode='HTML',
)
# Загружаем пользователей и сразу извлекаем telegram_id в список
# чтобы не обращаться к ORM-объектам во время долгой рассылки
if target.startswith('custom_'):
users = await get_custom_users(db, target.replace('custom_', ''))
users_orm = await get_custom_users(db, target.replace('custom_', ''))
else:
users = await get_target_users(db, target)
users_orm = await get_target_users(db, target)
# Извлекаем только telegram_id - это всё что нужно для отправки
# Фильтруем None (email-only пользователи)
recipient_telegram_ids: list[int] = [user.telegram_id for user in users_orm if user.telegram_id is not None]
total_users_count = len(users_orm)
# Создаём запись истории рассылки
broadcast_history = BroadcastHistory(
target_type=target,
message_text=message_text,
@@ -1129,145 +1172,252 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
media_type=media_type,
media_file_id=media_file_id,
media_caption=media_caption,
total_count=len(users),
total_count=total_users_count,
sent_count=0,
failed_count=0,
admin_id=db_user.id,
admin_name=db_user.full_name,
admin_id=admin_id,
admin_name=admin_name,
status='in_progress',
)
db.add(broadcast_history)
await db.commit()
await db.refresh(broadcast_history)
# Сохраняем ID - это единственное что нам нужно после коммита
broadcast_id: int = broadcast_history.id
# =========================================================================
# С этого момента НЕ используем db сессию и ORM-объекты!
# Работаем только со скалярными значениями.
# =========================================================================
sent_count = 0
failed_count = 0
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, db_user.language)
broadcast_keyboard = create_broadcast_keyboard(selected_buttons, admin_language)
# Ограничение на количество одновременных отправок и базовая задержка между сообщениями,
# чтобы избежать перегрузки бота и лимитов Telegram при больших рассылках
max_concurrent_sends = 5
per_message_delay = 0.05
semaphore = asyncio.Semaphore(max_concurrent_sends)
# =========================================================================
# Rate limiting: Telegram допускает ~30 msg/sec для бота.
# Используем batch_size=25 + 1 сек задержка между батчами = ~25 msg/sec
# с запасом, чтобы не получать FloodWait.
# Semaphore=25 — все сообщения батча отправляются параллельно.
# =========================================================================
_BATCH_SIZE = 25
_BATCH_DELAY = 1.0 # секунда между батчами
_MAX_SEND_RETRIES = 3
# Обновляем прогресс каждые N батчей (не каждое сообщение — иначе FloodWait на edit_text)
_PROGRESS_UPDATE_INTERVAL = max(1, 500 // _BATCH_SIZE) # ~каждые 500 сообщений
# Минимальный интервал между обновлениями прогресса (секунды)
_PROGRESS_MIN_INTERVAL = 5.0
async def send_single_broadcast(user):
"""Отправляет одно сообщение рассылки с семафором ограничения"""
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Пропуск email-пользователя %s при рассылке', user.id)
return False, None
# Глобальная пауза при FloodWait — тормозим ВСЕ отправки, а не один слот семафора
flood_wait_until: float = 0.0
async with semaphore:
for attempt in range(3):
try:
if has_media and media_file_id:
if media_type == 'photo':
await callback.bot.send_photo(
chat_id=user.telegram_id,
photo=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
elif media_type == 'video':
await callback.bot.send_video(
chat_id=user.telegram_id,
video=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
elif media_type == 'document':
await callback.bot.send_document(
chat_id=user.telegram_id,
document=media_file_id,
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
async def send_single_broadcast(telegram_id: int) -> bool:
"""Отправляет одно сообщение. Возвращает True при успехе."""
nonlocal flood_wait_until
for attempt in range(_MAX_SEND_RETRIES):
# Глобальная пауза при FloodWait
now = asyncio.get_event_loop().time()
if flood_wait_until > now:
await asyncio.sleep(flood_wait_until - now)
try:
if has_media and media_file_id:
send_method = {
'photo': callback.bot.send_photo,
'video': callback.bot.send_video,
'document': callback.bot.send_document,
}.get(media_type)
if send_method:
media_kwarg = {
'photo': 'photo',
'video': 'video',
'document': 'document',
}[media_type]
await send_method(
chat_id=telegram_id,
**{media_kwarg: media_file_id},
caption=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
else:
# Неизвестный media_type — отправляем как текст
await callback.bot.send_message(
chat_id=user.telegram_id,
chat_id=telegram_id,
text=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
await asyncio.sleep(per_message_delay)
return True, user.telegram_id
except TelegramRetryAfter as e:
retry_delay = min(e.retry_after + 1, 30)
logger.warning(f'Превышен лимит Telegram для {user.telegram_id}, ожидание {retry_delay} сек.')
await asyncio.sleep(retry_delay)
except TelegramForbiddenError:
# Пользователь мог удалить бота или запретить сообщения
logger.info(f'Рассылка недоступна для пользователя {user.telegram_id}: Forbidden')
return False, user.telegram_id
except TelegramBadRequest as e:
logger.error(f'Некорректный запрос при рассылке пользователю {user.telegram_id}: {e}')
return False, user.telegram_id
except Exception as e:
logger.error(
f'Ошибка отправки рассылки пользователю {user.telegram_id} (попытка {attempt + 1}/3): {e}'
else:
await callback.bot.send_message(
chat_id=telegram_id,
text=message_text,
parse_mode='HTML',
reply_markup=broadcast_keyboard,
)
return True
except TelegramRetryAfter as e:
# Глобальная пауза — тормозим все корутины
wait_seconds = e.retry_after + 1
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
logger.warning(
'FloodWait: Telegram просит подождать %d сек (пользователь %d, попытка %d/%d)',
e.retry_after,
telegram_id,
attempt + 1,
_MAX_SEND_RETRIES,
)
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return False
except TelegramBadRequest as e:
logger.debug('BadRequest при рассылке пользователю %d: %s', telegram_id, e)
return False
except Exception as e:
logger.error(
'Ошибка отправки пользователю %d (попытка %d/%d): %s',
telegram_id,
attempt + 1,
_MAX_SEND_RETRIES,
e,
)
if attempt < _MAX_SEND_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return False, user.telegram_id
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 50
for i in range(0, len(users), batch_size):
batch = users[i : i + batch_size]
tasks = [send_single_broadcast(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
# =========================================================================
# Прогресс-бар в реальном времени (как в сканере заблокированных)
# =========================================================================
total_recipients = len(recipient_telegram_ids)
last_progress_update: float = 0.0
# ID сообщения, которое обновляем (может быть заменено при ошибке)
progress_message = callback.message
def _build_progress_text(
current_sent: int,
current_failed: int,
total: int,
phase: str = 'sending',
) -> str:
processed = current_sent + current_failed
percent = round(processed / total * 100, 1) if total > 0 else 0
bar_length = 20
filled = int(bar_length * processed / total) if total > 0 else 0
bar = '' * filled + '' * (bar_length - filled)
if phase == 'sending':
return (
f'📨 <b>Рассылка в процессе...</b>\n\n'
f'[{bar}] {percent}%\n\n'
f'📊 <b>Прогресс:</b>\n'
f'• Отправлено: {current_sent}\n'
f'• Ошибок: {current_failed}\n'
f'• Обработано: {processed}/{total}\n\n'
f'⏳ Не закрывайте диалог — рассылка продолжается...'
)
return ''
async def _update_progress_message(current_sent: int, current_failed: int) -> None:
"""Безопасно обновляет сообщение с прогрессом."""
nonlocal last_progress_update, progress_message
now = asyncio.get_event_loop().time()
if now - last_progress_update < _PROGRESS_MIN_INTERVAL:
return
last_progress_update = now
text = _build_progress_text(current_sent, current_failed, total_recipients)
try:
await progress_message.edit_text(text, parse_mode='HTML')
except TelegramRetryAfter as e:
# Не паникуем — пропускаем обновление прогресса
logger.debug('FloodWait при обновлении прогресса, пропускаем: %d сек', e.retry_after)
except TelegramBadRequest:
# Сообщение удалено или контент не изменился — отправляем новое
try:
progress_message = await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=text,
parse_mode='HTML',
)
except Exception:
pass
except Exception:
pass # Не ломаем рассылку из-за ошибок обновления прогресса
# Первое обновление прогресса
await _update_progress_message(0, 0)
# =========================================================================
# Основной цикл рассылки — батчами по _BATCH_SIZE
# =========================================================================
for batch_idx, i in enumerate(range(0, total_recipients, _BATCH_SIZE)):
batch = recipient_telegram_ids[i : i + _BATCH_SIZE]
# Отправляем батч параллельно
results = await asyncio.gather(
*[send_single_broadcast(tid) for tid in batch],
return_exceptions=True,
)
for result in results:
if isinstance(result, tuple): # (success, telegram_id)
success, _ = result
if success:
if isinstance(result, bool):
if result:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
logger.error('Необработанное исключение в рассылке: %s', result)
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.25)
# Обновляем прогресс каждые _PROGRESS_UPDATE_INTERVAL батчей
if batch_idx % _PROGRESS_UPDATE_INTERVAL == 0:
await _update_progress_message(sent_count, failed_count)
# Задержка между батчами для соблюдения rate limits
await asyncio.sleep(_BATCH_DELAY)
# Учитываем пропущенных email-only пользователей
skipped_email_users = total_users_count - total_recipients
if skipped_email_users > 0:
logger.info('Пропущено %d email-only пользователей при рассылке', skipped_email_users)
status = 'completed' if failed_count == 0 else 'partial'
# Сохраняем результат в НОВОЙ сессии (старая уже мертва)
await _persist_broadcast_result(
db=db,
broadcast_history=broadcast_history,
broadcast_id=broadcast_id,
sent_count=sent_count,
failed_count=failed_count,
status=status,
)
media_info = ''
if has_media:
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}'
success_rate = round(sent_count / total_users_count * 100, 1) if total_users_count else 0
media_info = f'\n🖼️ <b>Медиафайл:</b> {media_type}' if has_media else ''
result_text = f"""
<b>Рассылка завершена!</b>
result_text = (
f'✅ <b>Рассылка завершена!</b>\n\n'
f'📊 <b>Результат:</b>\n'
f'• Отправлено: {sent_count}\n'
f'• Не доставлено: {failed_count}\n'
f'• Всего пользователей: {total_users_count}\n'
f'• Успешность: {success_rate}%{media_info}\n\n'
f'<b>Администратор:</b> {admin_name}'
)
📊 <b>Результат:</b>
- Отправлено: {sent_count}
- Не доставлено: {failed_count}
- Всего пользователей: {len(users)}
- Успешность: {round(sent_count / len(users) * 100, 1) if users else 0}%{media_info}
<b>Администратор:</b> {db_user.full_name}
"""
back_keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
)
try:
await callback.message.edit_text(
result_text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]]
),
parse_mode='HTML',
)
await progress_message.edit_text(result_text, reply_markup=back_keyboard, parse_mode='HTML')
except TelegramBadRequest as e:
error_msg = str(e).lower()
if (
@@ -1275,22 +1425,24 @@ async def confirm_broadcast(callback: types.CallbackQuery, db_user: User, state:
or 'there is no text' in error_msg
or "message can't be edited" in error_msg
):
# Сообщение удалено или это медиа - отправляем новое
await callback.bot.send_message(
chat_id=callback.message.chat.id,
text=result_text,
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='📨 К рассылкам', callback_data='admin_messages')]
]
),
reply_markup=back_keyboard,
parse_mode='HTML',
)
else:
raise
await state.clear()
logger.info(f'Рассылка выполнена админом {db_user.telegram_id}: {sent_count}/{len(users)} (медиа: {has_media})')
logger.info(
'Рассылка завершена админом %s: sent=%d, failed=%d, total=%d (медиа: %s)',
admin_telegram_id,
sent_count,
failed_count,
total_users_count,
has_media,
)
async def get_target_users_count(db: AsyncSession, target: str) -> int:
+250 -1
View File
@@ -42,6 +42,10 @@ def _method_display(method: PaymentMethod) -> str:
return 'CryptoBot'
if method == PaymentMethod.TELEGRAM_STARS:
return 'Telegram Stars'
if method == PaymentMethod.KASSA_AI:
return settings.get_kassa_ai_display_name()
if method == PaymentMethod.FREEKASSA:
return settings.get_freekassa_display_name()
return method.value
@@ -144,6 +148,18 @@ def _status_info(
}
return mapping.get(status, ('', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
if record.method == PaymentMethod.KASSA_AI:
mapping = {
'pending': ('', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
'created': ('', texts.t('ADMIN_PAYMENT_STATUS_PENDING', '⏳ Pending')),
'processing': ('', texts.t('ADMIN_PAYMENT_STATUS_PROCESSING', '⌛ Processing')),
'success': ('', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
'paid': ('', texts.t('ADMIN_PAYMENT_STATUS_PAID', '✅ Paid')),
'canceled': ('', texts.t('ADMIN_PAYMENT_STATUS_CANCELED', '❌ Cancelled')),
'error': ('', texts.t('ADMIN_PAYMENT_STATUS_FAILED', '❌ Failed')),
}
return mapping.get(status, ('', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')))
return '', texts.t('ADMIN_PAYMENT_STATUS_UNKNOWN', '❓ Unknown')
@@ -168,7 +184,9 @@ def _is_checkable(record: PendingPayment) -> bool:
if record.method == PaymentMethod.CRYPTOBOT:
return status in {'active'}
if record.method == PaymentMethod.FREEKASSA:
return status in {'pending', ''}
return status in {'pending', 'created', ''}
if record.method == PaymentMethod.KASSA_AI:
return status in {'pending', 'created', 'processing', ''}
return False
@@ -184,6 +202,7 @@ def _build_list_keyboard(
page: int,
total_pages: int,
language: str,
has_checkable: bool = False,
) -> InlineKeyboardMarkup:
buttons: list[list[InlineKeyboardButton]] = []
texts = get_texts(language)
@@ -204,6 +223,28 @@ def _build_list_keyboard(
]
)
# Кнопка "Проверить все" если есть что проверять
if has_checkable:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PAYMENTS_CHECK_ALL', '🔄 Проверить все'),
callback_data='admin_payments_check_all',
)
]
)
# Кнопка экспорта если есть платежи
if records:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('ADMIN_PAYMENTS_EXPORT', '📥 Выгрузить в файл'),
callback_data='admin_payments_export',
)
]
)
if total_pages > 1:
navigation_row: list[InlineKeyboardButton] = []
if page > 1:
@@ -485,11 +526,22 @@ async def show_payments_overview(
lines = [header, '', description]
# Проверяем есть ли платежи для массовой проверки
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
has_checkable = len(checkable_records) > 0
if page_records:
for idx, record in enumerate(page_records, start=start_index + 1):
lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
lines.append('')
lines.append(notice)
if has_checkable:
lines.append('')
lines.append(
texts.t('ADMIN_PAYMENTS_CHECKABLE_COUNT', '🔄 Доступно для проверки: {count}').format(
count=len(checkable_records)
)
)
else:
empty_text = texts.t('ADMIN_PAYMENTS_EMPTY', 'No pending top-ups in the last 24 hours.')
lines.append('')
@@ -500,6 +552,7 @@ async def show_payments_overview(
page=page,
total_pages=total_pages,
language=db_user.language,
has_checkable=has_checkable,
)
await callback.message.edit_text(
@@ -550,28 +603,42 @@ async def manual_check_payment(
db_user: User,
db: AsyncSession,
) -> None:
import logging
logger = logging.getLogger(__name__)
logger.info('manual_check_payment called: %s', callback.data)
parsed = _parse_method_and_id(callback.data, prefix='admin_payment_check_')
if not parsed:
logger.warning('Failed to parse: %s', callback.data)
await callback.answer('❌ Invalid payment reference', show_alert=True)
return
method, payment_id = parsed
logger.info('Checking payment: method=%s, id=%s', method, payment_id)
record = await get_payment_record(db, method, payment_id)
texts = get_texts(db_user.language)
if not record:
logger.warning('Payment not found: method=%s, id=%s', method, payment_id)
await callback.answer(texts.t('ADMIN_PAYMENT_NOT_FOUND', 'Payment not found.'), show_alert=True)
return
logger.info('Record found: status=%s, is_paid=%s', record.status, record.is_paid)
if not _is_checkable(record):
logger.info('Payment not checkable: method=%s, status=%s', method, record.status)
await callback.answer(
texts.t('ADMIN_PAYMENT_CHECK_NOT_AVAILABLE', 'Manual check is not available for this invoice.'),
show_alert=True,
)
return
logger.info('Running manual check...')
payment_service = PaymentService(callback.bot)
updated = await run_manual_check(db, method, payment_id, payment_service)
logger.info('Check result: updated=%s', updated is not None)
if not updated:
await callback.answer(
@@ -597,7 +664,189 @@ async def manual_check_payment(
await callback.answer(message, show_alert=True)
@admin_required
@error_handler
async def check_all_payments(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
"""Массовая проверка всех ожидающих платежей."""
import logging
logger = logging.getLogger(__name__)
logger.info('check_all_payments called')
texts = get_texts(db_user.language)
# Получаем все ожидающие платежи
records = await list_recent_pending_payments(db)
logger.info('Found %d total records', len(records))
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
logger.info('Found %d checkable records', len(checkable_records))
if not checkable_records:
await callback.answer(
texts.t('ADMIN_PAYMENTS_NO_CHECKABLE', 'Нет платежей для проверки'),
show_alert=True,
)
return
await callback.answer(
texts.t('ADMIN_PAYMENTS_CHECKING_ALL', '🔄 Проверяю {count} платежей...').format(count=len(checkable_records)),
)
payment_service = PaymentService(callback.bot)
checked = 0
confirmed = 0
failed = 0
for record in checkable_records:
try:
logger.info('Checking %s payment id=%s', record.method.value, record.local_id)
updated = await run_manual_check(db, record.method, record.local_id, payment_service)
checked += 1
logger.info('Check result: is_paid=%s', updated.is_paid if updated else None)
if updated and updated.is_paid and not record.is_paid:
confirmed += 1
except Exception as e:
logger.error('Check failed for %s id=%s: %s', record.method.value, record.local_id, e, exc_info=True)
failed += 1
logger.info('Check complete: checked=%d, confirmed=%d, failed=%d', checked, confirmed, failed)
# Показываем результат
result_lines = [
texts.t('ADMIN_PAYMENTS_CHECK_ALL_RESULT', '🔄 <b>Результат проверки</b>'),
'',
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CHECKED', '✅ Проверено: {count}').format(count=checked),
texts.t('ADMIN_PAYMENTS_CHECK_ALL_CONFIRMED', '💰 Подтверждено: {count}').format(count=confirmed),
]
if failed:
result_lines.append(texts.t('ADMIN_PAYMENTS_CHECK_ALL_FAILED', '❌ Ошибок: {count}').format(count=failed))
# Перезагружаем список платежей
records = await list_recent_pending_payments(db)
total = len(records)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
page_records = records[:PAGE_SIZE]
checkable_records = [r for r in records if _is_checkable(r) and not r.is_paid]
result_lines.append('')
result_lines.append(texts.t('ADMIN_PAYMENTS_TITLE', '💳 <b>Top-up verification</b>'))
if page_records:
result_lines.append('')
for idx, record in enumerate(page_records, start=1):
result_lines.extend(_build_record_lines(record, index=idx, texts=texts, language=db_user.language))
result_lines.append('')
keyboard = _build_list_keyboard(
page_records,
page=1,
total_pages=total_pages,
language=db_user.language,
has_checkable=len(checkable_records) > 0,
)
logger.info('Updating message with results...')
try:
await callback.message.edit_text(
'\n'.join(result_lines),
parse_mode='HTML',
reply_markup=keyboard,
)
logger.info('Message updated successfully')
except Exception as e:
logger.error('Failed to update message: %s', e, exc_info=True)
@admin_required
@error_handler
async def export_payments(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
) -> None:
"""Экспорт данных платежей в JSON файл."""
import json
from aiogram.types import BufferedInputFile
texts = get_texts(db_user.language)
records = await list_recent_pending_payments(db)
if not records:
await callback.answer(
texts.t('ADMIN_PAYMENTS_EXPORT_EMPTY', 'Нет платежей для экспорта'),
show_alert=True,
)
return
# Формируем данные для экспорта
export_data = []
for record in records:
payment = record.payment
user = record.user
payment_data = {
'id': record.local_id,
'method': record.method.value,
'method_display': _method_display(record.method),
'identifier': record.identifier,
'amount_kopeks': record.amount_kopeks,
'amount_rubles': record.amount_kopeks / 100,
'status': record.status,
'is_paid': record.is_paid,
'created_at': record.created_at.isoformat() if record.created_at else None,
'expires_at': record.expires_at.isoformat() if record.expires_at else None,
'user': {
'id': user.id,
'telegram_id': user.telegram_id,
'username': user.username,
'full_name': user.full_name,
},
}
# Добавляем специфичные поля в зависимости от метода
if hasattr(payment, 'order_id'):
payment_data['order_id'] = payment.order_id
if hasattr(payment, 'payment_url'):
payment_data['payment_url'] = payment.payment_url
if hasattr(payment, 'callback_payload'):
payment_data['callback_payload'] = payment.callback_payload
export_data.append(payment_data)
# Создаём JSON файл
json_content = json.dumps(export_data, ensure_ascii=False, indent=2, default=str)
file_bytes = json_content.encode('utf-8')
# Отправляем файл
from datetime import datetime
filename = f'payments_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
await callback.message.answer_document(
document=BufferedInputFile(file_bytes, filename=filename),
caption=texts.t(
'ADMIN_PAYMENTS_EXPORT_CAPTION',
'📥 Экспорт платежей\n\n📊 Всего записей: {count}\n💰 Оплачено: {paid}\n⏳ Ожидают: {pending}',
).format(
count=len(export_data),
paid=sum(1 for r in export_data if r['is_paid']),
pending=sum(1 for r in export_data if not r['is_paid']),
),
)
await callback.answer(texts.t('ADMIN_PAYMENTS_EXPORT_SUCCESS', '✅ Файл отправлен'))
def register_handlers(dp: Dispatcher) -> None:
dp.callback_query.register(check_all_payments, F.data == 'admin_payments_check_all')
dp.callback_query.register(export_payments, F.data == 'admin_payments_export')
dp.callback_query.register(manual_check_payment, F.data.startswith('admin_payment_check_'))
dp.callback_query.register(
show_payment_details,
+802
View File
@@ -83,6 +83,7 @@ async def show_referral_statistics(callback: types.CallbackQuery, db_user: User,
keyboard_rows = [
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_referrals')],
[types.InlineKeyboardButton(text='👥 Топ рефереров', callback_data='admin_referrals_top')],
[types.InlineKeyboardButton(text='🔍 Диагностика логов', callback_data='admin_referral_diagnostics')],
]
# Кнопка заявок на вывод (если функция включена)
@@ -650,11 +651,812 @@ async def process_test_referral_earning(message: types.Message, db_user: User, d
)
def _get_period_dates(period: str) -> tuple[datetime.datetime, datetime.datetime]:
"""Возвращает начальную и конечную даты для заданного периода."""
now = datetime.datetime.now()
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if period == 'today':
start_date = today
end_date = today + datetime.timedelta(days=1)
elif period == 'yesterday':
start_date = today - datetime.timedelta(days=1)
end_date = today
elif period == 'week':
start_date = today - datetime.timedelta(days=7)
end_date = today + datetime.timedelta(days=1)
elif period == 'month':
start_date = today - datetime.timedelta(days=30)
end_date = today + datetime.timedelta(days=1)
else:
# По умолчанию — сегодня
start_date = today
end_date = today + datetime.timedelta(days=1)
return start_date, end_date
def _get_period_display_name(period: str) -> str:
"""Возвращает человекочитаемое название периода."""
names = {'today': 'сегодня', 'yesterday': 'вчера', 'week': '7 дней', 'month': '30 дней'}
return names.get(period, 'сегодня')
async def _show_diagnostics_for_period(callback: types.CallbackQuery, db: AsyncSession, state: FSMContext, period: str):
"""Внутренняя функция для отображения диагностики за указанный период."""
try:
await callback.answer('Анализирую логи...')
from app.services.referral_diagnostics_service import referral_diagnostics_service
# Сохраняем период в state
await state.update_data(diagnostics_period=period)
from app.states import AdminStates
await state.set_state(AdminStates.referral_diagnostics_period)
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
# Формируем отчёт
period_display = _get_period_display_name(period)
text = f"""
🔍 <b>Диагностика рефералов {period_display}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
Уникальных пользователей: {report.unique_users_clicked}
Потерянных рефералов: {len(report.lost_referrals)}
"""
if report.lost_referrals:
text += '\n<b>❌ Потерянные рефералы:</b>\n'
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
for i, lost in enumerate(report.lost_referrals[:15], 1):
# Статус пользователя
if not lost.registered:
status = '⚠️ Не в БД'
elif not lost.has_referrer:
status = '❌ Без реферера'
else:
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
# Время
time_str = lost.click_time.strftime('%H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
else:
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
# Информация о логах
log_path = referral_diagnostics_service.log_path
log_exists = log_path.exists()
log_size = log_path.stat().st_size if log_exists else 0
text += f'\n<i>📂 {log_path.name}'
if log_exists:
text += f' ({log_size / 1024:.0f} KB)'
text += f' | Строк: {report.lines_in_period}'
else:
text += ' (не найден!)'
text += '</i>'
# Кнопки: только "Сегодня" (текущий лог) и "Загрузить файл" (старые логи)
keyboard_rows = [
[
types.InlineKeyboardButton(text='📅 Сегодня (текущий лог)', callback_data='admin_ref_diag:today'),
],
[types.InlineKeyboardButton(text='📤 Загрузить лог-файл', callback_data='admin_ref_diag_upload')],
[types.InlineKeyboardButton(text='🔍 Проверить бонусы (по БД)', callback_data='admin_ref_check_bonuses')],
[
types.InlineKeyboardButton(
text='🏆 Синхронизировать с конкурсом', callback_data='admin_ref_sync_contest'
)
],
]
# Кнопки действий (только если есть потерянные рефералы)
if report.lost_referrals:
keyboard_rows.append(
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
)
keyboard_rows.extend(
[
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data=f'admin_ref_diag:{period}')],
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в _show_diagnostics_for_period: {e}', exc_info=True)
await callback.answer('Ошибка при анализе логов', show_alert=True)
@admin_required
@error_handler
async def show_referral_diagnostics(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Показывает диагностику реферальной системы по логам."""
# Определяем период из callback_data или используем "today" по умолчанию
if ':' in callback.data:
period = callback.data.split(':')[1]
else:
period = 'today'
await _show_diagnostics_for_period(callback, db, state, period)
@admin_required
@error_handler
async def preview_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Показывает предпросмотр исправлений потерянных рефералов."""
try:
await callback.answer('Анализирую...')
# Получаем период из state
state_data = await state.get_data()
period = state_data.get('diagnostics_period', 'today')
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
# Проверяем, работаем ли с загруженным файлом
if period == 'uploaded_file':
# Используем сохранённый отчёт из загруженного файла (десериализуем)
report_data = state_data.get('uploaded_file_report')
if not report_data:
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
return
report = DiagnosticReport.from_dict(report_data)
period_display = 'загруженный файл'
else:
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
period_display = _get_period_display_name(period)
if not report.lost_referrals:
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
return
# Запускаем предпросмотр исправлений
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=False)
# Формируем отчёт
text = f"""
📋 <b>Предпросмотр исправлений {period_display}</b>
<b>📊 Что будет сделано:</b>
Исправлено рефералов: {fix_report.users_fixed}
Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
Ошибок: {fix_report.errors}
<b>🔍 Детали:</b>
"""
# Показываем первые 10 деталей
for i, detail in enumerate(fix_report.details[:10], 1):
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{detail.username}'
if detail.error:
text += f'{i}. {user_name} — ❌ {detail.error}\n'
else:
text += f'{i}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
if detail.had_first_topup:
text += f' • Первое пополнение: {settings.format_price(detail.topup_amount_kopeks)}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
if len(fix_report.details) > 10:
text += f'\n<i>... и ещё {len(fix_report.details) - 10}</i>\n'
text += '\n⚠️ <b>Внимание!</b> Это только предпросмотр. Нажмите "Применить", чтобы выполнить исправления.'
# Кнопка назад зависит от источника
back_button_text = '⬅️ К диагностике'
back_button_callback = f'admin_ref_diag:{period}' if period != 'uploaded_file' else 'admin_referral_diagnostics'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='✅ Применить исправления', callback_data='admin_ref_fix_apply')],
[types.InlineKeyboardButton(text=back_button_text, callback_data=back_button_callback)],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в preview_referral_fixes: {e}', exc_info=True)
await callback.answer('Ошибка при создании предпросмотра', show_alert=True)
@admin_required
@error_handler
async def apply_referral_fixes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Применяет исправления потерянных рефералов."""
try:
await callback.answer('Применяю исправления...')
# Получаем период из state
state_data = await state.get_data()
period = state_data.get('diagnostics_period', 'today')
from app.services.referral_diagnostics_service import DiagnosticReport, referral_diagnostics_service
# Проверяем, работаем ли с загруженным файлом
if period == 'uploaded_file':
# Используем сохранённый отчёт из загруженного файла (десериализуем)
report_data = state_data.get('uploaded_file_report')
if not report_data:
await callback.answer('Отчёт загруженного файла не найден', show_alert=True)
return
report = DiagnosticReport.from_dict(report_data)
period_display = 'загруженный файл'
else:
# Получаем даты периода
start_date, end_date = _get_period_dates(period)
# Анализируем логи
report = await referral_diagnostics_service.analyze_period(db, start_date, end_date)
period_display = _get_period_display_name(period)
if not report.lost_referrals:
await callback.answer('Нет потерянных рефералов для исправления', show_alert=True)
return
# Применяем исправления
fix_report = await referral_diagnostics_service.fix_lost_referrals(db, report.lost_referrals, apply=True)
# Формируем отчёт
text = f"""
<b>Исправления применены {period_display}</b>
<b>📊 Результаты:</b>
Исправлено рефералов: {fix_report.users_fixed}
Бонусов рефералам: {settings.format_price(fix_report.bonuses_to_referrals)}
Бонусов рефереам: {settings.format_price(fix_report.bonuses_to_referrers)}
Ошибок: {fix_report.errors}
<b>🔍 Детали:</b>
"""
# Показываем первые 10 успешных деталей
success_count = 0
for detail in fix_report.details:
if not detail.error and success_count < 10:
success_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
if detail.username:
user_name = f'@{user_name}'
text += f'{success_count}. {user_name}\n'
if detail.referred_by_set:
text += f' • Реферер: {detail.referrer_name or f"ID{detail.referrer_id}"}\n'
if detail.bonus_to_referral_kopeks > 0:
text += f' • Бонус рефералу: {settings.format_price(detail.bonus_to_referral_kopeks)}\n'
if detail.bonus_to_referrer_kopeks > 0:
text += f' • Бонус рефереру: {settings.format_price(detail.bonus_to_referrer_kopeks)}\n'
if fix_report.users_fixed > 10:
text += f'\n<i>... и ещё {fix_report.users_fixed - 10} исправлений</i>\n'
# Показываем ошибки
if fix_report.errors > 0:
text += '\n<b>❌ Ошибки:</b>\n'
error_count = 0
for detail in fix_report.details:
if detail.error and error_count < 5:
error_count += 1
user_name = detail.username or detail.full_name or f'ID{detail.telegram_id}'
text += f'{user_name}: {detail.error}\n'
if fix_report.errors > 5:
text += f'<i>... и ещё {fix_report.errors - 5} ошибок</i>\n'
# Кнопки зависят от источника
keyboard_rows = []
if period != 'uploaded_file':
keyboard_rows.append(
[types.InlineKeyboardButton(text='🔄 Обновить диагностику', callback_data=f'admin_ref_diag:{period}')]
)
keyboard_rows.append([types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')])
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
await callback.message.edit_text(text, reply_markup=keyboard)
# Очищаем сохранённый отчёт из state
if period == 'uploaded_file':
await state.update_data(uploaded_file_report=None)
except Exception as e:
logger.error(f'Ошибка в apply_referral_fixes: {e}', exc_info=True)
await callback.answer('Ошибка при применении исправлений', show_alert=True)
# =============================================================================
# Проверка бонусов по БД
# =============================================================================
@admin_required
@error_handler
async def check_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Проверяет по БД — всем ли рефералам начислены бонусы."""
from app.services.referral_diagnostics_service import (
referral_diagnostics_service,
)
await callback.answer('🔍 Проверяю бонусы...')
try:
report = await referral_diagnostics_service.check_missing_bonuses(db)
# Сохраняем отчёт в state для последующего применения
await state.update_data(missing_bonuses_report=report.to_dict())
text = f"""
🔍 <b>Проверка бонусов по БД</b>
📊 <b>Статистика:</b>
Всего рефералов: {report.total_referrals_checked}
С пополнением минимума: {report.referrals_with_topup}
<b>Без бонусов: {len(report.missing_bonuses)}</b>
"""
if report.missing_bonuses:
text += f"""
💰 <b>Требуется начислить:</b>
Рефералам: {report.total_missing_to_referrals / 100:.0f}
Рефереерам: {report.total_missing_to_referrers / 100:.0f}
<b>Итого: {(report.total_missing_to_referrals + report.total_missing_to_referrers) / 100:.0f}</b>
👤 <b>Список ({len(report.missing_bonuses)} чел.):</b>
"""
for i, mb in enumerate(report.missing_bonuses[:15], 1):
referral_name = mb.referral_full_name or mb.referral_username or str(mb.referral_telegram_id)
referrer_name = mb.referrer_full_name or mb.referrer_username or str(mb.referrer_telegram_id)
text += f'\n{i}. <b>{referral_name}</b>'
text += f'\n └ Пригласил: {referrer_name}'
text += f'\n └ Пополнение: {mb.first_topup_amount_kopeks / 100:.0f}'
text += f'\n └ Бонусы: {mb.referral_bonus_amount / 100:.0f}₽ + {mb.referrer_bonus_amount / 100:.0f}'
if len(report.missing_bonuses) > 15:
text += f'\n\n<i>... и ещё {len(report.missing_bonuses) - 15} чел.</i>'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='✅ Начислить все бонусы', callback_data='admin_ref_bonus_apply')],
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
else:
text += '\n✅ <b>Все бонусы начислены!</b>'
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔄 Обновить', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в check_missing_bonuses: {e}', exc_info=True)
await callback.answer('Ошибка при проверке бонусов', show_alert=True)
@admin_required
@error_handler
async def apply_missing_bonuses(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Применяет начисление пропущенных бонусов."""
from app.services.referral_diagnostics_service import (
MissingBonusReport,
referral_diagnostics_service,
)
await callback.answer('💰 Начисляю бонусы...')
try:
# Получаем сохранённый отчёт
data = await state.get_data()
report_dict = data.get('missing_bonuses_report')
if not report_dict:
await callback.answer('❌ Отчёт не найден. Обновите проверку.', show_alert=True)
return
report = MissingBonusReport.from_dict(report_dict)
if not report.missing_bonuses:
await callback.answer('✅ Нет бонусов для начисления', show_alert=True)
return
# Применяем исправления
fix_report = await referral_diagnostics_service.fix_missing_bonuses(db, report.missing_bonuses, apply=True)
text = f"""
<b>Бонусы начислены!</b>
📊 <b>Результат:</b>
Обработано: {fix_report.users_fixed} пользователей
Начислено рефералам: {fix_report.bonuses_to_referrals / 100:.0f}
Начислено рефереерам: {fix_report.bonuses_to_referrers / 100:.0f}
<b>Итого: {(fix_report.bonuses_to_referrals + fix_report.bonuses_to_referrers) / 100:.0f}</b>
"""
if fix_report.errors > 0:
text += f'\n⚠️ Ошибок: {fix_report.errors}'
# Очищаем отчёт из state
await state.update_data(missing_bonuses_report=None)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔍 Проверить снова', callback_data='admin_ref_check_bonuses')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в apply_missing_bonuses: {e}', exc_info=True)
await callback.answer('Ошибка при начислении бонусов', show_alert=True)
@admin_required
@error_handler
async def sync_referrals_with_contest(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
"""Синхронизирует всех рефералов с активными конкурсами."""
from app.database.crud.referral_contest import get_contests_for_events
from app.services.referral_contest_service import referral_contest_service
await callback.answer('🏆 Синхронизирую с конкурсами...')
try:
from datetime import datetime
now_utc = datetime.utcnow()
# Получаем активные конкурсы
paid_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_paid'])
reg_contests = await get_contests_for_events(db, now_utc, contest_types=['referral_registered'])
all_contests = list(paid_contests) + list(reg_contests)
if not all_contests:
await callback.message.edit_text(
'❌ <b>Нет активных конкурсов рефералов</b>\n\n'
'Создайте конкурс в разделе "Конкурсы" для синхронизации.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Синхронизируем каждый конкурс
total_created = 0
total_updated = 0
total_skipped = 0
contest_results = []
for contest in all_contests:
stats = await referral_contest_service.sync_contest(db, contest.id)
if 'error' not in stats:
total_created += stats.get('created', 0)
total_updated += stats.get('updated', 0)
total_skipped += stats.get('skipped', 0)
contest_results.append(f'{contest.title}: +{stats.get("created", 0)} новых')
else:
contest_results.append(f'{contest.title}: ошибка')
text = f"""
🏆 <b>Синхронизация с конкурсами завершена!</b>
📊 <b>Результат:</b>
Конкурсов обработано: {len(all_contests)}
Новых событий добавлено: {total_created}
Обновлено: {total_updated}
Пропущено (уже есть): {total_skipped}
📋 <b>По конкурсам:</b>
"""
text += '\n'.join(contest_results)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='🔄 Синхронизировать снова', callback_data='admin_ref_sync_contest')],
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
]
)
await callback.message.edit_text(text, reply_markup=keyboard)
except Exception as e:
logger.error(f'Ошибка в sync_referrals_with_contest: {e}', exc_info=True)
await callback.answer('Ошибка при синхронизации', show_alert=True)
@admin_required
@error_handler
async def request_log_file_upload(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
"""Запрашивает загрузку лог-файла для анализа."""
await state.set_state(AdminStates.waiting_for_log_file)
text = """
📤 <b>Загрузка лог-файла для анализа</b>
Отправьте файл лога (расширение .log или .txt).
Файл будет проанализирован на наличие потерянных рефералов за ВСЕ время, записанное в логе.
<b>Важно:</b>
Файл должен быть текстовым (.log, .txt)
Максимальный размер: 50 MB
После анализа файл будет автоматически удалён
Если ротация логов удалила старые данные загрузите резервную копию.
"""
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]]
)
await callback.message.edit_text(text, reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def receive_log_file(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
"""Получает и анализирует загруженный лог-файл."""
import tempfile
from pathlib import Path
if not message.document:
await message.answer(
'❌ Пожалуйста, отправьте файл документом.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Проверяем расширение файла
file_name = message.document.file_name or 'unknown'
file_ext = Path(file_name).suffix.lower()
if file_ext not in ['.log', '.txt']:
await message.answer(
f'❌ Неверный формат файла: {file_ext}\n\nПоддерживаются только текстовые файлы (.log, .txt)',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Проверяем размер файла
max_size = 50 * 1024 * 1024 # 50 MB
if message.document.file_size > max_size:
await message.answer(
f'❌ Файл слишком большой: {message.document.file_size / 1024 / 1024:.1f} MB\n\nМаксимальный размер: 50 MB',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='❌ Отмена', callback_data='admin_referral_diagnostics')]
]
),
)
return
# Информируем о начале загрузки
status_message = await message.answer(
f'📥 Загружаю файл {file_name} ({message.document.file_size / 1024 / 1024:.1f} MB)...'
)
temp_file_path = None
try:
# Скачиваем файл во временную директорию
temp_dir = tempfile.gettempdir()
temp_file_path = str(Path(temp_dir) / f'ref_diagnostics_{message.from_user.id}_{file_name}')
# Скачиваем файл
file = await message.bot.get_file(message.document.file_id)
await message.bot.download_file(file.file_path, temp_file_path)
logger.info(f'📥 Файл загружен: {temp_file_path} ({message.document.file_size} байт)')
# Обновляем статус
await status_message.edit_text(f'🔍 Анализирую файл {file_name}...\n\nЭто может занять некоторое время.')
# Анализируем файл
from app.services.referral_diagnostics_service import referral_diagnostics_service
report = await referral_diagnostics_service.analyze_file(db, temp_file_path)
# Формируем отчёт
text = f"""
🔍 <b>Анализ лог-файла: {file_name}</b>
<b>📊 Статистика переходов:</b>
Всего кликов по реф-ссылкам: {report.total_ref_clicks}
Уникальных пользователей: {report.unique_users_clicked}
Потерянных рефералов: {len(report.lost_referrals)}
Строк в файле: {report.lines_in_period}
"""
if report.lost_referrals:
text += '\n<b>❌ Потерянные рефералы:</b>\n'
text += '<i>(пришли по ссылке, но реферер не засчитался)</i>\n\n'
for i, lost in enumerate(report.lost_referrals[:15], 1):
# Статус пользователя
if not lost.registered:
status = '⚠️ Не в БД'
elif not lost.has_referrer:
status = '❌ Без реферера'
else:
status = f'⚡ Другой реферер (ID{lost.current_referrer_id})'
# Имя или ID
user_name = lost.username or lost.full_name or f'ID{lost.telegram_id}'
if lost.username:
user_name = f'@{lost.username}'
# Ожидаемый реферер
referrer_info = ''
if lost.expected_referrer_name:
referrer_info = f'{lost.expected_referrer_name}'
elif lost.expected_referrer_id:
referrer_info = f' → ID{lost.expected_referrer_id}'
# Время
time_str = lost.click_time.strftime('%d.%m.%Y %H:%M')
text += f'{i}. {user_name}{status}\n'
text += f' <code>{lost.referral_code}</code>{referrer_info} ({time_str})\n'
if len(report.lost_referrals) > 15:
text += f'\n<i>... и ещё {len(report.lost_referrals) - 15}</i>\n'
else:
text += '\n✅ <b>Все рефералы засчитаны!</b>\n'
# Сохраняем отчёт в state для дальнейшего использования (сериализуем в dict)
await state.update_data(
diagnostics_period='uploaded_file',
uploaded_file_report=report.to_dict(),
)
# Кнопки действий
keyboard_rows = []
if report.lost_referrals:
keyboard_rows.append(
[types.InlineKeyboardButton(text='📋 Предпросмотр исправлений', callback_data='admin_ref_fix_preview')]
)
keyboard_rows.extend(
[
[types.InlineKeyboardButton(text='⬅️ К диагностике', callback_data='admin_referral_diagnostics')],
[types.InlineKeyboardButton(text='⬅️ К статистике', callback_data='admin_referrals')],
]
)
keyboard = types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows)
# Удаляем статусное сообщение
await status_message.delete()
# Отправляем результат
await message.answer(text, reply_markup=keyboard)
# Очищаем состояние
await state.set_state(AdminStates.referral_diagnostics_period)
except Exception as e:
logger.error(f'❌ Ошибка при обработке файла: {e}', exc_info=True)
try:
await status_message.edit_text(
f'❌ <b>Ошибка при анализе файла</b>\n\n'
f'Файл: {file_name}\n'
f'Ошибка: {e!s}\n\n'
f'Проверьте, что файл является текстовым логом бота.',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text='🔄 Попробовать снова', callback_data='admin_ref_diag_upload'
)
],
[
types.InlineKeyboardButton(
text='⬅️ К диагностике', callback_data='admin_referral_diagnostics'
)
],
]
),
)
except:
await message.answer(
f'❌ Ошибка при анализе файла: {e!s}',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[types.InlineKeyboardButton(text='⬅️ Назад', callback_data='admin_referral_diagnostics')]
]
),
)
finally:
# Удаляем временный файл
if temp_file_path and Path(temp_file_path).exists():
try:
Path(temp_file_path).unlink()
logger.info(f'🗑️ Временный файл удалён: {temp_file_path}')
except Exception as e:
logger.error(f'Ошибка удаления временного файла: {e}')
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_referral_statistics, F.data == 'admin_referrals')
dp.callback_query.register(show_top_referrers, F.data == 'admin_referrals_top')
dp.callback_query.register(show_top_referrers_filtered, F.data.startswith('admin_top_ref:'))
dp.callback_query.register(show_referral_settings, F.data == 'admin_referrals_settings')
dp.callback_query.register(show_referral_diagnostics, F.data == 'admin_referral_diagnostics')
dp.callback_query.register(show_referral_diagnostics, F.data.startswith('admin_ref_diag:'))
dp.callback_query.register(preview_referral_fixes, F.data == 'admin_ref_fix_preview')
dp.callback_query.register(apply_referral_fixes, F.data == 'admin_ref_fix_apply')
# Загрузка лог-файла
dp.callback_query.register(request_log_file_upload, F.data == 'admin_ref_diag_upload')
dp.message.register(receive_log_file, AdminStates.waiting_for_log_file)
# Проверка бонусов по БД
dp.callback_query.register(check_missing_bonuses, F.data == 'admin_ref_check_bonuses')
dp.callback_query.register(apply_missing_bonuses, F.data == 'admin_ref_bonus_apply')
dp.callback_query.register(sync_referrals_with_contest, F.data == 'admin_ref_sync_contest')
# Хендлеры заявок на вывод
dp.callback_query.register(show_pending_withdrawal_requests, F.data == 'admin_withdrawal_requests')
+9
View File
@@ -1993,6 +1993,15 @@ async def process_edit_traffic_topup_packages(
await state.clear()
return
if not message.text:
await message.answer(
'Пожалуйста, отправьте текстовое сообщение.\n\n'
'Формат: <code>ГБ:цена_в_копейках</code>\n'
'Пример: <code>5:5000, 10:9000, 20:15000</code>',
parse_mode='HTML',
)
return
packages = _parse_traffic_topup_packages(message.text.strip())
if not packages:
+137 -188
View File
@@ -65,11 +65,8 @@ class UserFilterType(Enum):
"""Типы фильтрации пользователей."""
BALANCE = 'balance'
TRAFFIC = 'traffic'
ACTIVITY = 'activity'
SPENDING = 'spending'
PURCHASES = 'purchases'
CAMPAIGN = 'campaign'
POTENTIAL_CUSTOMERS = 'potential_customers'
@dataclass
@@ -92,34 +89,6 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_balance_list',
order_param='order_by_balance',
),
UserFilterType.TRAFFIC: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_traffic_list,
title='👥 <b>Список пользователей по использованному трафику</b>',
empty_message='📶 Пользователи с трафиком не найдены',
pagination_prefix='admin_users_traffic_list',
order_param='order_by_traffic',
),
UserFilterType.ACTIVITY: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_last_activity_list,
title='👥 <b>Пользователи по активности</b>',
empty_message='🕒 Пользователи с активностью не найдены',
pagination_prefix='admin_users_activity_list',
order_param='order_by_last_activity',
),
UserFilterType.SPENDING: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_spending_list,
title='👥 <b>Пользователи по сумме трат</b>',
empty_message='💳 Пользователи с тратами не найдены',
pagination_prefix='admin_users_spending_list',
order_param='order_by_total_spent',
),
UserFilterType.PURCHASES: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_purchases_list,
title='👥 <b>Пользователи по количеству покупок</b>',
empty_message='🛒 Пользователи с покупками не найдены',
pagination_prefix='admin_users_purchases_list',
order_param='order_by_purchase_count',
),
UserFilterType.CAMPAIGN: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_campaign_list,
title='👥 <b>Пользователи по кампании регистрации</b>',
@@ -127,6 +96,13 @@ USER_FILTER_CONFIGS: dict[UserFilterType, UserFilterConfig] = {
pagination_prefix='admin_users_campaign_list',
order_param='', # использует специальный метод
),
UserFilterType.POTENTIAL_CUSTOMERS: UserFilterConfig(
fsm_state=AdminStates.viewing_user_from_potential_customers_list,
title='👥 <b>Потенциальные клиенты</b>',
empty_message='💰 Потенциальные клиенты не найдены',
pagination_prefix='admin_users_potential_customers_list',
order_param='', # использует специальный метод
),
}
@@ -173,34 +149,6 @@ def _build_user_button_text(
days_left = (user.subscription.end_date - datetime.utcnow()).days
button_text += f' | 📅 {days_left}д'
elif filter_type == UserFilterType.TRAFFIC:
if user.subscription:
sub = user.subscription
used = sub.traffic_used_gb or 0.0
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
limit_display = f'{sub.traffic_limit_gb}'
else:
limit_display = '♾️'
traffic_display = f'{used:.1f}/{limit_display} ГБ'
else:
traffic_display = 'нет подписки'
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 📶 {traffic_display}'
if user.balance_kopeks > 0:
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
elif filter_type == UserFilterType.ACTIVITY:
activity_display = format_time_ago(user.last_activity, language) if user.last_activity else 'неизвестно'
button_text = f'{status_emoji} {sub_emoji} {user.full_name} | 🕒 {activity_display}'
elif filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
stats = extra_data.get(user.id, {'total_spent': 0, 'purchase_count': 0}) if extra_data else {}
total_spent = stats.get('total_spent', 0)
purchases = stats.get('purchase_count', 0)
if filter_type == UserFilterType.SPENDING:
button_text = f'{status_emoji} {user.full_name} | 💳 {settings.format_price(total_spent)} | 🛒 {purchases}'
else:
button_text = f'{status_emoji} {user.full_name} | 🛒 {purchases} | 💳 {settings.format_price(total_spent)}'
elif filter_type == UserFilterType.CAMPAIGN:
info = extra_data.get(user.id, {}) if extra_data else {}
campaign_name = info.get('campaign_name') or 'Без кампании'
@@ -219,18 +167,6 @@ def _build_user_button_text(
button_text = f'{status_emoji} {sub_emoji} {short_name}'
if user.balance_kopeks > 0:
button_text += f' | 💰 {settings.format_price(user.balance_kopeks)}'
elif filter_type == UserFilterType.TRAFFIC:
if user.subscription:
sub = user.subscription
used = sub.traffic_used_gb or 0.0
if sub.traffic_limit_gb and sub.traffic_limit_gb > 0:
limit_display = f'{sub.traffic_limit_gb}'
else:
limit_display = '♾️'
traffic_display = f'{used:.1f}/{limit_display} ГБ'
else:
traffic_display = 'нет'
button_text = f'{status_emoji} {sub_emoji} {short_name} | 📶 {traffic_display}'
else:
button_text = f'{status_emoji} {short_name}'
@@ -280,10 +216,6 @@ async def _show_users_list_filtered(
await callback.answer()
return
# Для spending/purchases нужны дополнительные данные
if filter_type in (UserFilterType.SPENDING, UserFilterType.PURCHASES):
extra_data = await user_service.get_user_spending_stats_map(db, [user.id for user in users])
# Формируем текст заголовка
text = f'{config.title} (стр. {page}/{users_data["total_pages"]})\n\n'
text += 'Нажмите на пользователя для управления:'
@@ -576,38 +508,122 @@ async def show_users_ready_to_renew(
@admin_required
@error_handler
async def show_users_list_by_traffic(
async def show_potential_customers(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по использованному трафику (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.TRAFFIC, page)
"""Показывает пользователей без активной подписки с балансом >= месячной цены."""
await state.set_state(AdminStates.viewing_user_from_potential_customers_list)
texts = get_texts(db_user.language)
from app.config import PERIOD_PRICES
@admin_required
@error_handler
async def show_users_list_by_last_activity(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по последней активности."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.ACTIVITY, page)
monthly_price = PERIOD_PRICES.get(30, 99000)
user_service = UserService()
users_data = await user_service.get_potential_customers(
db,
min_balance_kopeks=monthly_price,
page=page,
limit=10,
)
@admin_required
@error_handler
async def show_users_list_by_spending(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по сумме трат (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.SPENDING, page)
amount_text = settings.format_price(monthly_price)
header = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE',
'💰 Потенциальные клиенты',
)
description = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC',
'Пользователи без активной подписки с балансом {amount} или больше.',
).format(amount=amount_text)
if not users_data['users']:
empty_text = texts.t(
'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY',
'Сейчас нет пользователей, которые подходят под этот фильтр.',
)
await callback.message.edit_text(
f'{header}\n\n{description}\n\n{empty_text}',
reply_markup=get_admin_users_keyboard(db_user.language),
)
await callback.answer()
return
@admin_required
@error_handler
async def show_users_list_by_purchases(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext, page: int = 1
):
"""Список пользователей, отсортированный по количеству покупок (убывание)."""
await _show_users_list_filtered(callback, db_user, db, state, UserFilterType.PURCHASES, page)
text = f'{header}\n\n{description}\n\n'
text += 'Нажмите на пользователя для управления:'
keyboard = []
for user in users_data['users']:
subscription = user.subscription
status_emoji = '' if user.status == UserStatus.ACTIVE.value else '🚫'
subscription_emoji = ''
if subscription:
if subscription.is_trial:
subscription_emoji = '🎁'
elif subscription.is_active:
subscription_emoji = '💎'
else:
subscription_emoji = ''
button_text = (
f'{status_emoji} {subscription_emoji} {user.full_name} | 💰 {settings.format_price(user.balance_kopeks)}'
)
if len(button_text) > 60:
short_name = user.full_name
if len(short_name) > 20:
short_name = short_name[:17] + '...'
button_text = (
f'{status_emoji} {subscription_emoji} {short_name} | 💰 {settings.format_price(user.balance_kopeks)}'
)
keyboard.append(
[
types.InlineKeyboardButton(
text=button_text,
callback_data=f'admin_user_manage_{user.id}',
)
]
)
if users_data['total_pages'] > 1:
pagination_row = get_admin_pagination_keyboard(
users_data['current_page'],
users_data['total_pages'],
'admin_users_potential_customers_list',
'admin_users_potential_customers_filter',
db_user.language,
).inline_keyboard[0]
keyboard.append(pagination_row)
keyboard.extend(
[
[
types.InlineKeyboardButton(
text='🔍 Поиск',
callback_data='admin_users_search',
),
types.InlineKeyboardButton(
text='📊 Статистика',
callback_data='admin_users_stats',
),
],
[
types.InlineKeyboardButton(
text='⬅️ Назад',
callback_data='admin_users',
)
],
]
)
await callback.message.edit_text(
text,
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await callback.answer()
@admin_required
@@ -647,62 +663,6 @@ async def handle_users_balance_list_pagination(
await show_users_list_by_balance(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_traffic_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_traffic(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_traffic(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_activity_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_last_activity(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_last_activity(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_spending_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_spending(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_spending(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_purchases_list_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
callback_parts = callback.data.split('_')
page = int(callback_parts[-1])
await show_users_list_by_purchases(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_users_list_by_purchases(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_ready_to_renew_pagination(
@@ -716,6 +676,19 @@ async def handle_users_ready_to_renew_pagination(
await show_users_ready_to_renew(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_potential_customers_pagination(
callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext
):
try:
page = int(callback.data.split('_')[-1])
await show_potential_customers(callback, db_user, db, state, page)
except (ValueError, IndexError) as e:
logger.error(f'Ошибка парсинга номера страницы: {e}')
await show_potential_customers(callback, db_user, db, state, 1)
@admin_required
@error_handler
async def handle_users_campaign_list_pagination(
@@ -1310,18 +1283,12 @@ async def show_user_management(callback: types.CallbackQuery, db_user: User, db:
current_state = await state.get_state()
if current_state == AdminStates.viewing_user_from_balance_list:
back_callback = 'admin_users_balance_filter'
elif current_state == AdminStates.viewing_user_from_traffic_list:
back_callback = 'admin_users_traffic_filter'
elif current_state == AdminStates.viewing_user_from_last_activity_list:
back_callback = 'admin_users_activity_filter'
elif current_state == AdminStates.viewing_user_from_spending_list:
back_callback = 'admin_users_spending_filter'
elif current_state == AdminStates.viewing_user_from_purchases_list:
back_callback = 'admin_users_purchases_filter'
elif current_state == AdminStates.viewing_user_from_campaign_list:
back_callback = 'admin_users_campaign_filter'
elif current_state == AdminStates.viewing_user_from_ready_to_renew_list:
back_callback = 'admin_users_ready_to_renew_filter'
elif current_state == AdminStates.viewing_user_from_potential_customers_list:
back_callback = 'admin_users_potential_customers_filter'
# Базовая клавиатура профиля
kb = get_user_management_keyboard(user.id, user.status, db_user.language, back_callback)
@@ -5483,26 +5450,14 @@ def register_handlers(dp: Dispatcher):
handle_users_balance_list_pagination, F.data.startswith('admin_users_balance_list_page_')
)
dp.callback_query.register(
handle_users_traffic_list_pagination, F.data.startswith('admin_users_traffic_list_page_')
)
dp.callback_query.register(
handle_users_activity_list_pagination, F.data.startswith('admin_users_activity_list_page_')
)
dp.callback_query.register(
handle_users_spending_list_pagination, F.data.startswith('admin_users_spending_list_page_')
)
dp.callback_query.register(
handle_users_purchases_list_pagination, F.data.startswith('admin_users_purchases_list_page_')
)
dp.callback_query.register(
handle_users_ready_to_renew_pagination, F.data.startswith('admin_users_ready_to_renew_list_page_')
)
dp.callback_query.register(
handle_potential_customers_pagination, F.data.startswith('admin_users_potential_customers_list_page_')
)
dp.callback_query.register(
handle_users_campaign_list_pagination, F.data.startswith('admin_users_campaign_list_page_')
)
@@ -5659,14 +5614,8 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_users_list_by_balance, F.data == 'admin_users_balance_filter')
dp.callback_query.register(show_users_list_by_traffic, F.data == 'admin_users_traffic_filter')
dp.callback_query.register(show_users_list_by_last_activity, F.data == 'admin_users_activity_filter')
dp.callback_query.register(show_users_list_by_spending, F.data == 'admin_users_spending_filter')
dp.callback_query.register(show_users_list_by_purchases, F.data == 'admin_users_purchases_filter')
dp.callback_query.register(show_users_ready_to_renew, F.data == 'admin_users_ready_to_renew_filter')
dp.callback_query.register(show_potential_customers, F.data == 'admin_users_potential_customers_filter')
dp.callback_query.register(show_users_list_by_campaign, F.data == 'admin_users_campaign_filter')
-18
View File
@@ -8,7 +8,6 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -126,23 +125,6 @@ async def process_cryptobot_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_cryptobot_enabled():
-18
View File
@@ -8,7 +8,6 @@ from app.database.models import User
from app.external.telegram_stars import TelegramStarsService
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -94,23 +93,6 @@ async def process_stars_payment_amount(message: types.Message, db_user: User, am
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.TELEGRAM_STARS_ENABLED:
-35
View File
@@ -10,7 +10,6 @@ from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.blacklist_service import blacklist_service
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
@@ -171,23 +170,6 @@ async def process_yookassa_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
@@ -338,23 +320,6 @@ async def process_yookassa_sbp_payment_amount(
await state.clear()
return
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Оплата невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled() or not settings.YOOKASSA_SBP_ENABLED:
+11 -11
View File
@@ -153,7 +153,7 @@ async def show_main_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -243,7 +243,7 @@ async def handle_profile_unavailable(callback: types.CallbackQuery) -> None:
async def show_service_rules(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -277,7 +277,7 @@ async def show_info_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -320,7 +320,7 @@ async def show_promo_groups_info(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -468,7 +468,7 @@ async def show_faq_pages(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -525,7 +525,7 @@ async def show_faq_page(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -662,7 +662,7 @@ async def show_privacy_policy(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -779,7 +779,7 @@ async def show_public_offer(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -896,7 +896,7 @@ async def show_language_menu(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -938,7 +938,7 @@ async def process_language_change(
):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
@@ -1000,7 +1000,7 @@ async def process_language_change(
async def handle_back_to_menu(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
if db_user is None:
# Пользователь не найден, используем язык по умолчанию
texts = get_texts(settings.DEFAULT_LANGUAGE_CODE)
texts = get_texts(settings.DEFAULT_LANGUAGE)
await callback.answer(
texts.t(
'USER_NOT_FOUND_ERROR',
-18
View File
@@ -9,7 +9,6 @@ from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.promocode_service import PromoCodeService
from app.states import PromoCodeStates
from app.utils.decorators import error_handler
@@ -71,23 +70,6 @@ async def activate_promocode_for_registration(db: AsyncSession, user_id: int, co
@error_handler
async def process_promocode(message: types.Message, db_user: User, state: FSMContext, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Активация промокода невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
code = message.text.strip()
+54 -53
View File
@@ -35,7 +35,6 @@ from app.middlewares.channel_checker import (
get_pending_payload_from_redis,
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.campaign_service import AdvertisingCampaignService
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.pinned_message_service import (
@@ -309,24 +308,27 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f'🚀 START: Обработка /start от {message.from_user.id}')
data = await state.get_data() or {}
had_pending_payload = 'pending_start_payload' in data
pending_start_payload = data.pop('pending_start_payload', None)
had_campaign_notification_flag = 'campaign_notification_sent' in data
campaign_notification_sent = data.pop('campaign_notification_sent', False)
state_needs_update = had_pending_payload or had_campaign_notification_flag
# ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
# pending_start_payload обрабатывается отдельно ниже
campaign_notification_sent = data.get('campaign_notification_sent', False)
state_needs_update = False
# Получаем payload из state или Redis
pending_start_payload = data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
data['pending_start_payload'] = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
)
# Очищаем Redis после получения
await delete_pending_payload_from_redis(message.from_user.id)
# НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
referral_code = None
campaign = None
@@ -990,25 +992,6 @@ async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMCo
async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {callback.from_user.id}')
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.message.answer(
f'🚫 Регистрация невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1167,6 +1150,12 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(callback.from_user.id)
logger.info(
'🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id
)
await state.clear()
if campaign_message:
@@ -1253,25 +1242,6 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession):
logger.info(f'🎯 COMPLETE: Завершение регистрации для пользователя {message.from_user.id}')
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
message.from_user.id, message.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {message.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await message.answer(
f'🚫 Регистрация невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.'
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
await state.clear()
return
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
@@ -1454,6 +1424,10 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(message.from_user.id)
logger.info('🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя %s', user.telegram_id)
await state.clear()
if campaign_message:
@@ -1725,6 +1699,7 @@ async def required_sub_channel_check(
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
state_data['pending_start_payload'] = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '%s' восстановлен из Redis (fallback)",
pending_start_payload,
@@ -1780,16 +1755,31 @@ async def required_sub_channel_check(
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
logger.info(
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
campaign.id,
# Обрабатываем payload только если ещё не обработан
# (проверяем по наличию referral_code или campaign_id в state)
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
logger.info(
'📣 CHANNEL CHECK: Кампания %s восстановлена из payload',
campaign.id,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код: %s',
pending_start_payload,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
' CHANNEL CHECK: Реферальный код уже сохранен в state: %s',
state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
)
await state.set_data(state_data)
@@ -1829,6 +1819,12 @@ async def required_sub_channel_check(
except Exception as e:
logger.warning(f'Не удалось удалить сообщение: {e}')
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
# и перед показом главного меню или завершением регистрации
if pending_start_payload:
await delete_pending_payload_from_redis(query.from_user.id)
logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
if user and user.status != UserStatus.DELETED.value:
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
@@ -1911,6 +1907,11 @@ async def required_sub_channel_check(
)
await db.refresh(user, ['subscription'])
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
state_data.pop('pending_start_payload', None)
await state.set_data(state_data)
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id:
try:
+33
View File
@@ -39,6 +39,21 @@ async def handle_autopay_menu(callback: types.CallbackQuery, db_user: User, db:
)
return
# Суточные подписки имеют свой механизм продления, глобальный autopay не применяется
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
),
show_alert=True,
)
return
status = (
texts.t('AUTOPAY_STATUS_ENABLED', 'включен')
if subscription.autopay_enabled
@@ -68,6 +83,24 @@ async def toggle_autopay(callback: types.CallbackQuery, db_user: User, db: Async
subscription = db_user.subscription
enable = callback.data == 'autopay_enable'
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
if enable:
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
texts = get_texts(db_user.language)
await callback.answer(
texts.t(
'AUTOPAY_NOT_AVAILABLE_FOR_DAILY',
'Автоплатеж недоступен для суточных тарифов. Списание происходит автоматически раз в сутки.',
),
show_alert=True,
)
return
await update_subscription_autopay(db, subscription, enable)
texts = get_texts(db_user.language)
+93 -12
View File
@@ -186,18 +186,29 @@ async def handle_change_devices(callback: types.CallbackQuery, db_user: User, db
if tariff:
price_per_device = tariff_device_price
price_text = texts.format_price(price_per_device)
tariff_min_devices = getattr(tariff, 'device_limit', 1) or 1
# Добавляем информацию о минимальном лимите если он больше 1
min_devices_info = ''
if tariff_min_devices > 1:
min_devices_info = texts.t(
'CHANGE_DEVICES_MIN_LIMIT_INFO',
'\nМинимум для тарифа: {min_devices} устройств\n',
).format(min_devices=tariff_min_devices)
prompt_text = texts.t(
'CHANGE_DEVICES_PROMPT_TARIFF',
(
'📱 <b>Изменение количества устройств</b>\n\n'
'Текущий лимит: {current_devices} устройств\n'
'Цена за доп. устройство: {price}/мес\n'
'{min_devices_info}'
'Выберите новое количество устройств:\n\n'
'💡 <b>Важно:</b>\n'
'• При увеличении - доплата пропорционально оставшемуся времени\n'
'• При уменьшении - возврат средств не производится'
),
).format(current_devices=current_devices, price=price_text)
).format(current_devices=current_devices, price=price_text, min_devices_info=min_devices_info)
else:
prompt_text = texts.t(
'CHANGE_DEVICES_PROMPT',
@@ -276,6 +287,18 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
)
return
# Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
if new_devices_count < tariff_min_devices:
await callback.answer(
texts.t(
'DEVICES_MIN_LIMIT_REACHED',
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
).format(limit=tariff_min_devices),
show_alert=True,
)
return
devices_difference = new_devices_count - current_devices
if devices_difference > 0:
@@ -473,13 +496,41 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription = db_user.subscription
current_devices = subscription.device_limit
if not settings.is_devices_selection_enabled():
# Проверяем тариф подписки
tariff = None
if subscription.tariff_id:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
# Для тарифов - проверяем разрешено ли изменение устройств
if tariff:
tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
if tariff_device_price is None or tariff_device_price <= 0:
await callback.answer(
texts.t('TARIFF_DEVICES_DISABLED', '⚠️ Изменение устройств недоступно для вашего тарифа'),
show_alert=True,
)
return
elif not settings.is_devices_selection_enabled():
await callback.answer(
texts.t('DEVICES_SELECTION_DISABLED', '⚠️ Изменение количества устройств недоступно'),
show_alert=True,
)
return
# Проверяем минимальное количество устройств на тарифе
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
if new_devices_count < tariff_min_devices:
await callback.answer(
texts.t(
'DEVICES_MIN_LIMIT_REACHED',
'⚠️ Минимальное количество устройств для вашего тарифа: {limit}',
).format(limit=tariff_min_devices),
show_alert=True,
)
return
try:
if price > 0:
success = await subtract_user_balance(
@@ -510,7 +561,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
# При уменьшении лимита - сбросить лишние устройства
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
devices_reset_count = 0
if new_devices_count < current_devices and db_user.remnawave_uuid:
try:
@@ -521,16 +572,34 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
devices_list = response['response'].get('devices', [])
connected_count = len(devices_list)
# Если подключённых устройств больше чем новый лимит - сбросить все
# Если подключённых устройств больше чем новый лимит - удалить лишние
if connected_count > new_devices_count:
devices_to_remove = connected_count - new_devices_count
logger.info(
f'🔧 Сброс устройств при уменьшении лимита: '
f'подключено {connected_count}, новый лимит {new_devices_count}'
f'🔧 Удаление лишних устройств при уменьшении лимита: '
f'подключено {connected_count}, новый лимит {new_devices_count}, '
f'удаляем {devices_to_remove}'
)
await api.reset_user_devices(db_user.remnawave_uuid)
devices_reset_count = connected_count
# Сортируем по дате (последние в конце) и удаляем последние
sorted_devices = sorted(
devices_list,
key=lambda d: d.get('updatedAt') or d.get('createdAt') or '',
)
devices_to_delete = sorted_devices[-devices_to_remove:]
for device in devices_to_delete:
device_hwid = device.get('hwid')
if device_hwid:
try:
delete_data = {'userUuid': db_user.remnawave_uuid, 'hwid': device_hwid}
await api._make_request('POST', '/api/hwid/devices/delete', data=delete_data)
devices_reset_count += 1
logger.info(f'✅ Удалено устройство {device_hwid}')
except Exception as del_error:
logger.error(f'Ошибка удаления устройства {device_hwid}: {del_error}')
except Exception as reset_error:
logger.error(f'Ошибка сброса устройств при уменьшении лимита: {reset_error}')
logger.error(f'Ошибка удаления устройств при уменьшении лимита: {reset_error}')
await db.refresh(db_user)
await db.refresh(subscription)
@@ -570,9 +639,9 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
).format(old=current_devices, new=new_devices_count)
if devices_reset_count > 0:
success_text += texts.t(
'DEVICE_CHANGE_DEVICES_RESET',
'\n🔄 Сброшено устройств: {count}\n💡 Подключите заново нужные устройства (до {limit} шт.)\n\n',
).format(count=devices_reset_count, limit=new_devices_count)
'DEVICE_CHANGE_DEVICES_REMOVED',
'\n🗑 Удалено устройств: {count}\n',
).format(count=devices_reset_count)
success_text += texts.t(
'DEVICE_CHANGE_NO_REFUND_INFO',
'ℹ️ Возврат средств не производится',
@@ -1121,6 +1190,18 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
await db.refresh(db_user)
await db.refresh(subscription)
# Отправляем уведомление админам о докупке устройств
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
old_device_limit = subscription.device_limit - devices_count
await notification_service.send_subscription_update_notification(
db, db_user, subscription, 'devices', old_device_limit, subscription.device_limit, price
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления о докупке устройств: {e}')
success_text = (
'✅ Устройства успешно добавлены!\n\n'
f'📱 Добавлено: {devices_count} устройств\n'
-55
View File
@@ -39,7 +39,6 @@ from app.keyboards.inline import (
)
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.blacklist_service import blacklist_service
from app.services.remnawave_service import RemnaWaveConfigurationError
from app.services.subscription_checkout_service import (
clear_subscription_checkout_draft,
@@ -1724,24 +1723,6 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Продление подписки невозможно\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
@@ -2228,24 +2209,6 @@ async def devices_continue(callback: types.CallbackQuery, state: FSMContext, db_
async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_user: User, db: AsyncSession):
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Покупка подписки невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
# Проверка ограничения на покупку/продление подписки
if getattr(db_user, 'restriction_subscription', False):
reason = getattr(db_user, 'restriction_reason', None) or 'Действие ограничено администратором'
@@ -4134,24 +4097,6 @@ async def handle_simple_subscription_purchase(
db: AsyncSession,
):
"""Обрабатывает простую покупку подписки."""
# Проверяем, находится ли пользователь в черном списке
is_blacklisted, blacklist_reason = await blacklist_service.is_user_blacklisted(
callback.from_user.id, callback.from_user.username
)
if is_blacklisted:
logger.warning(f'🚫 Пользователь {callback.from_user.id} находится в черном списке: {blacklist_reason}')
try:
await callback.answer(
f'🚫 Простая покупка подписки невозможна\n\n'
f'Причина: {blacklist_reason}\n\n'
f'Если вы считаете, что это ошибка, обратитесь в поддержку.',
show_alert=True,
)
except Exception as e:
logger.error(f'Ошибка при отправке сообщения о блокировке: {e}')
return
texts = get_texts(db_user.language)
if not settings.SIMPLE_SUBSCRIPTION_ENABLED:
+43 -1
View File
@@ -2242,6 +2242,19 @@ async def confirm_tariff_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave при переключении тарифа: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при смене тарифа для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
# Создаем транзакцию
await create_transaction(
db,
@@ -2262,6 +2275,7 @@ async def confirm_tariff_switch(
days_for_new_tariff, # Итоговый срок подписки
was_trial_conversion=False,
amount_kopeks=final_price,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
@@ -2395,7 +2409,7 @@ async def confirm_daily_tariff_switch(
await db.commit()
await db.refresh(subscription)
# Обновляем пользователя в Remnawave
# Обновляем пользователя в Remnawave (create_remnawave_user также сбрасывает устройства)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
@@ -2407,6 +2421,19 @@ async def confirm_daily_tariff_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при смене на суточный тариф для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при смене тарифа: {e}')
# Создаем транзакцию
await create_transaction(
db,
@@ -2427,6 +2454,7 @@ async def confirm_daily_tariff_switch(
1, # 1 день
was_trial_conversion=False,
amount_kopeks=daily_price,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
@@ -2972,6 +3000,19 @@ async def confirm_instant_switch(
except Exception as e:
logger.error(f'Ошибка обновления Remnawave при мгновенном переключении: {e}')
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
if db_user.remnawave_uuid:
try:
from app.services.remnawave_service import RemnaWaveService
service = RemnaWaveService()
async with service.get_api_client() as api:
await api.reset_user_devices(db_user.remnawave_uuid)
logger.info(f'🔧 Сброшены устройства при мгновенном переключении тарифа для user_id={db_user.id}')
except Exception as e:
logger.error(f'Ошибка сброса устройств при переключении тарифа: {e}')
# Создаем транзакцию если была оплата
if is_upgrade and upgrade_cost > 0:
await create_transaction(
@@ -2993,6 +3034,7 @@ async def confirm_instant_switch(
remaining_days,
was_trial_conversion=False,
amount_kopeks=upgrade_cost,
purchase_type='tariff_switch',
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления админу: {e}')
+22 -1
View File
@@ -484,7 +484,14 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
discount_per_month = discount_result['discount']
charged_months = 1
if subscription:
# На тарифах пакеты трафика покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки.
# Пропорциональный расчёт применяем только в классическом режиме.
is_tariff_mode = settings.is_tariffs_mode() and subscription and subscription.tariff_id
if is_tariff_mode:
price = discounted_per_month
elif subscription:
price, charged_months = calculate_prorated_price(
discounted_per_month,
subscription.end_date,
@@ -540,6 +547,9 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
await callback.answer()
return
# Сохраняем старое значение трафика для уведомления
old_traffic_limit = subscription.traffic_limit_gb
try:
success = await subtract_user_balance(
db,
@@ -580,6 +590,17 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
await db.refresh(db_user)
await db.refresh(subscription)
# Отправляем уведомление админам о докупке трафика
try:
from app.services.admin_notification_service import AdminNotificationService
notification_service = AdminNotificationService(callback.bot)
await notification_service.send_subscription_update_notification(
db, db_user, subscription, 'traffic', old_traffic_limit, subscription.traffic_limit_gb, price
)
except Exception as e:
logger.error(f'Ошибка отправки уведомления о докупке трафика: {e}')
success_text = '✅ Трафик успешно добавлен!\n\n'
if traffic_gb == 0:
success_text += '🎉 Теперь у вас безлимитный трафик!'
+10 -1
View File
@@ -1,6 +1,6 @@
import logging
from aiogram import types
from aiogram import Bot, types
from aiohttp import web
from app.config import settings
@@ -13,6 +13,15 @@ from app.external.tribute import TributeService
logger = logging.getLogger(__name__)
# Глобальная ссылка на бота для отправки уведомлений
_bot_instance: Bot | None = None
def set_webhook_bot(bot: Bot) -> None:
"""Устанавливает экземпляр бота для отправки уведомлений об ошибках в webhook."""
global _bot_instance
_bot_instance = bot
async def tribute_webhook(request):
try:
+12 -24
View File
@@ -326,6 +326,12 @@ def get_admin_users_keyboard(language: str = 'ru') -> InlineKeyboardMarkup:
text=_t(texts, 'ADMIN_USERS_BULK_BAN', '🛑 Массовый бан'), callback_data='admin_bulk_ban_start'
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_BLOCKED_CHECK', '🔒 Заблокировавшие бота'),
callback_data='admin_blocked_users',
)
],
[InlineKeyboardButton(text=texts.BACK, callback_data='admin_submenu_users')],
]
)
@@ -342,36 +348,18 @@ def get_admin_users_filters_keyboard(language: str = 'ru') -> InlineKeyboardMark
callback_data='admin_users_balance_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_TRAFFIC', '📶 По трафику'),
callback_data='admin_users_traffic_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_ACTIVITY', '🕒 По активности'),
callback_data='admin_users_activity_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_SPENDING', '💳 По сумме трат'),
callback_data='admin_users_spending_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_PURCHASES', '🛒 По количеству покупок'),
callback_data='admin_users_purchases_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_RENEW_READY', '♻️ Готовы к продлению'),
callback_data='admin_users_ready_to_renew_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS', '💰 Потенциальные клиенты'),
callback_data='admin_users_potential_customers_filter',
)
],
[
InlineKeyboardButton(
text=_t(texts, 'ADMIN_USERS_FILTER_CAMPAIGN', '📢 По кампании'),
+15 -18
View File
@@ -1860,17 +1860,8 @@ def get_add_traffic_keyboard_from_tariff(
subscription_end_date: Дата окончания подписки для расчета цены
discount_percent: Процент скидки
"""
from app.utils.pricing_utils import get_remaining_months
texts = get_texts(language)
months_multiplier = 1
period_text = ''
if subscription_end_date:
months_multiplier = get_remaining_months(subscription_end_date)
if months_multiplier > 1:
period_text = f' (за {months_multiplier} мес)'
if not packages:
return InlineKeyboardMarkup(
inline_keyboard=[
@@ -1889,21 +1880,23 @@ def get_add_traffic_keyboard_from_tariff(
# Сортируем пакеты по размеру
sorted_packages = sorted(packages.items(), key=lambda x: x[0])
# Пакеты трафика на тарифах покупаются на 1 месяц (30 дней),
# цена в тарифе уже месячная — не умножаем на оставшиеся месяцы подписки
for gb, price_per_month in sorted_packages:
discounted_per_month, discount_per_month = apply_percentage_discount(
discounted_price, discount_value = apply_percentage_discount(
price_per_month,
discount_percent,
)
total_price = discounted_per_month * months_multiplier
total_discount = discount_per_month * months_multiplier
period_text = ' /мес' if language == 'ru' else ' /mo'
if language == 'ru':
text = f'📊 +{gb} ГБ трафика - {total_price // 100}{period_text}'
text = f'📊 +{gb} ГБ трафика - {discounted_price // 100}{period_text}'
else:
text = f'📊 +{gb} GB traffic - {total_price // 100}{period_text}'
text = f'📊 +{gb} GB traffic - {discounted_price // 100}{period_text}'
if discount_percent > 0 and total_discount > 0:
text += f' (скидка {discount_percent}%: -{total_discount // 100}₽)'
if discount_percent > 0 and discount_value > 0:
text += f' (скидка {discount_percent}%: -{discount_value // 100}₽)'
buttons.append([InlineKeyboardButton(text=text, callback_data=f'add_traffic_{gb}')])
@@ -1966,7 +1959,11 @@ def get_change_devices_keyboard(
else:
max_devices = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else 20
start_range = max(1, min(current_devices - 3, max_devices - 6))
# Минимальное количество устройств: device_limit тарифа или 1
tariff_min_devices = (getattr(tariff, 'device_limit', 1) or 1) if tariff else 1
min_devices = max(1, tariff_min_devices)
start_range = max(min_devices, min(current_devices - 3, max_devices - 6))
end_range = min(max_devices + 1, max(current_devices + 4, 7))
for devices_count in range(start_range, end_range):
@@ -2065,7 +2062,7 @@ def get_reset_traffic_confirm_keyboard(
[
InlineKeyboardButton(
text=texts.t('TOPUP_BALANCE_BUTTON', '💳 Пополнить баланс'),
callback_data=f'topup_amount_{missing_kopeks}',
callback_data='balance_topup',
)
]
)
+3 -1
View File
@@ -925,6 +925,7 @@
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
"CHANGE_DEVICES_PROMPT": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\nPrice per extra device: {price}/mo\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra cost prorated by remaining time\n• Decreasing — payments are not refunded",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\n️ Payments are not refunded\n",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
@@ -977,7 +978,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Confirm change</b>\n\nCurrent amount: {current} devices\nNew amount: {new} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Device limit decreased!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (discount {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {months} mo)",
"DEVICE_CHANGE_EXTRA_COST": "Extra payment: {amount} (for {period})",
"DEVICE_CHANGE_FREE": "Free",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Device limit increased!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Payments are not refunded",
@@ -1652,6 +1653,7 @@
"POLL_REWARD_GRANTED": "Reward {amount} has been credited to your balance.",
"ADMIN_USERS_BULK_BAN": "🛑 Bulk Ban",
"ADMIN_USERS_BLACKLIST": "🔐 Blacklist",
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Blocked bot users",
"CONTEST_START_ANNOUNCEMENT": "Game starts",
"CONTEST_PRIZE": "Prize",
"DAYS": "days subscription",
+8 -2
View File
@@ -807,6 +807,10 @@
"ADMIN_USERS_FILTER_RENEW_READY_TITLE": "♻️ Пользователи готовы к продлению",
"ADMIN_USERS_FILTER_RENEW_READY_DESC": "Подписка истекла, а на балансе осталось {amount} или больше.",
"ADMIN_USERS_FILTER_RENEW_READY_EMPTY": "Сейчас нет пользователей, которые подходят под этот фильтр.",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS": "💰 Потенциальные клиенты",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_TITLE": "💰 Потенциальные клиенты",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_DESC": "Нет подписки, но баланс достаточен для покупки.",
"ADMIN_USERS_FILTER_POTENTIAL_CUSTOMERS_EMPTY": "Нет пользователей без подписки с достаточным балансом.",
"ADMIN_USERS_FILTER_CAMPAIGN": "📢 По кампании",
"ADMIN_USERS_FILTER_PURCHASES": "🛒 По количеству покупок",
"ADMIN_USERS_FILTER_SPENDING": "💳 По сумме трат",
@@ -941,6 +945,7 @@
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
"CHANGE_DEVICES_PROMPT": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Изменение количества устройств</b>\n\nТекущий лимит: {current_devices} устройств\nЦена за доп. устройство: {price}/мес\nВыберите новое количество устройств:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
@@ -994,7 +999,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Подтверждение изменения</b>\n\nТекущее количество: {current} устройств\nНовое количество: {new} устройств\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить изменение?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Количество устройств уменьшено!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (скидка {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} мес)",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
"DEVICE_CHANGE_FREE": "Бесплатно",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Количество устройств увеличено!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Возврат средств не производится",
@@ -1534,7 +1539,7 @@
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
"TOPUP_BALANCE_BUTTON": "💳 Пополнить баланс",
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
"TOP_UP_STARS": "⭐ Telegram Stars",
@@ -1669,6 +1674,7 @@
"POLL_REWARD_GRANTED": "Награда {amount} зачислена на ваш баланс.",
"ADMIN_USERS_BULK_BAN": "🛑 Массовый бан",
"ADMIN_USERS_BLACKLIST": "🔐 Черный список",
"ADMIN_USERS_BLOCKED_CHECK": "🔒 Заблокировавшие бота",
"CONTEST_START_ANNOUNCEMENT": "Стартует игра",
"CONTEST_PRIZE": "Приз",
"DAYS": "дн. подписки",
+2 -1
View File
@@ -866,6 +866,7 @@
"CHANGE_DEVICES_CONFIRM": "\n  📱 <b>Підтвердження зміни</b>\n\n  Поточна кількість: {current_devices} пристроїв\n  Нова кількість: {new_devices} пристроїв\n\n  Дія: {action}\n  💰 {cost}\n\n  Підтвердити зміну?\n  ",
"CHANGE_DEVICES_INFO": "\n  📱 <b>Зміна кількості пристроїв</b>\n\n  Поточний ліміт: {current_devices} пристроїв\n\n  Оберіть нову кількість пристроїв:\n\n  💡 <b>Важливо:</b>\n  • При збільшенні - доплата пропорційно часу, що залишився\n  • При зменшенні - повернення коштів не здійснюється\n  ",
"CHANGE_DEVICES_PROMPT": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
"CHANGE_DEVICES_PROMPT_TARIFF": "📱 <b>Зміна кількості пристроїв</b>\n\nПоточний ліміт: {current_devices} пристроїв\nЦіна за дод. пристрій: {price}/міс\nОберіть нову кількість пристроїв:\n\n💡 <b>Важливо:</b>\n• При збільшенні - доплата пропорційно часу, що залишився\n• При зменшенні - повернення коштів не здійснюється",
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n  ✅ Кількість пристроїв зменшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  ℹ️ Повернення коштів не здійснюється\n  ",
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n  ✅ Кількість пристроїв збільшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  💰 Списано: {amount}\n  ",
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
@@ -917,7 +918,7 @@
"DEVICE_CHANGE_CONFIRMATION": "📱 <b>Підтвердження зміни</b>\n\nПоточна кількість: {current} пристроїв\nНова кількість: {new} пристроїв\n\nДія: {action}\n💰 {cost}\n\nПідтвердити зміну?",
"DEVICE_CHANGE_DECREASE_SUCCESS": "✅ Кількість пристроїв зменшено!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO": " (знижка {percent}%: -{amount})",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {months} міс)",
"DEVICE_CHANGE_EXTRA_COST": "Доплата: {amount} (за {period})",
"DEVICE_CHANGE_FREE": "Безкоштовно",
"DEVICE_CHANGE_INCREASE_SUCCESS": "✅ Кількість пристроїв збільшено!\n\n",
"DEVICE_CHANGE_NO_REFUND": "Повернення коштів не здійснюється",
+2 -1
View File
@@ -864,6 +864,7 @@
"CHANGE_DEVICES_CONFIRM":"\n  📱<b>确认更改</b>\n\n  当前数量:{current_devices}台设备\n  新数量:{new_devices}台设备\n\n  操作:{action}\n  💰{cost}\n\n  确认更改吗?\n  ",
"CHANGE_DEVICES_INFO":"\n  📱<b>更改设备数量</b>\n\n  当前限制:{current_devices}台设备\n\n  请选择新的设备数量:\n\n  💡<b>重要提示:</b>\n  •增加-按剩余时间比例补差价\n  •减少-不退款\n  ",
"CHANGE_DEVICES_PROMPT":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
"CHANGE_DEVICES_PROMPT_TARIFF":"📱<b>更改设备数量</b>\n\n当前限制:{current_devices}台设备\n每台额外设备价格:{price}/月\n请选择新的设备数量:\n\n💡<b>重要提示:</b>\n•增加-按剩余时间比例补差价\n•减少-不退款",
"CHANGE_DEVICES_SUCCESS_DECREASE":"\n  ✅设备数量已减少!\n\n  📱之前:{old_count}→现在:{new_count}\n  ️不退款\n  ",
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n  ✅设备数量已增加!\n\n  📱之前:{old_count}→现在:{new_count}\n  💰已扣除:{amount}\n  ",
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
@@ -915,7 +916,7 @@
"DEVICE_CHANGE_CONFIRMATION":"📱<b>确认更改</b>\n\n当前数量:{current}台设备\n新数量:{new}台设备\n\n操作:{action}\n💰{cost}\n\n确认更改吗?",
"DEVICE_CHANGE_DECREASE_SUCCESS":"✅设备数量已减少!\n\n",
"DEVICE_CHANGE_DISCOUNT_INFO":"(折扣{percent}%:-{amount})",
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}(共{months}个月)",
"DEVICE_CHANGE_EXTRA_COST":"补差价:{amount}({period})",
"DEVICE_CHANGE_FREE":"免费",
"DEVICE_CHANGE_INCREASE_SUCCESS":"✅设备数量已增加!\n\n",
"DEVICE_CHANGE_NO_REFUND":"不退款",
+231
View File
@@ -0,0 +1,231 @@
"""Кастомный logging handler для отправки ERROR/CRITICAL в админский чат Telegram.
Перехватывает все log records уровня ERROR и CRITICAL и отправляет их
в админский чат через существующий механизм send_error_to_admin_chat()
из app.middlewares.global_error.
Дедупликация:
- Записи, уже обработанные GlobalErrorMiddleware или @error_handler,
помечаются атрибутом _admin_notified = True и пропускаются.
- Хеши недавних сообщений хранятся в LRU-кеше для предотвращения
дублирования одинаковых ошибок за короткий период.
Async bridge:
- logging.Handler.emit() -- синхронный. Мы используем
asyncio.get_running_loop().call_soon_threadsafe() для планирования
asyncio.Task из любого потока (sync или async).
Deferred init:
- Bot instance создаётся позже в main.py. Метод set_bot() позволяет
передать его после создания. До этого записи молча пропускаются.
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
import time
from typing import Final
from aiogram import Bot
# Константы
RECENT_HASHES_MAX_SIZE: Final[int] = 256
RECENT_HASH_TTL_SECONDS: Final[float] = 300.0 # 5 минут -- совпадает с cooldown в global_error
# Логгеры, от которых мы гарантированно не хотим получать уведомления,
# даже если они вдруг выдадут ERROR (шум от транспортного уровня).
IGNORED_LOGGER_PREFIXES: Final[tuple[str, ...]] = (
'aiohttp.access',
'aiohttp.client',
'aiohttp.internal',
'uvicorn.access',
'uvicorn.error',
'uvicorn.protocols',
'websockets',
'asyncio',
)
class TelegramErrorHandler(logging.Handler):
"""Logging handler, отправляющий ERROR/CRITICAL записи в админский Telegram-чат.
Использует существующий механизм троттлинга и буферизации из
``app.middlewares.global_error.send_error_to_admin_chat``.
Usage::
handler = TelegramErrorHandler()
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
# Позже, когда Bot создан:
handler.set_bot(bot)
"""
def __init__(self, level: int = logging.ERROR) -> None:
super().__init__(level=level)
self._bot: Bot | None = None
# LRU-подобный кеш хешей недавних сообщений: hash -> timestamp
self._recent_hashes: dict[str, float] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def set_bot(self, bot: Bot) -> None:
"""Устанавливает Bot instance для отправки сообщений.
Вызывается из main.py после создания бота.
"""
self._bot = bot
# ------------------------------------------------------------------
# logging.Handler interface
# ------------------------------------------------------------------
def emit(self, record: logging.LogRecord) -> None:
"""Обрабатывает log record.
Синхронный метод (требование logging). Планирует async-отправку
через event loop.
"""
# 1. Фильтр по уровню (на случай если кто-то обойдёт setLevel)
if record.levelno < logging.ERROR:
return
# 2. Уже отправлено через GlobalErrorMiddleware / @error_handler
if getattr(record, '_admin_notified', False):
return
# 3. Фильтруем шумные логгеры
if any(record.name.startswith(prefix) for prefix in IGNORED_LOGGER_PREFIXES):
return
# 4. Бот ещё не инициализирован -- пропускаем
bot = self._bot
if bot is None:
return
# 5. Дедупликация по хешу (logger_name + message)
msg_hash = self._compute_hash(record)
now = time.monotonic()
# Чистим просроченные записи (ленивая очистка)
self._evict_stale(now)
if msg_hash in self._recent_hashes:
return
self._recent_hashes[msg_hash] = now
# 6. Планируем отправку через event loop
self._schedule_send(bot, record)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _compute_hash(record: logging.LogRecord) -> str:
"""Вычисляет короткий хеш для дедупликации.
Хешируем имя логгера + сообщение (без timestamp).
"""
raw = f'{record.name}:{record.getMessage()}'
return hashlib.md5(raw.encode('utf-8', errors='replace')).hexdigest()
def _evict_stale(self, now: float) -> None:
"""Удаляет устаревшие записи из кеша хешей."""
if not self._recent_hashes:
return
stale_keys = [k for k, ts in self._recent_hashes.items() if (now - ts) > RECENT_HASH_TTL_SECONDS]
for k in stale_keys:
self._recent_hashes.pop(k, None)
# Принудительная очистка при переполнении — удаляем самые старые
if len(self._recent_hashes) > RECENT_HASHES_MAX_SIZE:
sorted_keys = sorted(self._recent_hashes, key=self._recent_hashes.get)
for k in sorted_keys[: len(self._recent_hashes) - RECENT_HASHES_MAX_SIZE]:
self._recent_hashes.pop(k, None)
def _schedule_send(self, bot: Bot, record: logging.LogRecord) -> None:
"""Планирует асинхронную отправку в event loop.
Работает из любого потока:
- Если вызов из async-контекста -- создаём Task напрямую.
- Если из другого потока -- используем call_soon_threadsafe.
"""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Нет running loop -- мы в стороннем потоке без loop.
# Пытаемся получить loop, привязанный к основному потоку.
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
return
loop.call_soon_threadsafe(self._create_send_task, bot, record, loop)
except RuntimeError:
return
else:
# Мы в async-контексте -- создаём task напрямую
self._create_send_task(bot, record, loop)
def _create_send_task(self, bot: Bot, record: logging.LogRecord, loop: asyncio.AbstractEventLoop) -> None:
"""Создаёт asyncio.Task для отправки уведомления."""
loop.create_task(self._send(bot, record))
@staticmethod
async def _send(bot: Bot, record: logging.LogRecord) -> None:
"""Отправляет log record в админский чат через существующую инфраструктуру."""
try:
# Ленивый импорт -- избегаем циклических зависимостей при старте
from app.middlewares.global_error import send_error_to_admin_chat
# Формируем pseudo-Exception из log record
error = _make_log_record_error(record)
context_parts: list[str] = [f'Logger: {record.name}']
if record.funcName:
context_parts.append(f'Function: {record.funcName}')
if record.pathname and record.lineno:
context_parts.append(f'Location: {record.pathname}:{record.lineno}')
context = '\n'.join(context_parts)
# Извлекаем traceback из log record (если есть exc_info)
tb_override: str | None = None
if record.exc_info and record.exc_info[2] is not None:
import traceback
tb_override = ''.join(traceback.format_exception(*record.exc_info))
elif record.exc_text:
tb_override = record.exc_text
await send_error_to_admin_chat(bot, error, context, tb_override=tb_override)
except Exception:
# Ни в коем случае не даём исключению утечь -- это logging handler,
# рекурсия убьёт приложение.
pass
def _make_log_record_error(record: logging.LogRecord) -> Exception:
"""Создаёт Exception-обёртку для LogRecord.
send_error_to_admin_chat использует type(error).__name__ как error_type.
Мы динамически создаём класс с правильным именем, чтобы не мутировать
общий класс между вызовами.
"""
class_name = f'Log{record.levelname.capitalize()}'
error_cls = type(
class_name,
(Exception,),
{
'__str__': lambda self: self.args[0] if self.args else '',
},
)
error = error_cls(record.getMessage())
error.record = record # type: ignore[attr-defined]
return error
+17 -2
View File
@@ -7,6 +7,7 @@ from typing import Any
from aiogram import BaseMiddleware
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, User as TgUser
from sqlalchemy.exc import InterfaceError, OperationalError
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
@@ -204,13 +205,27 @@ class AuthMiddleware(BaseMiddleware):
data['is_admin'] = settings.is_admin(user.id)
result = await handler(event, data)
await db.commit()
try:
await db.commit()
except (InterfaceError, OperationalError) as conn_err:
# Соединение закрылось (таймаут после долгой операции) - просто логируем
logger.warning(f'⚠️ Соединение с БД закрыто после обработки, пропускаем commit: {conn_err}')
return result
except (InterfaceError, OperationalError) as conn_err:
# Соединение с БД закрылось - не пытаемся rollback
logger.error(f'Ошибка соединения с БД в AuthMiddleware: {conn_err}')
logger.error(f'Event type: {type(event)}')
if hasattr(event, 'data'):
logger.error(f'Callback data: {event.data}')
raise
except Exception as e:
logger.error(f'Ошибка в AuthMiddleware: {e}')
logger.error(f'Event type: {type(event)}')
if hasattr(event, 'data'):
logger.error(f'Callback data: {event.data}')
await db.rollback()
try:
await db.rollback()
except (InterfaceError, OperationalError):
pass # Соединение уже закрыто
raise
+49
View File
@@ -0,0 +1,49 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, PreCheckoutQuery, TelegramObject, User as TgUser
from app.services.blacklist_service import blacklist_service
logger = logging.getLogger(__name__)
class BlacklistMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user: TgUser | None = None
if isinstance(event, (Message, CallbackQuery, PreCheckoutQuery)):
user = event.from_user
if not user or user.is_bot:
return await handler(event, data)
is_blacklisted, reason = await blacklist_service.is_user_blacklisted(user.id, user.username)
if not is_blacklisted:
return await handler(event, data)
logger.warning(f'🚫 Пользователь {user.id} (@{user.username}) из черного списка: {reason}')
block_text = (
f'🚫 Доступ запрещен\n\nПричина: {reason}\n\nЕсли вы считаете, что это ошибка, обратитесь в поддержку.'
)
try:
if isinstance(event, Message):
await event.answer(block_text)
elif isinstance(event, CallbackQuery):
await event.answer(block_text, show_alert=True)
elif isinstance(event, PreCheckoutQuery):
await event.answer(ok=False, error_message='Доступ запрещен')
except Exception as e:
logger.error(f'Ошибка отправки сообщения о блокировке пользователю {user.id}: {e}')
return None
+2 -2
View File
@@ -396,10 +396,10 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированных пользователей
# НЕ реактивируем подписку заблокированным пользователям
if user.status == UserStatus.BLOCKED.value:
logger.info(
'🚫 Пропуск реактивации подписки для заблокированного пользователя %s',
'🚫 Пропуск реактивации для заблокированного пользователя %s',
telegram_id,
)
return
+218 -41
View File
@@ -1,14 +1,60 @@
import logging
import traceback
from collections.abc import Awaitable, Callable
from typing import Any
from datetime import datetime, timedelta
from typing import Any, Final
from aiogram import BaseMiddleware
from aiogram import BaseMiddleware, Bot
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import CallbackQuery, TelegramObject
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, TelegramObject
from sqlalchemy.exc import InterfaceError, OperationalError
from app.config import settings
from app.services.startup_notification_service import _get_error_recommendations
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
# Константы
ERROR_NOTIFICATION_COOLDOWN_MINUTES: Final[int] = 5
ERROR_BUFFER_MAX_SIZE: Final[int] = 10
ERROR_MESSAGE_MAX_LENGTH: Final[int] = 500
REPORT_SEPARATOR_WIDTH: Final[int] = 50
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
# Фразы ошибок Telegram API
OLD_QUERY_PHRASES: Final[tuple[str, ...]] = (
'query is too old',
'query id is invalid',
'response timeout expired',
)
BAD_REQUEST_PHRASES: Final[tuple[str, ...]] = (
'message not found',
'chat not found',
'bot was blocked by the user',
'user is deactivated',
)
TOPIC_ERROR_PHRASES: Final[tuple[str, ...]] = (
'topic must be specified',
'topic_closed',
'topic_deleted',
'forum_closed',
)
MESSAGE_NOT_MODIFIED_PHRASE: Final[str] = 'message is not modified'
BOT_BLOCKED_PHRASE: Final[str] = 'bot was blocked'
USER_DEACTIVATED_PHRASE: Final[str] = 'user is deactivated'
CHAT_NOT_FOUND_PHRASE: Final[str] = 'chat not found'
MESSAGE_NOT_FOUND_PHRASE: Final[str] = 'message not found'
# Троттлинг для предотвращения спама ошибками
_last_error_notification: datetime | None = None
_error_notification_cooldown = timedelta(minutes=ERROR_NOTIFICATION_COOLDOWN_MINUTES)
_error_buffer: list[tuple[str, str, str]] = [] # (error_type, error_message, traceback)
class GlobalErrorMiddleware(BaseMiddleware):
async def __call__(
@@ -20,81 +66,83 @@ class GlobalErrorMiddleware(BaseMiddleware):
try:
return await handler(event, data)
except TelegramBadRequest as e:
return await self._handle_telegram_error(event, e)
return await self._handle_telegram_error(event, e, data)
except (InterfaceError, OperationalError) as e:
# Ошибки соединения с БД (таймаут после долгих операций) - логируем, но не спамим админам
logger.warning('⚠️ Ошибка соединения с БД в GlobalErrorMiddleware: %s', e)
raise
except Exception as e:
logger.error(f'Неожиданная ошибка в GlobalErrorMiddleware: {e}', exc_info=True)
user_info = self._get_user_info(event)
logger.error('Неожиданная ошибка в GlobalErrorMiddleware (user=%s): %s', user_info, e, exc_info=True)
raise
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest):
async def _handle_telegram_error(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
error_message = str(error).lower()
if self._is_old_query_error(error_message):
return await self._handle_old_query(event, error)
if self._is_message_not_modified_error(error_message):
return await self._handle_message_not_modified(event, error)
return await self._handle_message_not_modified(event, error, data)
if self._is_topic_required_error(error_message):
# Канал с топиками — просто игнорируем
logger.debug(f'📋 [GlobalErrorMiddleware] Игнорируем ошибку топика: {error}')
logger.debug('[GlobalErrorMiddleware] Игнорируем ошибку топика: %s', error)
return None
if self._is_bad_request_error(error_message):
return await self._handle_bad_request(event, error)
logger.error(f'Неизвестная Telegram API ошибка: {error}')
return await self._handle_bad_request(event, error, data)
# Неизвестная ошибка — логируем
user_info = self._get_user_info(event)
logger.error('Неизвестная Telegram API ошибка (user=%s): %s', user_info, error)
raise error
def _is_old_query_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['query is too old', 'query id is invalid', 'response timeout expired']
)
return any(phrase in error_message for phrase in OLD_QUERY_PHRASES)
def _is_message_not_modified_error(self, error_message: str) -> bool:
return 'message is not modified' in error_message
return MESSAGE_NOT_MODIFIED_PHRASE in error_message
def _is_bad_request_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['message not found', 'chat not found', 'bot was blocked by the user', 'user is deactivated']
)
return any(phrase in error_message for phrase in BAD_REQUEST_PHRASES)
def _is_topic_required_error(self, error_message: str) -> bool:
return any(
phrase in error_message
for phrase in ['topic must be specified', 'topic_closed', 'topic_deleted', 'forum_closed']
)
return any(phrase in error_message for phrase in TOPIC_ERROR_PHRASES)
async def _handle_old_query(self, event: TelegramObject, error: TelegramBadRequest):
if isinstance(event, CallbackQuery):
user_info = self._get_user_info(event)
logger.warning(f"🕐 [GlobalErrorMiddleware] Игнорируем устаревший callback '{event.data}' от {user_info}")
logger.warning("[GlobalErrorMiddleware] Игнорируем устаревший callback '%s' от %s", event.data, user_info)
else:
logger.warning(f'🕐 [GlobalErrorMiddleware] Игнорируем устаревший запрос: {error}')
logger.warning('[GlobalErrorMiddleware] Игнорируем устаревший запрос: %s', error)
async def _handle_message_not_modified(self, event: TelegramObject, error: TelegramBadRequest):
logger.debug(f'📝 [GlobalErrorMiddleware] Сообщение не было изменено: {error}')
async def _handle_message_not_modified(
self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]
):
logger.debug('[GlobalErrorMiddleware] Сообщение не было изменено: %s', error)
if isinstance(event, CallbackQuery):
try:
await event.answer()
logger.debug("Успешно ответили на callback после 'message not modified'")
logger.debug("Успешно ответили на callback после 'message not modified'")
except TelegramBadRequest as answer_error:
if not self._is_old_query_error(str(answer_error).lower()):
logger.error(f'Ошибка при ответе на callback: {answer_error}')
logger.warning('Ошибка при ответе на callback: %s', answer_error)
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest):
async def _handle_bad_request(self, event: TelegramObject, error: TelegramBadRequest, data: dict[str, Any]):
error_message = str(error).lower()
if 'bot was blocked' in error_message:
if BOT_BLOCKED_PHRASE in error_message:
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
logger.info(f'🚫 [GlobalErrorMiddleware] Бот заблокирован пользователем {user_info}')
logger.info('[GlobalErrorMiddleware] Бот заблокирован пользователем %s', user_info)
return
if 'user is deactivated' in error_message:
if USER_DEACTIVATED_PHRASE in error_message:
user_info = self._get_user_info(event) if hasattr(event, 'from_user') else 'Unknown'
logger.info(f'👻 [GlobalErrorMiddleware] Пользователь деактивирован {user_info}')
logger.info('[GlobalErrorMiddleware] Пользователь деактивирован %s', user_info)
return
if 'chat not found' in error_message or 'message not found' in error_message:
logger.warning(f'🔍 [GlobalErrorMiddleware] Чат или сообщение не найдено: {error}')
if CHAT_NOT_FOUND_PHRASE in error_message or MESSAGE_NOT_FOUND_PHRASE in error_message:
logger.warning('[GlobalErrorMiddleware] Чат или сообщение не найдено: %s', error)
return
logger.error(f'❌ [GlobalErrorMiddleware] Неизвестная bad request ошибка: {error}')
user_info = self._get_user_info(event)
logger.error('[GlobalErrorMiddleware] Неизвестная bad request ошибка (user=%s): %s', user_info, error)
raise error
def _get_user_info(self, event: TelegramObject) -> str:
@@ -130,13 +178,13 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
def _count_error(self, error: TelegramBadRequest):
error_message = str(error).lower()
if 'query is too old' in error_message:
if OLD_QUERY_PHRASES[0] in error_message:
self.error_counts['old_queries'] += 1
elif 'message is not modified' in error_message:
elif MESSAGE_NOT_MODIFIED_PHRASE in error_message:
self.error_counts['message_not_modified'] += 1
elif 'bot was blocked' in error_message:
elif BOT_BLOCKED_PHRASE in error_message:
self.error_counts['bot_blocked'] += 1
elif 'user is deactivated' in error_message:
elif USER_DEACTIVATED_PHRASE in error_message:
self.error_counts['user_deactivated'] += 1
else:
self.error_counts['other_errors'] += 1
@@ -147,3 +195,132 @@ class ErrorStatisticsMiddleware(BaseMiddleware):
def reset_statistics(self):
for key in self.error_counts:
self.error_counts[key] = 0
async def send_error_to_admin_chat(
bot: Bot, error: Exception, context: str = '', tb_override: str | None = None
) -> bool:
"""
Отправляет уведомление об ошибке в админский чат с троттлингом.
Args:
bot: Экземпляр бота
error: Исключение
context: Дополнительный контекст (например, информация о пользователе)
tb_override: Готовый traceback (если вызывается не из except-блока)
Returns:
bool: True если уведомление отправлено
"""
global _last_error_notification
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
return False
error_type = type(error).__name__
error_message = str(error)[:ERROR_MESSAGE_MAX_LENGTH]
tb_str = tb_override or traceback.format_exc()
if tb_str == 'NoneType: None\n' or tb_str == 'NoneType: None':
tb_str = '(no traceback available)'
# Добавляем в буфер
_error_buffer.append((error_type, error_message, tb_str))
if len(_error_buffer) > ERROR_BUFFER_MAX_SIZE:
_error_buffer.pop(0)
# Проверяем троттлинг
now = datetime.utcnow()
if _last_error_notification and (now - _last_error_notification) < _error_notification_cooldown:
logger.debug('Ошибка добавлена в буфер, троттлинг активен: %s', error_type)
return False
_last_error_notification = now
try:
timestamp = format_local_datetime(now, DATETIME_FORMAT)
separator = '=' * REPORT_SEPARATOR_WIDTH
# Формируем лог-файл со всеми ошибками из буфера
log_lines = [
'ERROR REPORT',
separator,
f'Timestamp: {timestamp}',
f'Errors in buffer: {len(_error_buffer)}',
'',
]
for i, (err_type, err_msg, err_tb) in enumerate(_error_buffer):
log_lines.extend(
[
separator,
f'ERROR #{i}: {err_type}',
separator,
f'Message: {err_msg}',
'',
'Traceback:',
err_tb,
'',
]
)
log_content = '\n'.join(log_lines)
# Очищаем буфер после отправки
errors_count = len(_error_buffer)
_error_buffer.clear()
file_name = f'error_report_{now.strftime(DATETIME_FORMAT_FILENAME)}.txt'
file = BufferedInputFile(
file=log_content.encode('utf-8'),
filename=file_name,
)
message_text = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'⚠️ Ошибка во время работы\n\n'
f'<b>Тип:</b> <code>{error_type}</code>\n'
f'<b>Ошибок в отчёте:</b> {errors_count}\n'
)
if context:
message_text += f'<b>Контекст:</b> {context}\n'
# Добавляем рекомендации если есть
recommendations = _get_error_recommendations(error_message)
if recommendations:
message_text += f'\n{recommendations}\n'
message_text += f'\n<i>{timestamp}</i>'
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='💬 Сообщить разработчику',
url=DEVELOPER_CONTACT_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': chat_id,
'document': file,
'caption': message_text,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
}
if topic_id:
message_kwargs['message_thread_id'] = topic_id
await bot.send_document(**message_kwargs)
logger.info('Уведомление об ошибке отправлено в чат %s', chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки уведомления об ошибке: %s', e, extra={'_admin_notified': True})
return False
+298 -140
View File
@@ -288,9 +288,7 @@ class AdminNotificationService:
return False
user_status = '🆕 Новый' if not user.has_had_paid_subscription else '🔄 Существующий'
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
trial_device_limit = subscription.device_limit
@@ -308,33 +306,79 @@ class AdminNotificationService:
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
message = f"""🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>
# Получаем название тарифа (если режим тарифов)
tariff_name = await self._get_tariff_name(db, subscription)
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
message_lines = [
'🎯 <b>АКТИВАЦИЯ ТРИАЛА</b>',
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
f'👥 <b>Статус:</b> {user_status}',
'',
]
{promo_block}
# Промогруппа — только название, без скидок
if promo_group:
message_lines.append(f'🏷️ <b>Промогруппа:</b> {promo_group.name}')
else:
message_lines.append('🏷️ <b>Промогруппа:</b> —')
<b>Параметры триала:</b>
📅 Период: {settings.TRIAL_DURATION_DAYS} дней
📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}
📱 Устройства: {trial_device_limit}
🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else 'По умолчанию'}
{payment_block}
# Тариф триала (если есть)
if tariff_name:
message_lines.append(f'📦 <b>Тариф:</b> {tariff_name}')
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
🔗 <b>Реферер:</b> {referrer_info}
message_lines.append('')
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
message_lines.extend(
[
'⏰ <b>Параметры триала:</b>',
f'📅 Период: {settings.TRIAL_DURATION_DAYS} дней',
f'📊 Трафик: {self._format_traffic(settings.TRIAL_TRAFFIC_LIMIT_GB)}',
f'📱 Устройства: {trial_device_limit}',
f'🌐 Сервер: {subscription.connected_squads[0] if subscription.connected_squads else "По умолчанию"}',
]
)
return await self._send_message(message)
if payment_block:
message_lines.append(payment_block)
message_lines.append('')
message_lines.append(
f'📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}'
)
# Реферер — только если есть
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 <b>Реферер:</b> {referrer_info}')
message_lines.append('')
message_lines.append(f'⏰ <i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>')
return await self._send_message('\n'.join(message_lines))
except Exception as e:
logger.error(f'Ошибка отправки уведомления о триале: {e}')
return False
async def _get_tariff_name(self, db: AsyncSession, subscription: Subscription) -> str | None:
"""Получает название тарифа подписки, если он есть."""
if not subscription.tariff_id:
return None
try:
from app.database.crud.tariff import get_tariff_by_id
tariff = await get_tariff_by_id(db, subscription.tariff_id)
if tariff:
return tariff.name
except Exception:
pass
return None
async def send_subscription_purchase_notification(
self,
db: AsyncSession,
@@ -344,6 +388,7 @@ class AdminNotificationService:
period_days: int,
was_trial_conversion: bool = False,
amount_kopeks: int | None = None,
purchase_type: str | None = None, # 'tariff_switch', 'renewal', None (auto)
) -> bool:
try:
total_amount = (
@@ -371,93 +416,138 @@ class AdminNotificationService:
if not self._is_enabled():
return False
event_type = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА' if was_trial_conversion else '💎 ПОКУПКА ПОДПИСКИ'
if was_trial_conversion:
user_status = '🎯 Конверсия из триала'
elif user.has_had_paid_subscription:
user_status = '🔄 Продление/Обновление'
# Определяем тип операции и заголовок
if purchase_type == 'tariff_switch':
event_title = '🔄 СМЕНА ТАРИФА'
user_status = 'Смена тарифа'
elif was_trial_conversion:
event_title = '🔄 КОНВЕРСИЯ ИЗ ТРИАЛА'
user_status = 'Конверсия'
elif purchase_type == 'renewal' or user.has_had_paid_subscription:
event_title = '💎 ПРОДЛЕНИЕ ПОДПИСКИ'
user_status = 'Продление'
else:
user_status = '🆕 Первая покупка'
event_title = '💎 ПОКУПКА ПОДПИСКИ'
user_status = 'Первая покупка'
# Получаем название тарифа
tariff_name = await self._get_tariff_name(db, subscription)
servers_info = await self._get_servers_info(subscription.connected_squads)
payment_method = self._get_payment_method_display(transaction.payment_method) if transaction else 'Баланс'
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
transaction_id = transaction.id if transaction else ''
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
message = f"""💎 <b>{event_type}</b>
# Формируем компактное сообщение
message_lines = [
f'<b>{event_title}</b>',
'',
f'👤 {user_display} ({user_id_display})',
]
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
👥 <b>Статус:</b> {user_status}
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
{promo_block}
message_lines.append(f'📋 {user_status}')
💰 <b>Платеж:</b>
💵 Сумма: {settings.format_price(total_amount)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction_id}
# Тариф (если есть)
if tariff_name:
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
📱 <b>Параметры подписки:</b>
📅 Период: {period_days} дней
📊 Трафик: {self._format_traffic(subscription.traffic_limit_gb)}
📱 Устройства: {subscription.device_limit}
🌐 Серверы: {servers_info}
message_lines.extend(
[
'',
f'💵 <b>{settings.format_price(total_amount)}</b> • {payment_method}',
f'📅 {period_days} дн. • до {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
f'📊 {self._format_traffic(subscription.traffic_limit_gb)} • 📱 {subscription.device_limit} устр.',
f'🌐 {servers_info}',
]
)
📆 <b>Действует до:</b> {format_local_datetime(subscription.end_date, '%d.%m.%Y %H:%M')}
💰 <b>Баланс после покупки:</b> {settings.format_price(user.balance_kopeks)}
🔗 <b>Реферер:</b> {referrer_info}
# Баланс после покупки
message_lines.append(f'💰 Баланс: {settings.format_price(user.balance_kopeks)}')
<i>{format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
# Реферер (только если есть)
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 Реф: {referrer_info}')
return await self._send_message(message)
# ID транзакции (только если есть)
if transaction:
message_lines.append(f'🆔 #{transaction.id}')
message_lines.extend(
[
'',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
]
)
return await self._send_message('\n'.join(message_lines))
except Exception as e:
logger.error(f'Ошибка отправки уведомления о покупке: {e}')
return False
async def send_version_update_notification(self, current_version: str, latest_version, total_updates: int) -> bool:
"""Отправляет уведомление о новых обновлениях"""
"""Отправляет уведомление о новых обновлениях."""
if not self._is_enabled():
return False
try:
from app.utils.markdown_to_telegram import github_markdown_to_telegram_html, truncate_for_blockquote
repo = getattr(settings, 'VERSION_CHECK_REPO', 'fr1ngg/remnawave-bedolaga-telegram-bot')
release_url = f'https://github.com/{repo}/releases/tag/{latest_version.tag_name}'
repo_url = f'https://github.com/{repo}'
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
if latest_version.prerelease:
update_type = '🧪 ПРЕДВАРИТЕЛЬНАЯ ВЕРСИЯ'
type_icon = '🧪'
header = '🧪 <b>Pre-release</b>'
elif latest_version.is_dev:
update_type = '🔧 DEV ВЕРСИЯ'
type_icon = '🔧'
header = '🔧 <b>Dev build</b>'
else:
update_type = '📦 НОВАЯ ВЕРСИЯ'
type_icon = '📦'
header = '🆕 <b>Доступно обновление</b>'
description = latest_version.short_description
if len(description) > 200:
description = description[:197] + '...'
# -- message prefix (everything before blockquote) --
prefix_lines = [
header,
'',
f'<code>{current_version}</code> → <b><a href="{release_url}">{latest_version.tag_name}</a></b>',
f'📅 {latest_version.formatted_date}',
'',
]
message_prefix = '\n'.join(prefix_lines)
message = f"""{type_icon} <b>{update_type} ДОСТУПНА</b>
# -- message suffix (everything after blockquote) --
suffix_lines = ['']
if total_updates > 1:
suffix_lines.append(f'Доступно обновлений: <b>{total_updates}</b>')
suffix_lines.extend(
[
f'<a href="{repo_url}">Репозиторий</a>',
'',
f'<i>{timestamp}</i>',
]
)
message_suffix = '\n'.join(suffix_lines)
📦 <b>Текущая версия:</b> <code>{current_version}</code>
🆕 <b>Новая версия:</b> <code>{latest_version.tag_name}</code>
📅 <b>Дата релиза:</b> {latest_version.formatted_date}
# -- description in blockquote --
raw_description = getattr(latest_version, 'full_description', '') or latest_version.short_description
description_html = github_markdown_to_telegram_html(raw_description)
📝 <b>Описание:</b>
{description}
🔢 <b>Всего доступно обновлений:</b> {total_updates}
🔗 <b>Репозиторий:</b> https://github.com/{getattr(self, 'repo', 'fr1ngg/remnawave-bedolaga-telegram-bot')}
Для обновления перезапустите контейнер с новым тегом или обновите код из репозитория.
<i>Автоматическая проверка обновлений {format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')}</i>"""
if description_html:
description_html = truncate_for_blockquote(
description_html,
message_prefix=message_prefix,
message_suffix=message_suffix,
)
message = f'{message_prefix}<blockquote expandable>{description_html}</blockquote>{message_suffix}'
else:
message = f'{message_prefix}{message_suffix}'
return await self._send_message(message)
@@ -500,35 +590,81 @@ class AdminNotificationService:
payment_method = self._get_payment_method_display(transaction.payment_method)
balance_change = user.balance_kopeks - old_balance
subscription_status = self._get_subscription_status(subscription)
promo_block = self._format_promo_group_block(promo_group)
timestamp = format_local_datetime(datetime.utcnow(), '%d.%m.%Y %H:%M:%S')
user_display = self._get_user_display(user)
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
return f"""💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>
# --- Основной блок ---
message_lines: list[str] = [
'💰 <b>ПОПОЛНЕНИЕ БАЛАНСА</b>',
'',
f'👤 {user_display} ({user_id_display})',
]
👤 <b>Пользователь:</b> {user_display}
🆔 <b>{user_id_label}:</b> {user_id_display}
📱 <b>Username:</b> @{getattr(user, 'username', None) or 'отсутствует'}
💳 <b>Статус:</b> {topup_status}
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
{promo_block}
message_lines.append(f'💳 {topup_status}')
💰 <b>Детали пополнения:</b>
💵 Сумма: {settings.format_price(transaction.amount_kopeks)}
💳 Способ: {payment_method}
🆔 ID транзакции: {transaction.id}
# Промогруппа -- только название
if promo_group:
message_lines.append(f'🏷️ Промогруппа: {promo_group.name}')
💰 <b>Баланс:</b>
📉 Было: {settings.format_price(old_balance)}
📈 Стало: {settings.format_price(user.balance_kopeks)}
Изменение: +{settings.format_price(balance_change)}
message_lines.append('')
🔗 <b>Реферер:</b> {referrer_info}
📱 <b>Подписка:</b> {subscription_status}
# --- Детали пополнения ---
message_lines.extend(
[
f'💵 <b>{settings.format_price(transaction.amount_kopeks)}</b> | {payment_method}',
'',
f'📉 {settings.format_price(old_balance)} → 📈 {settings.format_price(user.balance_kopeks)}'
f' (<b>+{settings.format_price(balance_change)}</b>)',
]
)
<i>{timestamp}</i>"""
# --- Подписка ---
message_lines.append(f'📱 Подписка: {subscription_status}')
# --- Реферер (только если есть) ---
if referrer_info and referrer_info != 'Нет':
message_lines.append(f'🔗 Реферер: {referrer_info}')
# --- Expandable blockquote с техническими деталями ---
detail_lines: list[str] = [
f'ID транзакции: {transaction.id}',
f'Способ оплаты: {transaction.payment_method or "balance"}',
]
if transaction.external_id:
detail_lines.append(f'Внешний ID: {transaction.external_id}')
if transaction.description:
desc = transaction.description
if len(desc) > 120:
desc = desc[:117] + '...'
detail_lines.append(f'Описание: {desc}')
if transaction.created_at:
detail_lines.append(f'Создана: {format_local_datetime(transaction.created_at, "%d.%m.%Y %H:%M:%S")}')
if transaction.completed_at:
detail_lines.append(f'Завершена: {format_local_datetime(transaction.completed_at, "%d.%m.%Y %H:%M:%S")}')
if transaction.receipt_uuid:
detail_lines.append(f'Чек UUID: {transaction.receipt_uuid}')
blockquote_body = '\n'.join(detail_lines)
message_lines.extend(
[
'',
f'<blockquote expandable>{blockquote_body}</blockquote>',
]
)
message_lines.append(f'<i>{timestamp}</i>')
return '\n'.join(message_lines)
async def _reload_topup_notification_entities(
self,
@@ -1081,21 +1217,27 @@ class AdminNotificationService:
return self.enabled and bool(self.chat_id)
def _get_payment_method_display(self, payment_method: str | None) -> str:
mulenpay_name = settings.get_mulenpay_display_name()
method_names = {
if not payment_method:
return '💰 С баланса'
method_names: dict[str, str] = {
'telegram_stars': '⭐ Telegram Stars',
'yookassa': '💳 YooKassa (карта)',
'tribute': '💎 Tribute (карта)',
'mulenpay': f'💳 {mulenpay_name} (карта)',
'pal24': '🏦 PayPalych (СБП)',
'mulenpay': f'💳 {settings.get_mulenpay_display_name()} (карта)',
'pal24': f'🏦 {settings.get_pal24_display_name()} (СБП)',
'cryptobot': f'🪙 {settings.get_cryptobot_display_name()} (крипто)',
'heleket': f'🪙 {settings.get_heleket_display_name()} (крипто)',
'wata': f'💳 {settings.get_wata_display_name()}',
'platega': f'💳 {settings.get_platega_display_name()}',
'cloudpayments': f'💳 {settings.get_cloudpayments_display_name()}',
'freekassa': f'💳 {settings.get_freekassa_display_name()}',
'kassa_ai': f'💳 {settings.get_kassa_ai_display_name()}',
'manual': '🛠️ Вручную (админ)',
'balance': '💰 С баланса',
}
if not payment_method:
return '💰 С баланса'
return method_names.get(payment_method, '💰 С баланса')
return method_names.get(payment_method, f'💳 {payment_method}')
def _format_traffic(self, traffic_gb: int) -> str:
if traffic_gb == 0:
@@ -1369,65 +1511,81 @@ class AdminNotificationService:
return False
try:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
promo_group = await self._get_user_promo_group(db, user)
promo_block = self._format_promo_group_block(promo_group)
user_display = self._get_user_display(user)
update_types = {
'traffic': ('📊 ИЗМЕНЕНИЕ ТРАФИКА', 'трафик'),
'devices': ('📱 ИЗМЕНЕНИЕ УСТРОЙСТВ', 'количество устройств'),
'servers': ('🌐 ИЗМЕНЕНИЕ СЕРВЕРОВ', 'серверы'),
'modem': ('📡 ИЗМЕНЕНИЕ МОДЕМА', 'модем'),
}
title, param_name = update_types.get(update_type, ('⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ', 'параметры'))
user_id_label = self._get_user_identifier_label(user)
user_id_display = self._get_user_identifier_display(user)
# Определяем заголовок по типу операции
update_titles = {
'traffic': '📊 ДОКУПКА ТРАФИКА',
'devices': '📱 ДОКУПКА УСТРОЙСТВ',
'servers': '🌐 СМЕНА СЕРВЕРОВ',
'modem': '📡 МОДЕМ',
}
title = update_titles.get(update_type, '⚙️ ИЗМЕНЕНИЕ ПОДПИСКИ')
# Получаем название тарифа
tariff_name = await self._get_tariff_name(db, subscription)
# Формируем компактное сообщение
message_lines = [
f'{title}',
f'<b>{title}</b>',
'',
f'👤 <b>Пользователь:</b> {user_display}',
f'🆔 <b>{user_id_label}:</b> {user_id_display}',
f'📱 <b>Username:</b> @{getattr(user, "username", None) or "отсутствует"}',
'',
promo_block,
'',
'🔧 <b>Изменение:</b>',
f'📋 Параметр: {param_name}',
f'👤 {user_display} ({user_id_display})',
]
# Добавляем username только если есть
username = getattr(user, 'username', None)
if username:
message_lines.append(f'📱 @{username}')
# Тариф (если есть)
if tariff_name:
message_lines.append(f'🏷️ Тариф: <b>{tariff_name}</b>')
message_lines.append('')
# Форматируем изменение в зависимости от типа
if update_type == 'servers':
old_servers_info = await self._format_servers_detailed(old_value)
new_servers_info = await self._format_servers_detailed(new_value)
message_lines.extend(
[
f'📉 Было: {old_servers_info}',
f'📈 Стало: {new_servers_info}',
]
)
message_lines.append(f'🔄 {old_servers_info}{new_servers_info}')
elif update_type == 'traffic':
old_formatted = self._format_update_value(old_value, update_type)
new_formatted = self._format_update_value(new_value, update_type)
message_lines.append(f'🔄 {old_formatted}{new_formatted}')
elif update_type == 'devices':
message_lines.append(f'🔄 {old_value}{new_value} устр.')
elif update_type == 'modem':
old_state = '✅ Вкл' if old_value else '❌ Выкл'
new_state = '✅ Вкл' if new_value else '❌ Выкл'
message_lines.append(f'🔄 {old_state}{new_state}')
else:
message_lines.extend(
[
f'📉 Было: {self._format_update_value(old_value, update_type)}',
f'📈 Стало: {self._format_update_value(new_value, update_type)}',
]
)
message_lines.append(f'🔄 {old_value}{new_value}')
# Стоимость операции
if price_paid > 0:
message_lines.append(f'💰 Доплачено: {settings.format_price(price_paid)}')
message_lines.append(f'💵 <b>{settings.format_price(price_paid)}</b>')
else:
message_lines.append('💸 Бесплатно')
message_lines.extend(
[
'',
f'📅 <b>Подписка действует до:</b> {format_local_datetime(subscription.end_date, "%d.%m.%Y %H:%M")}',
f'💰 <b>Баланс после операции:</b> {settings.format_price(user.balance_kopeks)}',
f'🔗 <b>Рефер:</b> {referrer_info}',
f'📅 До {format_local_datetime(subscription.end_date, "%d.%m.%Y")}',
f'💰 Баланс: {settings.format_price(user.balance_kopeks)}',
]
)
# Реферер (только если есть)
if user.referred_by_id:
referrer_info = await self._get_referrer_info(db, user.referred_by_id)
if referrer_info != 'Нет':
message_lines.append(f'🔗 Реф: {referrer_info}')
message_lines.extend(
[
'',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M:%S")}</i>',
f'<i>{format_local_datetime(datetime.utcnow(), "%d.%m.%Y %H:%M")}</i>',
]
)
+2 -1
View File
@@ -160,8 +160,9 @@ class BlacklistService:
# Проверяем по username, если он передан
if username:
username_lower = username.lower().lstrip('@')
for bl_id, bl_username, bl_reason in self.blacklist_data:
if bl_username and (bl_username == username or bl_username == f'@{username}'):
if bl_username and bl_username.lower().lstrip('@') == username_lower:
logger.info(
f'Пользователь {username} ({telegram_id}) найден в черном списке по username: {bl_reason}'
)
+449
View File
@@ -0,0 +1,449 @@
"""
Сервис для проверки пользователей, заблокировавших бота.
Проверяет возможность отправки сообщений пользователям и позволяет
очистить БД и панель Remnawave от неактивных пользователей.
"""
import asyncio
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from aiogram import Bot
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
AdvertisingCampaignRegistration,
ButtonClickLog,
CabinetRefreshToken,
CloudPaymentsPayment,
ContestAttempt,
CryptoBotPayment,
DiscountOffer,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PollResponse,
PromoCodeUse,
ReferralContestEvent,
ReferralEarning,
SentNotification,
Subscription,
SubscriptionConversion,
SubscriptionEvent,
SubscriptionServer,
Ticket,
TicketMessage,
TicketNotification,
Transaction,
User,
UserPromoGroup,
UserStatus,
WataPayment,
WheelSpin,
WithdrawalRequest,
YooKassaPayment,
)
from app.services.remnawave_service import RemnaWaveService
logger = logging.getLogger(__name__)
class BlockCheckStatus(Enum):
"""Статус проверки блокировки пользователя."""
BLOCKED = 'blocked'
ACTIVE = 'active'
NO_TELEGRAM_ID = 'no_telegram_id'
ERROR = 'error'
class BlockedUserAction(Enum):
"""Действия над заблокированными пользователями."""
DELETE_FROM_DB = 'delete_from_db'
DELETE_FROM_REMNAWAVE = 'delete_from_remnawave'
DELETE_BOTH = 'delete_both'
MARK_AS_BLOCKED = 'mark_as_blocked'
@dataclass
class BlockCheckResult:
"""Результат проверки одного пользователя."""
user_id: int
telegram_id: int | None
username: str | None
full_name: str
status: BlockCheckStatus
error_message: str | None = None
remnawave_uuid: str | None = None
@dataclass
class BlockedUsersScanResult:
"""Результат сканирования пользователей на блокировку."""
total_checked: int = 0
blocked_users: list[BlockCheckResult] = field(default_factory=list)
active_users: int = 0
errors: int = 0
skipped_no_telegram: int = 0
scan_duration_seconds: float = 0.0
@property
def blocked_count(self) -> int:
return len(self.blocked_users)
@dataclass
class CleanupResult:
"""Результат очистки заблокированных пользователей."""
deleted_from_db: int = 0
deleted_from_remnawave: int = 0
marked_as_blocked: int = 0
errors: list[str] = field(default_factory=list)
class BlockedUsersService:
"""Сервис проверки и очистки заблокированных пользователей."""
# Задержка между проверками для избежания rate limit
CHECK_DELAY_SECONDS: float = 0.05
# Максимальное количество параллельных проверок
MAX_CONCURRENT_CHECKS: int = 10
# Задержка между API запросами к Remnawave (rate limit protection)
API_DELAY_SECONDS: float = 0.15
def __init__(self, bot: Bot):
self.bot = bot
self.remnawave_service = RemnaWaveService()
async def check_user_blocked(self, telegram_id: int) -> BlockCheckStatus:
"""
Проверяет, заблокировал ли пользователь бота.
Отправляет ChatAction.TYPING - это не создает видимого сообщения,
но позволяет определить блокировку.
"""
try:
await self.bot.send_chat_action(chat_id=telegram_id, action='typing')
return BlockCheckStatus.ACTIVE
except TelegramForbiddenError:
# Пользователь заблокировал бота
return BlockCheckStatus.BLOCKED
except TelegramBadRequest as e:
error_lower = str(e).lower()
if 'chat not found' in error_lower or 'user not found' in error_lower:
# Пользователь удалил аккаунт или никогда не начинал диалог
return BlockCheckStatus.BLOCKED
logger.warning(f'TelegramBadRequest при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except TelegramAPIError as e:
logger.warning(f'TelegramAPIError при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
except Exception as e:
logger.error(f'Неожиданная ошибка при проверке {telegram_id}: {e}')
return BlockCheckStatus.ERROR
async def _check_single_user(self, user: User) -> BlockCheckResult:
"""Проверяет одного пользователя."""
if not user.telegram_id:
return BlockCheckResult(
user_id=user.id,
telegram_id=None,
username=user.username,
full_name=user.full_name,
status=BlockCheckStatus.NO_TELEGRAM_ID,
remnawave_uuid=user.remnawave_uuid,
)
status = await self.check_user_blocked(user.telegram_id)
return BlockCheckResult(
user_id=user.id,
telegram_id=user.telegram_id,
username=user.username,
full_name=user.full_name,
status=status,
remnawave_uuid=user.remnawave_uuid,
)
async def scan_all_users(
self,
db: AsyncSession,
*,
only_active: bool = True,
batch_size: int = 100,
progress_callback: Callable | None = None,
) -> BlockedUsersScanResult:
"""
Сканирует всех пользователей на предмет блокировки бота.
Args:
db: Сессия БД
only_active: Проверять только активных пользователей
batch_size: Размер батча для загрузки из БД
progress_callback: Callback для отчета о прогрессе (checked, total)
Returns:
Результат сканирования
"""
start_time = datetime.now(tz=UTC)
result = BlockedUsersScanResult()
# Формируем запрос
query = select(User).options(selectinload(User.subscription))
if only_active:
query = query.where(User.status == UserStatus.ACTIVE.value)
query = query.where(User.telegram_id.isnot(None))
# Получаем всех пользователей
users_result = await db.execute(query)
all_users = users_result.scalars().all()
total_users = len(all_users)
logger.info(f'Начинаем проверку {total_users} пользователей на блокировку бота')
# Проверяем пользователей батчами с ограничением параллелизма
semaphore = asyncio.Semaphore(self.MAX_CONCURRENT_CHECKS)
async def check_with_semaphore(user: User) -> BlockCheckResult:
async with semaphore:
check_result = await self._check_single_user(user)
await asyncio.sleep(self.CHECK_DELAY_SECONDS)
return check_result
checked = 0
for i in range(0, total_users, batch_size):
batch = all_users[i : i + batch_size]
tasks = [check_with_semaphore(user) for user in batch]
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
for check_result in batch_results:
if isinstance(check_result, Exception):
result.errors += 1
logger.error(f'Ошибка при проверке пользователя: {check_result}')
continue
result.total_checked += 1
if check_result.status == BlockCheckStatus.BLOCKED:
result.blocked_users.append(check_result)
elif check_result.status == BlockCheckStatus.ACTIVE:
result.active_users += 1
elif check_result.status == BlockCheckStatus.NO_TELEGRAM_ID:
result.skipped_no_telegram += 1
else:
result.errors += 1
checked += len(batch)
if progress_callback:
await progress_callback(checked, total_users)
result.scan_duration_seconds = (datetime.now(tz=UTC) - start_time).total_seconds()
logger.info(
f'Сканирование завершено: {result.blocked_count} заблокированных '
f'из {result.total_checked} проверенных за {result.scan_duration_seconds:.1f}с'
)
return result
async def delete_user_from_remnawave(self, remnawave_uuid: str) -> bool:
"""Удаляет пользователя из панели Remnawave."""
if not remnawave_uuid:
return False
try:
if not self.remnawave_service.is_configured:
logger.warning('Remnawave API не настроен')
return False
async with self.remnawave_service.get_api_client() as api:
await api.delete_user(remnawave_uuid)
logger.info(f'Удален пользователь {remnawave_uuid} из Remnawave')
return True
except Exception as e:
error_msg = str(e).lower()
if 'not found' in error_msg or '404' in error_msg:
logger.info(f'Пользователь {remnawave_uuid} уже удален из Remnawave')
return True
logger.error(f'Ошибка удаления {remnawave_uuid} из Remnawave: {e}')
return False
async def delete_user_from_db(self, db: AsyncSession, user_id: int) -> bool:
"""
Полностью удаляет пользователя из БД со всеми связанными данными.
"""
try:
# Получаем пользователя
user_result = await db.execute(
select(User).options(selectinload(User.subscription)).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
if not user:
logger.warning(f'Пользователь {user_id} не найден в БД')
return False
user_display = user.telegram_id or user.email or f'#{user.id}'
# Удаляем связанные записи (порядок важен из-за foreign keys)
# 1. Платежные системы (до транзакций, т.к. ссылаются на них)
await db.execute(delete(YooKassaPayment).where(YooKassaPayment.user_id == user.id))
await db.execute(delete(CryptoBotPayment).where(CryptoBotPayment.user_id == user.id))
await db.execute(delete(HeleketPayment).where(HeleketPayment.user_id == user.id))
await db.execute(delete(MulenPayPayment).where(MulenPayPayment.user_id == user.id))
await db.execute(delete(Pal24Payment).where(Pal24Payment.user_id == user.id))
await db.execute(delete(WataPayment).where(WataPayment.user_id == user.id))
await db.execute(delete(PlategaPayment).where(PlategaPayment.user_id == user.id))
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user.id))
await db.execute(delete(FreekassaPayment).where(FreekassaPayment.user_id == user.id))
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user.id))
# 2. Транзакции (после платежей)
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
# 3. Подписки
if user.subscription:
await db.execute(
delete(SubscriptionServer).where(SubscriptionServer.subscription_id == user.subscription.id)
)
await db.execute(delete(Subscription).where(Subscription.user_id == user.id))
await db.execute(delete(SubscriptionConversion).where(SubscriptionConversion.user_id == user.id))
await db.execute(delete(SubscriptionEvent).where(SubscriptionEvent.user_id == user.id))
# 4. Тикеты (сначала зависимые)
await db.execute(delete(TicketNotification).where(TicketNotification.user_id == user.id))
await db.execute(delete(TicketMessage).where(TicketMessage.user_id == user.id))
await db.execute(delete(Ticket).where(Ticket.user_id == user.id))
# 5. Остальные связи
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
await db.execute(delete(WithdrawalRequest).where(WithdrawalRequest.user_id == user.id))
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
await db.execute(delete(DiscountOffer).where(DiscountOffer.user_id == user.id))
await db.execute(delete(SentNotification).where(SentNotification.user_id == user.id))
await db.execute(delete(PollResponse).where(PollResponse.user_id == user.id))
await db.execute(delete(ContestAttempt).where(ContestAttempt.user_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referrer_id == user.id))
await db.execute(delete(ReferralContestEvent).where(ReferralContestEvent.referral_id == user.id))
await db.execute(
delete(AdvertisingCampaignRegistration).where(AdvertisingCampaignRegistration.user_id == user.id)
)
await db.execute(delete(UserPromoGroup).where(UserPromoGroup.user_id == user.id))
await db.execute(delete(CabinetRefreshToken).where(CabinetRefreshToken.user_id == user.id))
await db.execute(delete(ButtonClickLog).where(ButtonClickLog.user_id == user.id))
await db.execute(delete(WheelSpin).where(WheelSpin.user_id == user.id))
# Обнуляем referred_by_id у рефералов этого пользователя
referrals_query = select(User).where(User.referred_by_id == user.id)
referrals_result = await db.execute(referrals_query)
for referral in referrals_result.scalars().all():
referral.referred_by_id = None
# Удаляем пользователя
await db.delete(user)
await db.commit()
logger.info(f'Пользователь {user_display} полностью удален из БД')
return True
except Exception as e:
logger.error(f'Ошибка удаления пользователя {user_id} из БД: {e}')
await db.rollback()
return False
async def mark_user_as_blocked(self, db: AsyncSession, user_id: int) -> bool:
"""Помечает пользователя как заблокированного в БД."""
try:
user_result = await db.execute(select(User).where(User.id == user_id))
user = user_result.scalar_one_or_none()
if not user:
return False
user.status = UserStatus.BLOCKED.value
user.updated_at = datetime.now(tz=UTC)
await db.commit()
logger.info(f'Пользователь {user.telegram_id or user.id} помечен как заблокированный')
return True
except Exception as e:
logger.error(f'Ошибка пометки пользователя {user_id}: {e}')
await db.rollback()
return False
async def cleanup_blocked_users(
self,
db: AsyncSession,
blocked_users: list[BlockCheckResult],
action: BlockedUserAction,
*,
progress_callback: Callable | None = None,
) -> CleanupResult:
"""
Выполняет очистку заблокированных пользователей.
Args:
db: Сессия БД
blocked_users: Список заблокированных пользователей
action: Действие для выполнения
progress_callback: Callback для отчета о прогрессе
Returns:
Результат очистки
"""
result = CleanupResult()
total = len(blocked_users)
for i, user_result in enumerate(blocked_users):
try:
if action in (BlockedUserAction.DELETE_FROM_REMNAWAVE, BlockedUserAction.DELETE_BOTH):
if user_result.remnawave_uuid:
success = await self.delete_user_from_remnawave(user_result.remnawave_uuid)
if success:
result.deleted_from_remnawave += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из Remnawave')
# Задержка для избежания rate limit
await asyncio.sleep(self.API_DELAY_SECONDS)
if action in (BlockedUserAction.DELETE_FROM_DB, BlockedUserAction.DELETE_BOTH):
success = await self.delete_user_from_db(db, user_result.user_id)
if success:
result.deleted_from_db += 1
else:
result.errors.append(f'Ошибка удаления {user_result.telegram_id} из БД')
if action == BlockedUserAction.MARK_AS_BLOCKED:
success = await self.mark_user_as_blocked(db, user_result.user_id)
if success:
result.marked_as_blocked += 1
else:
result.errors.append(f'Ошибка пометки {user_result.telegram_id}')
if progress_callback:
await progress_callback(i + 1, total)
except Exception as e:
error_msg = f'Ошибка обработки {user_result.telegram_id}: {e}'
result.errors.append(error_msg)
logger.error(error_msg)
return result
+201 -199
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from typing import TYPE_CHECKING
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.exc import InterfaceError, SQLAlchemyError
@@ -27,8 +28,18 @@ logger = logging.getLogger(__name__)
VALID_MEDIA_TYPES = {'photo', 'video', 'document'}
LARGE_BROADCAST_THRESHOLD = 20_000
PROGRESS_UPDATE_STEP = 5_000
# =========================================================================
# Telegram rate limits: ~30 msg/sec для бота.
# batch_size=25 + 1 sec delay = ~25 msg/sec с запасом.
# =========================================================================
_TG_BATCH_SIZE = 25
_TG_BATCH_DELAY = 1.0 # секунда между батчами
_TG_MAX_RETRIES = 3 # retry при FloodWait / transient errors
# Прогресс обновляется каждые ~500 сообщений ИЛИ раз в 5 секунд (что наступит раньше)
_PROGRESS_UPDATE_MESSAGES = 500
_PROGRESS_MIN_INTERVAL_SEC = 5.0
# Email broadcast rate limiting: max 8 emails per second
EMAIL_RATE_LIMIT = 8
@@ -61,6 +72,14 @@ class EmailBroadcastConfig:
initiator_name: str | None = None
@dataclass(slots=True)
class _EmailRecipient:
"""Скалярные данные получателя email (без ORM)."""
email: str
user_name: str
@dataclass(slots=True)
class _BroadcastTask:
task: asyncio.Task
@@ -136,7 +155,8 @@ class BroadcastService:
broadcast.failed_count = 0
await session.commit()
recipients = await self._fetch_recipients(config.target)
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
recipient_ids: list[int] = await self._fetch_recipients(config.target)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
@@ -144,45 +164,35 @@ class BroadcastService:
logger.error('Запись рассылки %s удалена до запуска', broadcast_id)
return
broadcast.total_count = len(recipients)
broadcast.total_count = len(recipient_ids)
await session.commit()
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return
if not recipients:
if not recipient_ids:
logger.info('Рассылка %s: получатели не найдены', broadcast_id)
await self._mark_finished(broadcast_id, sent_count, failed_count, cancelled=False)
return
keyboard = self._build_keyboard(config.selected_buttons)
if len(recipients) > LARGE_BROADCAST_THRESHOLD:
logger.info('Запускаем стабильный режим рассылки для %s получателей', len(recipients))
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_resilient_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
else:
(
sent_count,
failed_count,
cancelled_during_run,
) = await self._run_standard_broadcast(
broadcast_id,
recipients,
config,
keyboard,
cancel_event,
)
logger.info(
'Рассылка %s: начинаем отправку %d получателям (batch=%d, delay=%.1fs)',
broadcast_id,
len(recipient_ids),
_TG_BATCH_SIZE,
_TG_BATCH_DELAY,
)
sent_count, failed_count, cancelled_during_run = await self._send_batched(
broadcast_id,
recipient_ids,
config,
keyboard,
cancel_event,
)
if cancelled_during_run:
logger.info(
@@ -211,140 +221,125 @@ class BroadcastService:
logger.exception('Критическая ошибка при выполнении рассылки %s: %s', broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_recipients(self, target: str):
async def _fetch_recipients(self, target: str) -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
async with AsyncSessionLocal() as session:
if target.startswith('custom_'):
criteria = target[len('custom_') :]
return await get_custom_users(session, criteria)
return await get_target_users(session, target)
users_orm = await get_custom_users(session, criteria)
else:
users_orm = await get_target_users(session, target)
async def _run_standard_broadcast(
# Извлекаем telegram_id сразу, пока сессия жива.
# После выхода из блока ORM-объекты станут detached.
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
async def _send_batched(
self,
broadcast_id: int,
recipients: list,
recipient_ids: list[int],
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Базовый режим рассылки для небольших списков."""
"""
Единый метод рассылки для любого количества получателей.
Батчинг по _TG_BATCH_SIZE сообщений с _TG_BATCH_DELAY задержкой.
Прогресс обновляется каждые _PROGRESS_UPDATE_MESSAGES сообщений.
Глобальная пауза при FloodWait.
"""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(20)
# Глобальная пауза при FloodWait — все корутины ждут
flood_wait_until: float = 0.0
last_progress_update: float = 0.0
last_progress_count: int = 0
async def send_single(telegram_id: int) -> bool:
nonlocal flood_wait_until
for attempt in range(_TG_MAX_RETRIES):
# Глобальная пауза при FloodWait
now = asyncio.get_event_loop().time()
if flood_wait_until > now:
await asyncio.sleep(flood_wait_until - now)
async def send_single_message(user):
"""Отправляет одно сообщение с семафором ограничения"""
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, 'telegram_id', None)
if telegram_id is None:
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
return None
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %s: %s',
except TelegramRetryAfter as e:
wait_seconds = e.retry_after + 1
flood_wait_until = asyncio.get_event_loop().time() + wait_seconds
logger.warning(
'FloodWait рассылки %s: Telegram просит %d сек (user=%d, попытка %d/%d)',
broadcast_id,
e.retry_after,
telegram_id,
exc,
attempt + 1,
_TG_MAX_RETRIES,
)
await asyncio.sleep(wait_seconds)
except TelegramForbiddenError:
return False
# Отправляем сообщения пакетами для эффективности
batch_size = 100
skipped_count = 0
for i in range(0, len(recipients), batch_size):
except TelegramBadRequest:
return False
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %d (попытка %d/%d): %s',
broadcast_id,
telegram_id,
attempt + 1,
_TG_MAX_RETRIES,
exc,
)
if attempt < _TG_MAX_RETRIES - 1:
await asyncio.sleep(0.5 * (attempt + 1))
return False
for i in range(0, len(recipient_ids), _TG_BATCH_SIZE):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
batch = recipient_ids[i : i + _TG_BATCH_SIZE]
results = await asyncio.gather(
*[send_single(tid) for tid in batch],
return_exceptions=True,
)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Email-пользователи - пропускаем без ошибки
skipped_count += 1
else:
failed_count += 1
# Небольшая задержка между пакетами для снижения нагрузки на API
await asyncio.sleep(0.1)
return sent_count, failed_count, False
async def _run_resilient_broadcast(
self,
broadcast_id: int,
recipients: list,
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Режим рассылки с периодическим обновлением статуса для больших списков."""
sent_count = 0
failed_count = 0
# Ограничение на количество одновременных отправок
semaphore = asyncio.Semaphore(15)
async def send_single_message(user):
async with semaphore:
if cancel_event.is_set():
return False
telegram_id = getattr(user, 'telegram_id', None)
if telegram_id is None:
# Email-пользователи без telegram_id - пропускаем (не считаем ошибкой)
return None
try:
await self._deliver_message(telegram_id, config, keyboard)
return True
except Exception as exc:
logger.error(
'Ошибка отправки рассылки %s пользователю %s: %s',
broadcast_id,
telegram_id,
exc,
)
return False
batch_size = 100
for i in range(0, len(recipients), batch_size):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + batch_size]
tasks = [send_single_message(user) for user in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Email-пользователи - пропускаем без ошибки
pass
else:
if isinstance(result, bool):
if result:
sent_count += 1
else:
failed_count += 1
elif isinstance(result, Exception):
failed_count += 1
logger.error('Необработанное исключение в рассылке %s: %s', broadcast_id, result)
# Обновляем прогресс в БД периодически
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0:
now = asyncio.get_event_loop().time()
if (
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
or now - last_progress_update >= _PROGRESS_MIN_INTERVAL_SEC
):
await self._update_progress(broadcast_id, sent_count, failed_count)
last_progress_count = processed
last_progress_update = now
await asyncio.sleep(0.1)
# Задержка между батчами для rate limiting
await asyncio.sleep(_TG_BATCH_DELAY)
return sent_count, failed_count, False
@@ -359,37 +354,36 @@ class BroadcastService:
config: BroadcastConfig,
keyboard: InlineKeyboardMarkup | None,
) -> None:
"""
Отправляет одно сообщение.
НЕ ловит исключения TelegramRetryAfter, TelegramForbiddenError и др.
обрабатываются в вызывающем коде (_send_batched).
"""
if not self._bot:
raise RuntimeError('Телеграм-бот не инициализирован')
if config.media and config.media.type in VALID_MEDIA_TYPES:
caption = config.media.caption or config.message_text
if config.media.type == 'photo':
await self._bot.send_photo(
chat_id=telegram_id,
photo=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == 'video':
await self._bot.send_video(
chat_id=telegram_id,
video=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
elif config.media.type == 'document':
await self._bot.send_document(
chat_id=telegram_id,
document=config.media.file_id,
caption=caption,
reply_markup=keyboard,
)
media_methods = {
'photo': ('photo', self._bot.send_photo),
'video': ('video', self._bot.send_video),
'document': ('document', self._bot.send_document),
}
kwarg_name, send_method = media_methods[config.media.type]
await send_method(
chat_id=telegram_id,
**{kwarg_name: config.media.file_id},
caption=caption,
parse_mode='HTML',
reply_markup=keyboard,
)
return
await self._bot.send_message(
chat_id=telegram_id,
text=config.message_text,
parse_mode='HTML',
reply_markup=keyboard,
)
@@ -617,8 +611,13 @@ class EmailBroadcastService:
logger.exception('Critical error in email broadcast %s: %s', broadcast_id, exc)
await self._mark_failed(broadcast_id, sent_count, failed_count)
async def _fetch_email_recipients(self, target: str) -> list:
"""Fetch email recipients based on target filter."""
async def _fetch_email_recipients(self, target: str) -> list[_EmailRecipient]:
"""
Загружает получателей email-рассылки.
Возвращает список _EmailRecipient (скалярные данные), а не ORM-объектов,
чтобы избежать detached state при долгих рассылках.
"""
from sqlalchemy import select
from app.database.models import Subscription, SubscriptionStatus, User
@@ -632,18 +631,15 @@ class EmailBroadcastService:
]
if target == 'all_email':
# All users with verified email
query = select(User).where(*base_conditions)
elif target == 'email_only':
# Only email-registered users (no telegram)
query = select(User).where(
*base_conditions,
User.auth_type == 'email',
)
elif target == 'telegram_with_email':
# Telegram users who also have email
query = select(User).where(
*base_conditions,
User.auth_type == 'telegram',
@@ -651,7 +647,6 @@ class EmailBroadcastService:
)
elif target == 'active_email':
# Email users with active subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
@@ -662,7 +657,6 @@ class EmailBroadcastService:
)
elif target == 'expired_email':
# Email users with expired subscription
query = (
select(User)
.join(Subscription, User.id == Subscription.user_id)
@@ -681,8 +675,8 @@ class EmailBroadcastService:
logger.warning('Unknown email target filter: %s', target)
return []
# Load users in batches
users: list = []
# Загружаем батчами и извлекаем скаляры сразу
recipients: list[_EmailRecipient] = []
offset = 0
batch_size = 1000
@@ -693,108 +687,116 @@ class EmailBroadcastService:
if not batch:
break
users.extend(batch)
for user in batch:
email = user.email
if not email:
continue
# Формируем имя пользователя
user_name = user.username
if not user_name:
user_name = user.first_name or ''
if last_name := user.last_name:
user_name = f'{user_name} {last_name}'.strip()
if not user_name:
user_name = email.split('@')[0]
recipients.append(_EmailRecipient(email=email, user_name=user_name))
offset += batch_size
return users
return recipients
async def _send_emails(
self,
broadcast_id: int,
recipients: list,
recipients: list[_EmailRecipient],
config: EmailBroadcastConfig,
cancel_event: asyncio.Event,
) -> tuple[int, int, bool]:
"""Send emails with rate limiting."""
"""
Отправляет email-рассылку с rate limiting.
Использует run_in_executor для синхронного SMTP, ограничивая
параллельность семафором EMAIL_RATE_LIMIT.
"""
sent_count = 0
failed_count = 0
last_progress_count = 0
last_progress_time: float = 0.0
# Semaphore for rate limiting (max EMAIL_RATE_LIMIT concurrent sends)
semaphore = asyncio.Semaphore(EMAIL_RATE_LIMIT)
async def send_single_email(user) -> bool | None:
"""Send single email with rate limiting."""
async def send_single_email(recipient: _EmailRecipient) -> bool | None:
"""Отправляет один email."""
async with semaphore:
if cancel_event.is_set():
return None
email = getattr(user, 'email', None)
if not email:
return None
# Render template with variables
html_content = self._render_template(config.email_html_content, user)
subject = self._render_template(config.email_subject, user)
html_content = self._render_template(config.email_html_content, recipient)
subject = self._render_template(config.email_subject, recipient)
try:
# Run sync email send in executor to not block event loop
loop = asyncio.get_event_loop()
success = await loop.run_in_executor(
None,
self._email_service.send_email,
email,
recipient.email,
subject,
html_content,
)
return success
except Exception as exc:
logger.error(
'Error sending email broadcast %s to %s: %s',
'Ошибка отправки email рассылки %s на %s: %s',
broadcast_id,
email,
recipient.email,
exc,
)
return False
# Process in batches
for i in range(0, len(recipients), EMAIL_BATCH_SIZE):
if cancel_event.is_set():
await self._mark_cancelled(broadcast_id, sent_count, failed_count)
return sent_count, failed_count, True
batch = recipients[i : i + EMAIL_BATCH_SIZE]
tasks = [send_single_email(user) for user in batch]
tasks = [send_single_email(r) for r in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if result is True:
sent_count += 1
elif result is None:
# Skipped (cancelled or no email)
pass
pass # Cancelled or skipped
else:
failed_count += 1
# Update progress periodically
# Обновляем прогресс периодически
processed = sent_count + failed_count
if processed % PROGRESS_UPDATE_STEP == 0 or i + EMAIL_BATCH_SIZE >= len(recipients):
now = asyncio.get_event_loop().time()
if (
processed - last_progress_count >= _PROGRESS_UPDATE_MESSAGES
or now - last_progress_time >= _PROGRESS_MIN_INTERVAL_SEC
or i + EMAIL_BATCH_SIZE >= len(recipients)
):
await self._update_progress(broadcast_id, sent_count, failed_count)
last_progress_count = processed
last_progress_time = now
# Rate limiting delay between batches (ensure ~8 emails/sec)
# Rate limiting: ~8 emails/sec
await asyncio.sleep(EMAIL_BATCH_SIZE / EMAIL_RATE_LIMIT)
return sent_count, failed_count, False
def _render_template(self, template: str, user) -> str:
"""Render template with user variables."""
@staticmethod
def _render_template(template: str, recipient: _EmailRecipient) -> str:
"""Подставляет переменные в шаблон email."""
if not template:
return template
# Get user name
user_name = getattr(user, 'username', None)
if not user_name:
user_name = getattr(user, 'first_name', None) or ''
if last_name := getattr(user, 'last_name', None):
user_name = f'{user_name} {last_name}'.strip()
if not user_name:
user_name = getattr(user, 'email', '').split('@')[0] if getattr(user, 'email', None) else 'User'
email = getattr(user, 'email', '') or ''
# Replace template variables
result = template.replace('{{user_name}}', user_name)
result = result.replace('{{email}}', email)
result = template.replace('{{user_name}}', recipient.user_name)
result = result.replace('{{email}}', recipient.email)
return result
async def _mark_finished(
+2 -2
View File
@@ -246,7 +246,7 @@ class KassaAiService:
}
params['signature'] = self._generate_hmac_signature(params)
logger.debug(f'KassaAI get_order_status: order_id={order_id}')
logger.info(f'KassaAI get_order_status: order_id={order_id}')
try:
async with (
@@ -259,7 +259,7 @@ class KassaAiService:
) as response,
):
text = await response.text()
logger.debug(f'KassaAI get_order_status response: {text}')
logger.info(f'KassaAI get_order_status response: {text}')
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f'KassaAI API connection error: {e}')
+2 -11
View File
@@ -219,17 +219,8 @@ class MaintenanceService:
settings.get_maintenance_retry_attempts(),
)
await self._notify_admins(
f"""Мониторинг технических работ запущен
🔄 <b>Интервал проверки:</b> {settings.get_maintenance_check_interval()} секунд
🤖 <b>Автовключение:</b> {'Включено' if settings.is_maintenance_auto_enable() else 'Отключено'}
🎯 <b>Порог ошибок:</b> {self._max_consecutive_failures}
🔁 <b>Повторных попыток:</b> {settings.get_maintenance_retry_attempts()}
Система будет следить за доступностью API.""",
'info',
)
# Сообщение о запуске мониторинга убрано - теперь используется
# единое стартовое уведомление через StartupNotificationService
return True
+60 -7
View File
@@ -61,6 +61,7 @@ from app.services.notification_settings_service import NotificationSettingsServi
from app.services.payment_service import PaymentService
from app.services.promo_offer_service import promo_offer_service
from app.services.subscription_service import SubscriptionService
from app.utils.cache import cache
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
from app.utils.pricing_utils import apply_percentage_discount
from app.utils.subscription_utils import (
@@ -69,6 +70,10 @@ from app.utils.subscription_utils import (
from app.utils.timezone import format_local_datetime
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
logger = logging.getLogger(__name__)
@@ -966,7 +971,8 @@ class MonitoringService:
selectinload(Subscription.user).options(
selectinload(User.promo_group),
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
)
),
selectinload(Subscription.tariff),
)
.where(
and_(
@@ -980,6 +986,16 @@ class MonitoringService:
autopay_subscriptions = []
for sub in all_autopay_subscriptions:
# Суточные подписки имеют свой собственный механизм продления
# (DailySubscriptionService), глобальный autopay на них не распространяется
if sub.tariff and getattr(sub.tariff, 'is_daily', False):
logger.debug(
'Пропускаем суточную подписку %s (тариф %s) в глобальном autopay',
sub.id,
sub.tariff.name,
)
continue
days_before_expiry = (sub.end_date - current_time).days
if days_before_expiry <= min(sub.autopay_days_before, 3):
autopay_subscriptions.append(sub)
@@ -1056,13 +1072,50 @@ class MonitoringService:
logger.warning(f'💳 Ошибка списания средств для автопродления пользователя {user_identifier}')
else:
failed_count += 1
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Недостаточно средств на балансе',
# Проверяем кулдаун уведомления через Redis, чтобы не спамить
# при каждом срабатывании мониторинга
cooldown_key = f'autopay_insufficient_balance_notified:{user.id}'
should_notify = True
try:
if await cache.exists(cooldown_key):
should_notify = False
logger.debug(
'💳 Пропуск уведомления о недостаточном балансе для пользователя %s — кулдаун активен',
user_identifier,
)
except Exception as redis_err:
# Fallback: если Redis недоступен — отправляем уведомление
logger.warning(
'⚠️ Ошибка проверки кулдауна в Redis для пользователя %s: %s. Отправляем уведомление.',
user_identifier,
redis_err,
)
if should_notify:
if user.telegram_id and self.bot:
await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount)
elif not user.telegram_id:
await notification_delivery_service.notify_autopay_failed(
user=user,
reason='Недостаточно средств на балансе',
)
# Ставим ключ кулдауна после отправки
try:
await cache.set(
cooldown_key,
1,
expire=AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS,
)
except Exception as redis_err:
logger.warning(
'⚠️ Не удалось установить кулдаун в Redis для пользователя %s: %s',
user_identifier,
redis_err,
)
logger.warning(f'💳 Недостаточно средств для автопродления у пользователя {user_identifier}')
if processed_count > 0 or failed_count > 0:
+21 -9
View File
@@ -223,8 +223,8 @@ class CloudPaymentsPaymentMixin:
logger.error('Пользователь не найден: id=%s', payment.user_id)
return False
# Add balance
await add_user_balance(db, user.id, amount_kopeks)
# Add balance (без автоматической транзакции - создадим ниже с external_id)
await add_user_balance(db, user, amount_kopeks, create_transaction=False)
# Create transaction record
from app.database.crud.transaction import create_transaction
@@ -232,7 +232,7 @@ class CloudPaymentsPaymentMixin:
transaction = await create_transaction(
db=db,
user_id=user.id,
type_=TransactionType.DEPOSIT,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
payment_method=PaymentMethod.CLOUDPAYMENTS,
@@ -350,11 +350,17 @@ class CloudPaymentsPaymentMixin:
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from app.bot import bot
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from app.config import settings
from app.localization.texts import get_texts
if not bot:
return
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
# Skip email-only users (no telegram_id)
if not user.telegram_id:
@@ -400,10 +406,16 @@ class CloudPaymentsPaymentMixin:
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from app.bot import bot
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
if not bot:
return
from app.config import settings
bot = Bot(
token=settings.BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
+95 -2
View File
@@ -69,8 +69,13 @@ class KassaAiPaymentMixin:
)
return None
# Генерируем уникальный order_id
order_id = f'kai_{user_id}_{uuid.uuid4().hex[:12]}'
# Получаем telegram_id пользователя для order_id
payment_module = import_module('app.services.payment_service')
user = await payment_module.get_user_by_id(db, user_id)
tg_id = user.telegram_id if user else user_id
# Генерируем уникальный order_id с telegram_id для удобного поиска
order_id = f'k{tg_id}_{uuid.uuid4().hex[:6]}'
amount_rubles = amount_kopeks / 100
currency = settings.KASSA_AI_CURRENCY
@@ -487,3 +492,91 @@ class KassaAiPaymentMixin:
except Exception as e:
logger.exception('KassaAI: ошибка проверки статуса: %s', e)
return None
async def get_kassa_ai_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> dict[str, Any] | None:
"""
Проверяет статус платежа KassaAI по локальному ID через API.
Если платёж оплачен автоматически начисляет баланс.
"""
logger.info('KassaAI: checking payment status for id=%s', local_payment_id)
kassa_ai_crud = import_module('app.database.crud.kassa_ai')
payment = await kassa_ai_crud.get_kassa_ai_payment_by_id(db, local_payment_id)
if not payment:
logger.warning('KassaAI payment not found: id=%s', local_payment_id)
return None
if payment.is_paid:
return {
'payment': payment,
'status': 'success',
'is_paid': True,
}
if not settings.KASSA_AI_API_KEY:
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
try:
# Запрашиваем статус заказа в KassaAI (api.fk.life)
response = await kassa_ai_service.get_order_status(payment.order_id)
# KassaAI возвращает список заказов (как Freekassa)
orders = response.get('orders', [])
target_order = None
# Ищем наш заказ в списке
for order in orders:
order_key = str(order.get('merchant_order_id') or order.get('paymentId'))
if order_key == str(payment.order_id):
target_order = order
break
if target_order:
# Статус 1 = Оплачен (как в Freekassa)
kai_status = int(target_order.get('status', 0))
if kai_status == 1:
logger.info('KassaAI payment %s confirmed via API', payment.order_id)
callback_payload = {
'check_source': 'api',
'kai_order_data': target_order,
}
# ID заказа на стороне KassaAI
kai_intid = str(target_order.get('fk_order_id') or target_order.get('id'))
# Обновляем статус
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status='success',
is_paid=True,
kassa_ai_order_id=kai_intid,
payment_system_id=int(target_order.get('curID')) if target_order.get('curID') else None,
callback_payload=callback_payload,
)
# Финализируем (начисляем баланс)
await self._finalize_kassa_ai_payment(
db,
payment,
intid=kai_intid,
trigger='api_check',
)
except Exception as e:
logger.error('Error checking KassaAI payment status: %s', e)
return {
'payment': payment,
'status': payment.status or 'pending',
'is_paid': payment.is_paid,
}
+11
View File
@@ -100,6 +100,17 @@ class WataPaymentMixin:
payment_module = import_module('app.services.payment_service')
# Добавляем идентификатор плательщика (telegram_id или email) в описание
try:
user = await payment_module.get_user_by_id(db, user_id)
if user:
if user.telegram_id:
description = f'{description} | ID: {user.telegram_id}'
elif user.email:
description = f'{description} | {user.email}'
except Exception as error:
logger.debug('Не удалось получить данные пользователя для описания WATA: %s', error)
order_id = f'wata_{user_id}_{uuid.uuid4().hex[:12]}'
try:
+64 -28
View File
@@ -164,42 +164,78 @@ async def ensure_payment_method_configs(db: AsyncSession) -> None:
"""Initialize payment method configs if they don't exist yet.
Called on startup to seed defaults from env vars.
Also adds any missing methods that were added after initial setup.
"""
count_result = await db.execute(select(func.count()).select_from(PaymentMethodConfig))
count = count_result.scalar() or 0
# Get existing method IDs
existing_result = await db.execute(select(PaymentMethodConfig.method_id))
existing_method_ids = set(existing_result.scalars().all())
if count > 0:
return # Already initialized
if not existing_method_ids:
# First-time initialization
logger.info('Initializing payment method configurations from env vars...')
defaults = _get_method_defaults()
logger.info('Initializing payment method configurations from env vars...')
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
# Enable all sub-options by default
sub_options = {opt['id']: True for opt in available}
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
await db.commit()
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
return
# Add missing methods (for cases when new methods are added to code)
defaults = _get_method_defaults()
missing_methods = [m for m in DEFAULT_METHOD_ORDER if m not in existing_method_ids]
for idx, method_id in enumerate(DEFAULT_METHOD_ORDER):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
# Enable all sub-options by default
sub_options = {opt['id']: True for opt in available}
if missing_methods:
logger.info(f'Adding missing payment methods: {missing_methods}')
# Get max sort_order to append new methods at the end
max_order_result = await db.execute(select(func.max(PaymentMethodConfig.sort_order)))
max_order = max_order_result.scalar() or 0
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
for idx, method_id in enumerate(missing_methods, start=max_order + 1):
method_def = defaults.get(method_id, {})
is_configured = method_def.get('is_configured', False)
sub_options = None
available = method_def.get('available_sub_options')
if available:
sub_options = {opt['id']: True for opt in available}
await db.commit()
logger.info(f'Payment method configurations initialized ({len(DEFAULT_METHOD_ORDER)} methods).')
config = PaymentMethodConfig(
method_id=method_id,
sort_order=idx,
is_enabled=is_configured,
display_name=None,
sub_options=sub_options,
min_amount_kopeks=None,
max_amount_kopeks=None,
user_type_filter='all',
first_topup_filter='any',
promo_group_filter_mode='all',
)
db.add(config)
await db.commit()
logger.info(f'Added {len(missing_methods)} missing payment method(s).')
# ============ CRUD ============
@@ -71,6 +71,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
}
)
@@ -87,6 +88,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
# WATA removed - API returns 429 "Use webhook polling is rate-limited".
# Payments are processed via webhook (wata_webhook.py).
PaymentMethod.FREEKASSA,
PaymentMethod.KASSA_AI,
}
)
@@ -955,6 +957,9 @@ async def run_manual_check(
elif method == PaymentMethod.FREEKASSA:
result = await payment_service.get_freekassa_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
elif method == PaymentMethod.KASSA_AI:
result = await payment_service.get_kassa_ai_payment_status(db, local_payment_id)
payment = result.get('payment') if result else None
else:
logger.warning('Manual check requested for unsupported method %s', method)
return None
+51 -29
View File
@@ -123,7 +123,15 @@ async def broadcast_pinned_message(
db: AsyncSession,
pinned_message: PinnedMessage,
) -> tuple[int, int]:
users: list[User] = []
"""
Рассылает закреплённое сообщение всем активным пользователям.
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой рассылки,
чтобы избежать обращения к ORM-объектам после истечения таймаута
соединения с БД.
"""
# Собираем telegram_id всех активных пользователей
recipient_telegram_ids: list[int] = []
offset = 0
batch_size = 5000
@@ -138,27 +146,26 @@ async def broadcast_pinned_message(
if not batch:
break
users.extend(batch)
# Извлекаем только telegram_id, фильтруем email-only пользователей
for user in batch:
if user.telegram_id is not None:
recipient_telegram_ids.append(user.telegram_id)
offset += batch_size
sent_count = 0
failed_count = 0
semaphore = asyncio.Semaphore(3)
async def send_to_user(user: User) -> None:
async def send_to_telegram_id(telegram_id: int) -> None:
nonlocal sent_count, failed_count
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
async with semaphore:
for attempt in range(3):
try:
success = await _send_and_pin_message(
bot,
user.telegram_id,
telegram_id,
pinned_message,
)
if success:
@@ -170,22 +177,22 @@ async def broadcast_pinned_message(
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter for user %s, waiting %s seconds',
user.telegram_id,
telegram_id,
delay,
)
await asyncio.sleep(delay)
except Exception as send_error:
logger.error(
'Ошибка отправки закрепленного сообщения пользователю %s: %s',
user.telegram_id,
telegram_id,
send_error,
)
failed_count += 1
break
for i in range(0, len(users), 30):
batch = users[i : i + 30]
tasks = [send_to_user(user) for user in batch]
for i in range(0, len(recipient_telegram_ids), 30):
batch = recipient_telegram_ids[i : i + 30]
tasks = [send_to_telegram_id(tid) for tid in batch]
await asyncio.gather(*tasks)
await asyncio.sleep(0.05)
@@ -196,11 +203,19 @@ async def unpin_active_pinned_message(
bot: Bot,
db: AsyncSession,
) -> tuple[int, int, bool]:
"""
Открепляет активное сообщение у всех пользователей.
ВАЖНО: Извлекаем telegram_id в список ДО начала долгой операции,
чтобы избежать обращения к ORM-объектам после истечения таймаута
соединения с БД.
"""
pinned_message = await deactivate_active_pinned_message(db)
if not pinned_message:
return 0, 0, False
users: list[User] = []
# Собираем telegram_id всех активных пользователей
recipient_telegram_ids: list[int] = []
offset = 0
batch_size = 5000
@@ -215,24 +230,23 @@ async def unpin_active_pinned_message(
if not batch:
break
users.extend(batch)
# Извлекаем только telegram_id, фильтруем email-only пользователей
for user in batch:
if user.telegram_id is not None:
recipient_telegram_ids.append(user.telegram_id)
offset += batch_size
unpinned_count = 0
failed_count = 0
semaphore = asyncio.Semaphore(5)
async def unpin_for_user(user: User) -> None:
async def unpin_for_telegram_id(telegram_id: int) -> None:
nonlocal unpinned_count, failed_count
# Skip email-only users (no telegram_id)
if not user.telegram_id:
failed_count += 1
return
async with semaphore:
try:
success = await _unpin_message_for_user(bot, user.telegram_id)
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
@@ -241,22 +255,30 @@ async def unpin_active_pinned_message(
delay = min(retry_error.retry_after + 1, 30)
logger.warning(
'RetryAfter while unpinning for user %s, waiting %s seconds',
user.telegram_id,
telegram_id,
delay,
)
await asyncio.sleep(delay)
await unpin_for_user(user)
# Повторная попытка после ожидания
try:
success = await _unpin_message_for_user(bot, telegram_id)
if success:
unpinned_count += 1
else:
failed_count += 1
except Exception:
failed_count += 1
except Exception as error:
logger.error(
'Ошибка открепления сообщения у пользователя %s: %s',
user.telegram_id,
telegram_id,
error,
)
failed_count += 1
for i in range(0, len(users), 40):
batch = users[i : i + 40]
tasks = [unpin_for_user(user) for user in batch]
for i in range(0, len(recipient_telegram_ids), 40):
batch = recipient_telegram_ids[i : i + 40]
tasks = [unpin_for_telegram_id(tid) for tid in batch]
await asyncio.gather(*tasks)
await asyncio.sleep(0.05)
+100
View File
@@ -7,6 +7,7 @@ from app.database.crud.promo_group import get_promo_group_by_id
from app.database.crud.promocode import (
check_user_promocode_usage,
create_promocode_use,
get_active_discount_promocode_for_user,
get_promocode_by_code,
)
from app.database.crud.subscription import extend_subscription, get_subscription_by_user_id
@@ -300,3 +301,102 @@ class PromoCodeService:
effects.append('ℹ️ У вас уже есть активная подписка')
return '\n'.join(effects) if effects else '✅ Промокод активирован'
async def deactivate_discount_promocode(
self,
db: AsyncSession,
user_id: int,
*,
admin_initiated: bool = False,
) -> dict[str, Any]:
"""
Деактивирует активный промокод на процентную скидку у пользователя.
Действия:
- Сбрасывает promo_offer_discount_percent / source / expires_at на пользователе
- Удаляет запись PromoCodeUse (чтобы промокод мог быть повторно использован, если max_uses > current_uses)
- Декрементирует current_uses на промокоде
- Если промокод назначил промогруппу -- снимает её с пользователя
Args:
db: Сессия БД
user_id: ID пользователя
admin_initiated: True если деактивацию инициировал админ
Returns:
dict с ключами success, error (опционально), deactivated_code (опционально)
"""
try:
user = await get_user_by_id(db, user_id)
if not user:
return {'success': False, 'error': 'user_not_found'}
current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
source = getattr(user, 'promo_offer_discount_source', None)
if current_discount <= 0 or not source or not source.startswith('promocode:'):
return {'success': False, 'error': 'no_active_discount_promocode'}
from datetime import datetime
expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
# Если скидка уже истекла по времени -- тоже нечего деактивировать
if expires_at is not None and expires_at <= datetime.utcnow():
# Просто зачистим протухшие данные
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
await db.commit()
return {'success': False, 'error': 'discount_already_expired'}
promocode, promo_use = await get_active_discount_promocode_for_user(db, user_id)
deactivated_code = source.split(':', 1)[1]
# 1. Сбрасываем скидку на пользователе
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.promo_offer_discount_expires_at = None
user.updated_at = datetime.utcnow()
# 2. Откатываем использование промокода (если нашли запись)
if promocode and promo_use:
await db.delete(promo_use)
if promocode.current_uses > 0:
promocode.current_uses -= 1
promocode.updated_at = datetime.utcnow()
# 3. Если промокод назначал промогруппу -- снимаем её
if promocode.promo_group_id:
from app.database.crud.user_promo_group import (
has_user_promo_group,
remove_user_from_promo_group,
)
has_group = await has_user_promo_group(db, user_id, promocode.promo_group_id)
if has_group:
await remove_user_from_promo_group(db, user_id, promocode.promo_group_id)
logger.info(
f'Снята промогруппа ID {promocode.promo_group_id} у пользователя '
f'{self._format_user_log(user)} при деактивации промокода {deactivated_code}'
)
await db.commit()
initiator = 'администратором' if admin_initiated else 'пользователем'
logger.info(
f'Промокод {deactivated_code} (скидка {current_discount}%) деактивирован '
f'{initiator} для пользователя {self._format_user_log(user)}'
)
return {
'success': True,
'deactivated_code': deactivated_code,
'discount_percent': current_discount,
}
except Exception as e:
logger.error(f'Ошибка деактивации промокода для пользователя {user_id}: {e}')
await db.rollback()
return {'success': False, 'error': 'server_error'}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,452 @@
"""
Сервис стартового уведомления бота.
Отправляет красивое сообщение с информацией о системе при запуске бота.
"""
import logging
import os
from datetime import datetime
from typing import Final
from aiogram import Bot
from aiogram.enums import ParseMode
from aiogram.types import BufferedInputFile, InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy import func, select
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.database.models import Subscription, SubscriptionStatus, Ticket, TicketStatus, User, UserStatus
from app.external.remnawave_api import RemnaWaveAPI, test_api_connection
from app.utils.timezone import format_local_datetime
logger = logging.getLogger(__name__)
# Константы
VERSION_ENV_VAR: Final[str] = 'VERSION'
DEFAULT_VERSION: Final[str] = 'dev'
DEFAULT_AUTH_TYPE: Final[str] = 'api_key'
# Форматирование
KOPEKS_IN_RUBLE: Final[int] = 100
MILLION: Final[int] = 1_000_000
THOUSAND: Final[int] = 1_000
DATETIME_FORMAT: Final[str] = '%d.%m.%Y %H:%M:%S'
DATETIME_FORMAT_FILENAME: Final[str] = '%Y%m%d_%H%M%S'
REPORT_SEPARATOR_WIDTH: Final[int] = 50
# Лимиты сообщений
CRASH_ERROR_MESSAGE_MAX_LENGTH: Final[int] = 1000
CRASH_ERROR_PREVIEW_LENGTH: Final[int] = 200
# URL-ы
GITHUB_BOT_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot'
GITHUB_CABINET_URL: Final[str] = 'https://github.com/BEDOLAGA-DEV/bedolaga-cabinet'
COMMUNITY_URL: Final[str] = 'https://t.me/+wTdMtSWq8YdmZmVi'
DEVELOPER_CONTACT_URL: Final[str] = 'https://t.me/fringg'
# Ключевые слова для определения типа ошибки
WEBHOOK_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('webhook', 'failed to resolve host')
DATABASE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('database', 'postgres', 'connection refused')
REDIS_ERROR_KEYWORD: Final[str] = 'redis'
REMNAWAVE_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('remnawave', 'panel')
AUTH_ERROR_KEYWORDS: Final[tuple[str, ...]] = ('unauthorized', 'bot token')
INLINE_BUTTON_URL_ERROR_KEYWORDS: Final[tuple[str, ...]] = (
'web app url',
'url host is empty',
'unsupported url protocol',
'button url',
)
class StartupNotificationService:
"""Сервис для отправки стартового уведомления в админский чат."""
def __init__(self, bot: Bot) -> None:
self.bot = bot
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
def _get_version(self) -> str:
"""Получает версию из переменной окружения VERSION."""
version = os.getenv(VERSION_ENV_VAR, '').strip()
if version:
return version
return DEFAULT_VERSION
async def _get_users_count(self) -> int:
"""Получает количество активных пользователей в базе."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(User.id)).where(User.status == UserStatus.ACTIVE.value))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества пользователей: %s', e)
return 0
async def _get_total_balance(self) -> int:
"""Получает сумму балансов всех пользователей в копейках."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.coalesce(func.sum(User.balance_kopeks), 0)).where(
User.status == UserStatus.ACTIVE.value
)
)
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения суммы балансов: %s', e)
return 0
async def _get_open_tickets_count(self) -> int:
"""Получает количество открытых тикетов."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(Ticket.id)).where(Ticket.status == TicketStatus.OPEN.value))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества открытых тикетов: %s', e)
return 0
async def _get_paid_subscriptions_count(self) -> int:
"""Получает количество платных подписок (не триальных, активных)."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(func.count(Subscription.id)).where(
Subscription.is_trial == False,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
)
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества платных подписок: %s', e)
return 0
async def _get_trial_subscriptions_count(self) -> int:
"""Получает количество триальных подписок."""
try:
async with AsyncSessionLocal() as db:
result = await db.execute(select(func.count(Subscription.id)).where(Subscription.is_trial == True))
return result.scalar() or 0
except Exception as e:
logger.error('Ошибка получения количества триальных подписок: %s', e)
return 0
async def _check_remnawave_connection(self) -> tuple[bool, str]:
"""
Проверяет соединение с панелью Remnawave.
Returns:
Tuple[bool, str]: (is_connected, status_message)
"""
try:
auth_params = settings.get_remnawave_auth_params()
base_url = (auth_params.get('base_url') or '').strip()
api_key = (auth_params.get('api_key') or '').strip()
if not base_url or not api_key:
return False, 'Не настроен'
secret_key = (auth_params.get('secret_key') or '').strip() or None
username = (auth_params.get('username') or '').strip() or None
password = (auth_params.get('password') or '').strip() or None
caddy_token = (auth_params.get('caddy_token') or '').strip() or None
auth_type = (auth_params.get('auth_type') or DEFAULT_AUTH_TYPE).strip()
api = RemnaWaveAPI(
base_url=base_url,
api_key=api_key,
secret_key=secret_key,
username=username,
password=password,
caddy_token=caddy_token,
auth_type=auth_type,
)
async with api:
is_connected = await test_api_connection(api)
if is_connected:
return True, 'Подключено'
return False, 'Недоступна'
except Exception as e:
logger.error('Ошибка проверки соединения с Remnawave: %s', e)
return False, 'Ошибка подключения'
def _format_balance(self, kopeks: int) -> str:
"""Форматирует баланс в рублях."""
rubles = kopeks / KOPEKS_IN_RUBLE
if rubles >= MILLION:
return f'{rubles / MILLION:.2f}M RUB'
if rubles >= THOUSAND:
return f'{rubles / THOUSAND:.1f}K RUB'
return f'{rubles:.2f} RUB'
async def send_startup_notification(self) -> bool:
"""
Отправляет стартовое уведомление в админский чат.
Returns:
bool: True если сообщение отправлено успешно
"""
if not self.enabled or not self.chat_id:
logger.debug('Стартовое уведомление отключено или chat_id не задан')
return False
try:
version = self._get_version()
users_count = await self._get_users_count()
total_balance_kopeks = await self._get_total_balance()
open_tickets_count = await self._get_open_tickets_count()
paid_subscriptions_count = await self._get_paid_subscriptions_count()
trial_subscriptions_count = await self._get_trial_subscriptions_count()
remnawave_connected, remnawave_status = await self._check_remnawave_connection()
# Иконка статуса Remnawave
remnawave_icon = '🟢' if remnawave_connected else '🔴'
# Формируем системную информацию для blockquote
system_info_lines = [
f'Версия: {version}',
f'Пользователей: {users_count:,}'.replace(',', ' '),
f'Сумма балансов: {self._format_balance(total_balance_kopeks)}',
f'Платных подписок: {paid_subscriptions_count:,}'.replace(',', ' '),
f'Триальных подписок: {trial_subscriptions_count:,}'.replace(',', ' '),
f'Открытых тикетов: {open_tickets_count:,}'.replace(',', ' '),
f'{remnawave_icon} Remnawave: {remnawave_status}',
]
system_info = '\n'.join(system_info_lines)
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
message = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'✅ Бот успешно запущен\n\n'
f'<blockquote expandable>{system_info}</blockquote>\n\n'
f'<i>{timestamp}</i>'
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='Поставить звезду',
url=GITHUB_BOT_URL,
),
],
[
InlineKeyboardButton(
text='Вебкабинет',
url=GITHUB_CABINET_URL,
),
],
[
InlineKeyboardButton(
text='Сообщество',
url=COMMUNITY_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': self.chat_id,
'text': message,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
'disable_web_page_preview': True,
}
if self.topic_id:
message_kwargs['message_thread_id'] = self.topic_id
await self.bot.send_message(**message_kwargs)
logger.info('Стартовое уведомление отправлено в чат %s', self.chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки стартового уведомления: %s', e)
return False
async def send_bot_startup_notification(bot: Bot) -> bool:
"""
Удобная функция для отправки стартового уведомления.
Args:
bot: Экземпляр бота aiogram
Returns:
bool: True если уведомление отправлено успешно
"""
service = StartupNotificationService(bot)
return await service.send_startup_notification()
def _get_error_recommendations(error_message: str) -> str | None:
"""
Возвращает рекомендации по исправлению ошибки на основе текста ошибки.
Args:
error_message: Текст ошибки
Returns:
Рекомендации в формате HTML blockquote или None
"""
error_lower = error_message.lower()
# Ошибки вебхука
if any(keyword in error_lower for keyword in WEBHOOK_ERROR_KEYWORDS):
tips = [
'• Проверьте WEBHOOK_HOST в .env',
'• Убедитесь что домен доступен извне',
'• Проверьте SSL сертификат (должен быть валидный)',
'• Проверьте reverse proxy (nginx/caddy)',
'• Проверьте сеть Docker (docker network)',
'• Попробуйте: docker compose restart',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки подключения к БД
if any(keyword in error_lower for keyword in DATABASE_ERROR_KEYWORDS):
tips = [
'• Проверьте что PostgreSQL запущен',
'• Проверьте DATABASE_URL в .env',
'• Проверьте сеть Docker между контейнерами',
'• Попробуйте: docker compose restart db',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Redis
if REDIS_ERROR_KEYWORD in error_lower:
tips = [
'• Проверьте что Redis запущен',
'• Проверьте REDIS_URL в .env',
'• Попробуйте: docker compose restart redis',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки Remnawave API
if any(keyword in error_lower for keyword in REMNAWAVE_ERROR_KEYWORDS):
tips = [
'• Проверьте REMNAWAVE_API_URL в .env',
'• Проверьте REMNAWAVE_API_KEY',
'• Убедитесь что панель Remnawave доступна',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки токена бота
if any(keyword in error_lower for keyword in AUTH_ERROR_KEYWORDS):
tips = [
'• Проверьте BOT_TOKEN в .env',
'• Убедитесь что токен актуален (@BotFather)',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
# Ошибки inline-кнопок с URL (WebApp, кастомные протоколы)
if any(keyword in error_lower for keyword in INLINE_BUTTON_URL_ERROR_KEYWORDS):
tips = [
'• Проверьте MINIAPP_CUSTOM_URL в .env',
'• Проверьте HAPP_CRYPTOLINK_REDIRECT_TEMPLATE',
'• Telegram не поддерживает кастомные схемы (happ://, v2ray://, ss://, и т.д.) в inline-кнопках',
'• Используйте HTTPS редирект для диплинков',
]
return '<blockquote expandable>💡 <b>Рекомендации:</b>\n' + '\n'.join(tips) + '</blockquote>'
return None
async def send_crash_notification(bot: Bot, error: Exception, traceback_str: str) -> bool:
"""
Отправляет уведомление о падении бота с лог-файлом.
Args:
bot: Экземпляр бота aiogram
error: Исключение, вызвавшее падение
traceback_str: Строка с полным traceback
Returns:
bool: True если уведомление отправлено успешно
"""
chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
if not enabled or not chat_id:
logger.debug('Уведомление о падении отключено или chat_id не задан')
return False
try:
timestamp = format_local_datetime(datetime.utcnow(), DATETIME_FORMAT)
error_type = type(error).__name__
error_message = str(error)[:CRASH_ERROR_MESSAGE_MAX_LENGTH]
separator = '=' * REPORT_SEPARATOR_WIDTH
# Формируем содержимое лог-файла
log_content = (
f'CRASH REPORT\n'
f'{separator}\n\n'
f'Timestamp: {timestamp}\n'
f'Error Type: {error_type}\n'
f'Error Message: {error_message}\n\n'
f'{separator}\n'
f'TRACEBACK\n'
f'{separator}\n\n'
f'{traceback_str}\n'
)
# Создаем файл для отправки
file_name = f'crash_report_{datetime.utcnow().strftime(DATETIME_FORMAT_FILENAME)}.txt'
file = BufferedInputFile(
file=log_content.encode('utf-8'),
filename=file_name,
)
# Текст сообщения
message_text = (
f'<b>Remnawave Bedolaga Bot</b>\n\n'
f'❌ Бот упал с ошибкой\n\n'
f'<b>Тип:</b> <code>{error_type}</code>\n'
f'<b>Сообщение:</b> <code>{error_message[:CRASH_ERROR_PREVIEW_LENGTH]}</code>\n'
)
# Добавляем рекомендации если есть
recommendations = _get_error_recommendations(error_message)
if recommendations:
message_text += f'\n{recommendations}\n'
message_text += f'\n<i>{timestamp}</i>'
# Кнопка для связи с разработчиком
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text='💬 Сообщить разработчику',
url=DEVELOPER_CONTACT_URL,
),
],
]
)
message_kwargs: dict = {
'chat_id': chat_id,
'document': file,
'caption': message_text,
'parse_mode': ParseMode.HTML,
'reply_markup': keyboard,
}
if topic_id:
message_kwargs['message_thread_id'] = topic_id
await bot.send_document(**message_kwargs)
logger.info('Уведомление о падении отправлено в чат %s', chat_id)
return True
except Exception as e:
logger.error('Ошибка отправки уведомления о падении: %s', e)
return False
+2 -2
View File
@@ -198,7 +198,7 @@ class TributeService:
break
except Exception as e:
logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
logger.error(f'Ошибка обработки успешного Tribute платежа: {e}', exc_info=True)
async def _handle_failed_payment(self, payment_data: dict[str, Any]):
try:
@@ -466,7 +466,7 @@ class TributeService:
return True
except Exception as e:
logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True)
logger.error(f'Ошибка принудительной обработки: {e}', exc_info=True)
return False
async def get_payment_status(self, payment_id: str) -> dict[str, Any] | None:
+121 -12
View File
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta
from typing import Any
from aiogram import Bot, types
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -30,9 +30,11 @@ from app.database.models import (
AdvertisingCampaign,
AdvertisingCampaignRegistration,
BroadcastHistory,
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PaymentMethod,
@@ -345,6 +347,72 @@ class UserService:
'total_count': 0,
}
async def get_potential_customers(
self,
db: AsyncSession,
min_balance_kopeks: int,
page: int = 1,
limit: int = 10,
) -> dict[str, Any]:
"""Возвращает пользователей без активной подписки с достаточным балансом."""
try:
offset = (page - 1) * limit
# Фильтры: нет активной подписки И баланс >= порога
base_filters = [
User.balance_kopeks >= min_balance_kopeks,
]
# Основной запрос с LEFT JOIN для поддержки пользователей без подписки
query = (
select(User)
.options(selectinload(User.subscription))
.outerjoin(Subscription, Subscription.user_id == User.id)
.where(
*base_filters,
or_(
User.subscription == None,
~Subscription.status.in_(['active', 'trial']),
),
)
.order_by(User.balance_kopeks.desc(), User.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
users = result.scalars().unique().all()
# Запрос для подсчета общего количества
count_query = (
select(func.count(User.id))
.outerjoin(Subscription, Subscription.user_id == User.id)
.where(
*base_filters,
or_(
User.subscription == None,
~Subscription.status.in_(['active', 'trial']),
),
)
)
total_count = (await db.execute(count_query)).scalar() or 0
total_pages = (total_count + limit - 1) // limit if total_count else 0
return {
'users': users,
'current_page': page,
'total_pages': total_pages,
'total_count': total_count,
}
except Exception as e:
logger.error(f'Ошибка получения потенциальных клиентов: {e}')
return {
'users': [],
'current_page': 1,
'total_pages': 1,
'total_count': 0,
}
async def get_user_spending_stats_map(self, db: AsyncSession, user_ids: list[int]) -> dict[int, dict[str, int]]:
try:
return await get_users_spending_stats(db, user_ids)
@@ -891,6 +959,58 @@ class UserService:
except Exception as e:
logger.error(f'❌ Ошибка удаления Freekassa платежей: {e}')
# Удаляем Wata платежи (до транзакций, т.к. wata_payments.transaction_id -> transactions.id)
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
await db.execute(
update(WataPayment).where(WataPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
# Удаляем CloudPayments платежи
try:
cloudpayments_result = await db.execute(
select(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id)
)
cloudpayments_payments = cloudpayments_result.scalars().all()
if cloudpayments_payments:
logger.info(f'🔄 Удаляем {len(cloudpayments_payments)} CloudPayments платежей')
await db.execute(
update(CloudPaymentsPayment)
.where(CloudPaymentsPayment.user_id == user_id)
.values(transaction_id=None)
)
await db.flush()
await db.execute(delete(CloudPaymentsPayment).where(CloudPaymentsPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления CloudPayments платежей: {e}')
# Удаляем KassaAi платежи
try:
kassa_ai_result = await db.execute(select(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
kassa_ai_payments = kassa_ai_result.scalars().all()
if kassa_ai_payments:
logger.info(f'🔄 Удаляем {len(kassa_ai_payments)} KassaAi платежей')
await db.execute(
update(KassaAiPayment).where(KassaAiPayment.user_id == user_id).values(transaction_id=None)
)
await db.flush()
await db.execute(delete(KassaAiPayment).where(KassaAiPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления KassaAi платежей: {e}')
try:
transactions_result = await db.execute(select(Transaction).where(Transaction.user_id == user_id))
transactions = transactions_result.scalars().all()
@@ -990,17 +1110,6 @@ class UserService:
except Exception as e:
logger.error(f'❌ Ошибка удаления подписки: {e}')
try:
wata_payments_result = await db.execute(select(WataPayment).where(WataPayment.user_id == user_id))
wata_payments = wata_payments_result.scalars().all()
if wata_payments:
logger.info(f'🔄 Удаляем {len(wata_payments)} Wata платежей')
await db.execute(delete(WataPayment).where(WataPayment.user_id == user_id))
await db.flush()
except Exception as e:
logger.error(f'❌ Ошибка удаления Wata платежей: {e}')
try:
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
+7
View File
@@ -51,6 +51,13 @@ class VersionInfo:
return description
@property
def full_description(self) -> str:
"""Full release body text, stripped of leading/trailing whitespace."""
if not self.body:
return ''
return self.body.strip()
class VersionService:
def __init__(self, bot=None):
+8 -4
View File
@@ -118,6 +118,9 @@ class AdminStates(StatesGroup):
adding_virtual_participant_name = State()
adding_virtual_participant_count = State()
editing_virtual_participant_count = State()
# Массовое создание виртуальных участников (массовка)
adding_mass_virtual_count = State() # Сколько призраков создать
adding_mass_virtual_referrals = State() # По сколько рефералов у каждого
editing_daily_contest_field = State()
editing_daily_contest_value = State()
@@ -132,6 +135,10 @@ class AdminStates(StatesGroup):
# Тестовое начисление реферального дохода
test_referral_earning_input = State()
# Диагностика рефералов
referral_diagnostics_period = State()
waiting_for_log_file = State()
editing_rules_page = State()
editing_privacy_policy = State()
editing_public_offer = State()
@@ -173,12 +180,9 @@ class AdminStates(StatesGroup):
# Состояния для отслеживания источника перехода
viewing_user_from_balance_list = State()
viewing_user_from_traffic_list = State()
viewing_user_from_last_activity_list = State()
viewing_user_from_spending_list = State()
viewing_user_from_purchases_list = State()
viewing_user_from_campaign_list = State()
viewing_user_from_ready_to_renew_list = State()
viewing_user_from_potential_customers_list = State()
# Состояния для управления тарифами
creating_tariff_name = State()
+11 -9
View File
@@ -89,15 +89,16 @@ def error_handler(func: Callable) -> Callable:
await event.answer()
except TelegramBadRequest as answer_error:
if 'query is too old' not in str(answer_error).lower():
logger.error(f'Ошибка при ответе на callback: {answer_error}')
logger.error(f'Ошибка при ответе на callback в {func.__name__}: {answer_error}')
return None
logger.error(f'Telegram API error в {func.__name__}: {e}')
await _send_error_message(args, kwargs, e)
# Уведомление отправляется в _send_error_message
await _send_error_message(args, kwargs, e, func.__name__)
except Exception as e:
logger.error(f'Ошибка в {func.__name__}: {e}', exc_info=True)
await _send_error_message(args, kwargs, e)
await _send_error_message(args, kwargs, e, func.__name__)
return wrapper
@@ -109,11 +110,12 @@ def _extract_event(args) -> types.TelegramObject:
return None
async def _send_error_message(args, kwargs, original_error):
try:
event = _extract_event(args)
db_user = kwargs.get('db_user')
async def _send_error_message(args, kwargs, original_error, func_name: str = 'unknown'):
event = _extract_event(args)
db_user = kwargs.get('db_user')
# Отправляем сообщение пользователю
try:
if not event:
return
@@ -128,9 +130,9 @@ async def _send_error_message(args, kwargs, original_error):
if 'query is too old' in str(e).lower():
logger.warning('Не удалось отправить сообщение об ошибке - callback query устарел')
else:
logger.error(f'Ошибка при отправке сообщения об ошибке: {e}')
logger.warning(f'Ошибка при отправке сообщения об ошибке: {e}')
except Exception as e:
logger.error(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
logger.warning(f'Критическая ошибка при отправке сообщения об ошибке: {e}')
def state_cleanup(func: Callable) -> Callable:
+203
View File
@@ -0,0 +1,203 @@
"""Converts GitHub-flavored Markdown to Telegram-compatible HTML.
Telegram supports a limited subset of HTML tags:
<b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>, <tg-spoiler>.
This module strips everything else and maps common Markdown constructs
to the supported tags.
"""
import re
# HTML tags that Telegram Bot API supports (case-insensitive tag names)
_ALLOWED_TAGS: frozenset[str] = frozenset(
{
'b',
'strong',
'i',
'em',
'u',
'ins',
's',
'strike',
'del',
'code',
'pre',
'a',
'blockquote',
'tg-spoiler',
'tg-emoji',
}
)
# Regex to match any HTML tag (opening, closing, or self-closing)
_HTML_TAG_RE: re.Pattern[str] = re.compile(r'<(/?)(\w[\w-]*)((?:\s+[^>]*)?)(/?)>', re.IGNORECASE)
def _strip_unsupported_html(text: str) -> str:
"""Remove HTML tags that Telegram does not support, keeping only allowed ones."""
def _replace_tag(match: re.Match[str]) -> str:
tag_name = match.group(2).lower()
if tag_name in _ALLOWED_TAGS:
return match.group(0)
return ''
return _HTML_TAG_RE.sub(_replace_tag, text)
def _escape_html(text: str) -> str:
"""Escape characters that conflict with Telegram HTML parsing.
Only escapes `&`, `<`, `>` that are NOT already part of allowed HTML tags.
We run this BEFORE markdown conversion so markdown symbols are still intact.
"""
# Escape ampersands that are not already HTML entities
text = re.sub(r'&(?!amp;|lt;|gt;|quot;|#\d+;)', '&amp;', text)
return text
def github_markdown_to_telegram_html(text: str) -> str:
"""Convert GitHub-flavored Markdown to Telegram HTML.
Handles:
- ``## Header`` -> ``<b>Header</b>``
- ``**bold**`` / ``__bold__`` -> ``<b>bold</b>``
- ``*italic*`` / ``_italic_`` -> ``<i>italic</i>``
- `` `code` `` -> ``<code>code</code>``
- ``- item`` / ``* item`` -> ``bullet item``
- ``[text](url)`` -> ``<a href="url">text</a>``
- Strips unsupported HTML tags
"""
if not text:
return ''
# Escape HTML-sensitive chars first (but preserve existing tags for later stripping)
# We do a targeted escape: only bare < > that are NOT part of tags
result = text
# --- Code blocks (``` ... ```) -- protect from further processing ---
code_blocks: list[str] = []
def _save_code_block(match: re.Match[str]) -> str:
lang = match.group(1) or ''
code = match.group(2)
# Escape HTML inside code
code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
placeholder = f'\x00CODEBLOCK{len(code_blocks)}\x00'
if lang:
code_blocks.append(f'<pre><code class="language-{lang}">{code}</code></pre>')
else:
code_blocks.append(f'<pre>{code}</pre>')
return placeholder
result = re.sub(r'```(\w+)?\n(.*?)```', _save_code_block, result, flags=re.DOTALL)
# --- Inline code (`...`) -- protect from further processing ---
inline_codes: list[str] = []
def _save_inline_code(match: re.Match[str]) -> str:
code = match.group(1)
code = code.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
placeholder = f'\x00INLINECODE{len(inline_codes)}\x00'
inline_codes.append(f'<code>{code}</code>')
return placeholder
result = re.sub(r'`([^`]+)`', _save_inline_code, result)
# --- Escape remaining bare HTML entities ---
result = _escape_html(result)
# --- Headers: ## Header -> <b>Header</b> ---
result = re.sub(r'^#{1,6}\s+(.+)$', r'<b>\1</b>', result, flags=re.MULTILINE)
# --- Bold: **text** or __text__ -> <b>text</b> ---
result = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', result)
result = re.sub(r'__(.+?)__', r'<b>\1</b>', result)
# --- Italic: *text* or _text_ -> <i>text</i> ---
# Negative lookbehind/lookahead to avoid matching inside words with underscores
result = re.sub(r'(?<!\w)\*([^*]+?)\*(?!\w)', r'<i>\1</i>', result)
result = re.sub(r'(?<!\w)_([^_]+?)_(?!\w)', r'<i>\1</i>', result)
# --- Strikethrough: ~~text~~ -> <s>text</s> ---
result = re.sub(r'~~(.+?)~~', r'<s>\1</s>', result)
# --- Links: [text](url) -> <a href="url">text</a> ---
result = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', result)
# --- Unordered lists: - item or * item -> bullet ---
_BULLET = '\u2022'
result = re.sub(r'^[\s]*[-*]\s+', f' {_BULLET} ', result, flags=re.MULTILINE)
# --- Horizontal rules: --- or *** or ___ ---
result = re.sub(r'^[-*_]{3,}\s*$', '', result, flags=re.MULTILINE)
# --- Images: ![alt](url) -> just alt text ---
result = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', result)
# --- Strip unsupported HTML tags ---
result = _strip_unsupported_html(result)
# --- Restore code blocks ---
for i, block in enumerate(code_blocks):
result = result.replace(f'\x00CODEBLOCK{i}\x00', block)
for i, code in enumerate(inline_codes):
result = result.replace(f'\x00INLINECODE{i}\x00', code)
# --- Clean up excessive blank lines (max 2 consecutive) ---
result = re.sub(r'\n{3,}', '\n\n', result)
return result.strip()
def truncate_for_blockquote(
description_html: str,
*,
message_prefix: str,
message_suffix: str,
max_message_length: int = 4096,
ellipsis: str = '...',
) -> str:
"""Truncate description HTML to fit within Telegram message limit inside a blockquote.
Calculates available space by subtracting prefix/suffix lengths and blockquote
tag overhead from the total message limit.
Args:
description_html: The already-converted HTML description.
message_prefix: Everything before the blockquote in the message.
message_suffix: Everything after the blockquote in the message.
max_message_length: Telegram message character limit (default 4096).
ellipsis: String to append when truncating.
Returns:
The (possibly truncated) description HTML ready to be placed inside
``<blockquote expandable>...</blockquote>``.
"""
blockquote_open = '<blockquote expandable>'
blockquote_close = '</blockquote>'
overhead = len(blockquote_open) + len(blockquote_close)
available = max_message_length - len(message_prefix) - len(message_suffix) - overhead
# Leave a small safety margin for any off-by-one with Telegram entity counting
available -= 20
if available <= 0:
return ellipsis
if len(description_html) <= available:
return description_html
# Truncate, trying not to break mid-tag
truncated = description_html[: available - len(ellipsis)]
# If we broke an HTML tag, backtrack to before it
last_open = truncated.rfind('<')
last_close = truncated.rfind('>')
if last_open > last_close:
truncated = truncated[:last_open]
return truncated.rstrip() + ellipsis
+15
View File
@@ -3698,7 +3698,22 @@ async def update_subscription_autopay_endpoint(
subscription = _ensure_paid_subscription(user)
_validate_subscription_id(payload.subscription_id, subscription)
# Суточные подписки имеют свой механизм продления (DailySubscriptionService),
# глобальный autopay для них запрещён
target_enabled = bool(payload.enabled) if payload.enabled is not None else bool(subscription.autopay_enabled)
if target_enabled:
try:
await db.refresh(subscription, ['tariff'])
except Exception:
pass
if subscription.tariff and getattr(subscription.tariff, 'is_daily', False):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
'code': 'autopay_not_available_for_daily',
'message': 'Autopay is not available for daily subscriptions',
},
)
requested_days = payload.days_before
normalized_days = _normalize_autopay_days(requested_days)
+181 -102
View File
@@ -50,7 +50,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
display_name = settings.get_mulenpay_display_name()
if not secret_key:
logger.error('%s secret key is not configured', display_name)
logger.warning('%s secret key is not configured', display_name)
return False
signature = _extract_header(
@@ -95,7 +95,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if hmac.compare_digest(normalized_no_padding, expected_urlsafe.rstrip('=')):
return True
logger.error('Неверная подпись %s webhook', display_name)
logger.warning('Неверная подпись %s webhook', display_name)
return False
authorization_header = request.headers.get('Authorization')
@@ -107,7 +107,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if scheme_lower in {'bearer', 'token'}:
if hmac.compare_digest(token, secret_key):
return True
logger.error('Неверный %s токен %s webhook', scheme, display_name)
logger.warning('Неверный %s токен %s webhook', scheme, display_name)
return False
if not value and hmac.compare_digest(token, secret_key):
@@ -124,7 +124,7 @@ def _verify_mulenpay_signature(request: Request, raw_body: bytes) -> bool:
if fallback_token and hmac.compare_digest(fallback_token, secret_key):
return True
logger.error('Отсутствует подпись %s webhook', display_name)
logger.warning('Отсутствует подпись %s webhook', display_name)
return False
@@ -217,14 +217,23 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({'status': 'ok', 'result': result})
try:
result = await tribute_service.process_webhook(payload)
if result:
return JSONResponse({'status': 'ok', 'result': result})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
error = ValueError('Tribute webhook processing returned empty result')
logger.error('Tribute webhook processing failed: %s', error)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Tribute webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -256,18 +265,26 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_mulenpay_callback',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_mulenpay_callback',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
logger.error('MulenPay webhook processing failed: %s', payload)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('MulenPay webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -311,18 +328,29 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_cryptobot_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_cryptobot_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
logger.error(
'CryptoBot webhook processing failed: invoice_id=%s',
payload.get('payload', {}).get('invoice_id'),
)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('CryptoBot webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -416,18 +444,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
}:
return JSONResponse({'status': 'ok', 'ignored': event_type})
success = await _process_payment_service_callback(
payment_service,
webhook_data,
'process_yookassa_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
webhook_data,
'process_yookassa_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
payment_id = webhook_data.get('object', {}).get('id', 'unknown')
logger.error('YooKassa webhook processing failed: payment_id=%s', payment_id)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
except Exception as e:
logger.exception('YooKassa webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'processing_failed'},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
routes_registered = True
@@ -478,18 +515,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_wata_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_wata_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
order_id = payload.get('order_id', 'unknown')
logger.error('Wata webhook processing failed: order_id=%s', order_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Wata webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -533,18 +579,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_401_UNAUTHORIZED,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_heleket_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_heleket_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
uuid_val = payload.get('uuid', 'unknown')
logger.error('Heleket webhook processing failed: uuid=%s', uuid_val)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Heleket webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -595,18 +650,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
'process_pal24_callback',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
parsed_payload,
'process_pal24_callback',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
bill_id = parsed_payload.get('bill_id', 'unknown')
logger.error('Pal24 webhook processing failed: bill_id=%s', bill_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Pal24 webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -640,18 +704,27 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
status_code=status.HTTP_400_BAD_REQUEST,
)
success = await _process_payment_service_callback(
payment_service,
payload,
'process_platega_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
try:
success = await _process_payment_service_callback(
payment_service,
payload,
'process_platega_webhook',
)
if success:
return JSONResponse({'status': 'ok'})
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
transaction_id = payload.get('transactionId', 'unknown')
logger.error('Platega webhook processing failed: transactionId=%s', transaction_id)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.exception('Platega webhook processing error: %s', e)
return JSONResponse(
{'status': 'error', 'reason': 'not_processed'},
status_code=status.HTTP_400_BAD_REQUEST,
)
routes_registered = True
@@ -902,8 +975,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error('Freekassa webhook: не удалось прочитать данные формы')
except Exception as form_error:
logger.error('Freekassa webhook: не удалось прочитать данные формы: %s', form_error)
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры
@@ -945,17 +1018,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
cur_id=cur_id_int,
client_ip=client_ip,
)
if success:
return Response('YES', status_code=status.HTTP_200_OK)
logger.error('Freekassa webhook processing failed: order_id=%s, intid=%s', order_id, intid)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.exception('Freekassa webhook processing error: %s', e)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response('YES', status_code=status.HTTP_200_OK)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
# KassaAI webhook
@@ -976,8 +1052,8 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error('KassaAI webhook: не удалось прочитать данные формы')
except Exception as form_error:
logger.error('KassaAI webhook: не удалось прочитать данные формы: %s', form_error)
return Response('Error reading form data', status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры (те же что и у Freekassa)
@@ -1017,17 +1093,20 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
intid=intid,
cur_id=cur_id_int,
)
if success:
return Response('YES', status_code=status.HTTP_200_OK)
logger.error('KassaAI webhook processing failed: order_id=%s, intid=%s', order_id, intid)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
except Exception as e:
logger.exception('KassaAI webhook processing error: %s', e)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response('YES', status_code=status.HTTP_200_OK)
return Response('Error', status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
+47
View File
@@ -14,6 +14,7 @@ from app.database.database import init_db
from app.database.models import PaymentMethod
from app.database.universal_migration import run_universal_migration
from app.localization.loader import ensure_locale_templates
from app.logging_handler import TelegramErrorHandler
from app.services.backup_service import backup_service
from app.services.ban_notification_service import ban_notification_service
from app.services.broadcast_service import broadcast_service
@@ -140,6 +141,11 @@ async def main():
handlers=log_handlers,
)
# === TelegramErrorHandler: отправка ERROR/CRITICAL в админский чат ===
telegram_error_handler = TelegramErrorHandler(level=logging.ERROR)
telegram_error_handler.setFormatter(formatter)
logging.getLogger().addHandler(telegram_error_handler)
# Установим более высокий уровень логирования для "мусорных" логов
logging.getLogger('aiohttp.access').setLevel(logging.ERROR)
logging.getLogger('aiohttp.client').setLevel(logging.WARNING)
@@ -286,6 +292,7 @@ async def main():
ban_notification_service.set_bot(bot)
traffic_monitoring_scheduler.set_bot(bot)
daily_subscription_service.set_bot(bot)
telegram_error_handler.set_bot(bot)
# Initialize email broadcast service
from app.cabinet.services.email_service import email_service
@@ -715,6 +722,14 @@ async def main():
timeline.log_summary()
summary_logged = True
# Отправляем стартовое уведомление в админский чат
try:
from app.services.startup_notification_service import send_bot_startup_notification
await send_bot_startup_notification(bot)
except Exception as startup_notify_error:
logger.warning(f'Не удалось отправить стартовое уведомление: {startup_notify_error}')
try:
while not killer.exit:
await asyncio.sleep(1)
@@ -907,6 +922,30 @@ async def main():
logger.info('✅ Завершение работы бота завершено')
async def _send_crash_notification_on_error(error: Exception) -> None:
"""Отправляет уведомление о падении бота в админский чат."""
import traceback
from app.config import settings
if not getattr(settings, 'BOT_TOKEN', None):
return
try:
from aiogram import Bot
from app.services.startup_notification_service import send_crash_notification
bot = Bot(token=settings.BOT_TOKEN)
try:
traceback_str = traceback.format_exc()
await send_crash_notification(bot, error, traceback_str)
finally:
await bot.session.close()
except Exception as notify_error:
print(f'⚠️ Не удалось отправить уведомление о падении: {notify_error}')
if __name__ == '__main__':
try:
asyncio.run(main())
@@ -914,4 +953,12 @@ if __name__ == '__main__':
print('\n🛑 Бот остановлен пользователем')
except Exception as e:
print(f'❌ Критическая ошибка: {e}')
import traceback
traceback.print_exc()
# Пытаемся отправить уведомление о падении
try:
asyncio.run(_send_crash_notification_on_error(e))
except Exception:
pass
sys.exit(1)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = '3.1.3'
version = "3.5.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
+26
View File
@@ -0,0 +1,26 @@
{
"packages": {
".": {
"release-type": "python",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"include-component-in-tag": false,
"extra-files": [
"pyproject.toml"
],
"changelog-sections": [
{ "type": "feat", "section": "New Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "docs", "section": "Documentation" },
{ "type": "chore", "section": "Maintenance", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true },
{ "type": "test", "section": "Tests", "hidden": true },
{ "type": "build", "section": "Build", "hidden": true },
{ "type": "style", "section": "Style", "hidden": true }
]
}
},
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json"
}
+153
View File
@@ -0,0 +1,153 @@
"""
Тесты для сервиса диагностики реферальной системы.
"""
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from app.services.referral_diagnostics_service import ReferralDiagnosticsService
@pytest.fixture
def temp_log_file():
"""Создаёт временный лог-файл для тестов."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
yield Path(f.name)
# Cleanup
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def sample_log_content():
"""Пример содержимого лог-файла с реферальными событиями."""
today = datetime.now().strftime('%Y-%m-%d')
return f"""
{today} 10:00:00,123 - app.handlers.start - INFO - 🔎 Найден реферальный код: <ABC123>
{today} 10:00:05,456 - app.handlers.start - INFO - Реферальный код ABC123 применен для пользователя 123456789
{today} 10:00:10,789 - app.services.referral_service - INFO - Реферальная регистрация обработана для 123456789
{today} 10:00:15,012 - app.services.referral_service - INFO - 💰 Реферал 123456789 получил бонус
{today} 11:00:00,345 - app.handlers.start - INFO - 🔎 Найден реферальный код: <XYZ999>
{today} 11:00:05,678 - app.handlers.start - INFO - Реферальный код XYZ999 применен для пользователя 987654321
{today} 12:00:00,901 - app.handlers.start - INFO - 🔎 Найден реферальный код: <TEST777>
{today} 13:00:00,234 - unrelated module - INFO - Some other log message
"""
@pytest.mark.asyncio
async def test_parse_logs_basic(temp_log_file, sample_log_content):
"""Тест базового парсинга логов."""
# Записываем тестовые данные в файл
temp_log_file.write_text(sample_log_content)
service = ReferralDiagnosticsService(log_path=str(temp_log_file))
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + timedelta(days=1)
events = await service._parse_logs(today, tomorrow)
# Проверяем что нашлись все события
assert len(events) >= 6, f'Expected at least 6 events, found {len(events)}'
# Проверяем типы событий
event_types = [e.event_type for e in events]
assert 'code_found' in event_types
assert 'code_applied' in event_types
assert 'registration_processed' in event_types
assert 'bonus_given' in event_types
@pytest.mark.asyncio
async def test_analyze_period_with_issues(temp_log_file, sample_log_content):
"""Тест анализа с проблемными случаями."""
temp_log_file.write_text(sample_log_content)
service = ReferralDiagnosticsService(log_path=str(temp_log_file))
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + timedelta(days=1)
# Используем None вместо db для базового теста парсинга
from unittest.mock import AsyncMock
mock_db = AsyncMock()
mock_db.execute.return_value.scalar_one_or_none.return_value = None
report = await service.analyze_period(mock_db, today, tomorrow)
# Проверяем статистику
# Примечание: code_found не имеет telegram_id, поэтому total_link_clicks будет 0
# Это нормально - мы считаем только события с telegram_id
assert report.total_codes_applied >= 1, 'Should have applied codes'
# Проверяем что нашлись проблемные случаи
# (987654321 применил код, но не завершил регистрацию)
assert 987654321 in report.users_applied_no_registration, (
f'Expected 987654321 in problems, got: {report.users_applied_no_registration}'
)
@pytest.mark.asyncio
async def test_empty_log_file(temp_log_file):
"""Тест работы с пустым лог-файлом."""
temp_log_file.write_text('')
service = ReferralDiagnosticsService(log_path=str(temp_log_file))
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + timedelta(days=1)
from unittest.mock import AsyncMock
mock_db = AsyncMock()
report = await service.analyze_period(mock_db, today, tomorrow)
# Проверяем что отчёт пустой
assert report.total_link_clicks == 0
assert report.total_codes_applied == 0
assert report.total_registrations == 0
assert len(report.events) == 0
@pytest.mark.asyncio
async def test_nonexistent_log_file():
"""Тест работы с несуществующим лог-файлом."""
service = ReferralDiagnosticsService(log_path='/nonexistent/path/to/log.log')
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + timedelta(days=1)
from unittest.mock import AsyncMock
mock_db = AsyncMock()
# Не должно быть исключений
report = await service.analyze_period(mock_db, today, tomorrow)
assert report.total_link_clicks == 0
assert len(report.events) == 0
@pytest.mark.asyncio
async def test_analyze_today(temp_log_file, sample_log_content):
"""Тест метода analyze_today."""
temp_log_file.write_text(sample_log_content)
service = ReferralDiagnosticsService(log_path=str(temp_log_file))
from unittest.mock import AsyncMock
mock_db = AsyncMock()
report = await service.analyze_today(mock_db)
# Проверяем что период установлен корректно
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
assert report.analysis_period_start.date() == today.date()
Generated
+1 -1
View File
@@ -1150,7 +1150,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.1.3"
version = "3.4.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },