Compare commits

..
61 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 08810eb2e0 Merge pull request #69 from machka-pasla/dev
Update promo_edit_field_handler to clarify callback data structure
2025-08-07 23:57:59 +03:00
machka-pasla b5d3b7b9c7 Update promo_edit_field_handler to clarify callback data structure
- Changed the variable name from 'i18n' to 'action' for better readability and understanding of the callback data being processed.
- Ensured that the promo ID and field to edit are still correctly extracted and updated in the state management.
2025-08-07 23:55:43 +03:00
Machka PaslaandGitHub 6330d57c60 Merge pull request #68 from machka-pasla/dev
Tribute hotfix, payments logs
2025-08-07 23:51:55 +03:00
machka-pasla 149ff057a9 Refactor subscription synchronization logic for improved handling
- Enhanced the subscription syncing process to prioritize concrete subscription UUIDs for updates and creations, ensuring idempotency.
- Implemented atomic updates for existing subscriptions and streamlined the creation of new subscriptions when a UUID is available.
- Improved logging for subscription updates and creations to provide clearer feedback on sync actions.
- Added handling for cases where no subscription UUID is present, avoiding unnecessary record creation.
2025-08-07 23:47:19 +03:00
machka-pasla bb7641bb74 Improve amount conversion in TributeService to handle minor units
- Updated the amount handling logic to convert minor currency units (kopecks/cents) to major units before persisting.
- Added error handling for invalid amount inputs to ensure robustness in processing payment data.
- Ensured that the amount is rounded to two decimal places for accurate representation in the system.
2025-08-07 23:39:48 +03:00
machka-pasla 18f65ea493 Refactor notification handling and streamline router registration
- Replaced legacy notification functions with a unified NotificationService for better maintainability and clarity.
- Updated the main bot router registration to utilize a root router, simplifying the inclusion of user and admin routes.
- Removed unused middleware and helper functions to enhance code cleanliness and focus on essential components.
- Improved localization by adding new error messages for user interactions.
2025-08-07 23:30:32 +03:00
machka-pasla 5853b9da63 Enhance payment identifier normalization in TributeService
- Updated the TributeService to normalize provider payment identifiers, prioritizing true payment identifiers over subscription IDs for better uniqueness.
- Implemented fallback logic to append timestamps to subscription IDs, preventing deduplication of renewals.
- Expanded the list of successful charge events to include various payment-related events, improving event handling consistency.
2025-08-07 23:02:23 +03:00
machka-pasla 91cfe0baf3 Add payments feature to admin panel
- Integrated payments functionality into the admin panel by adding a new payments router and corresponding handlers.
- Updated the admin panel actions to include a view payments option, enhancing admin capabilities.
- Implemented new database functions to retrieve successful payment counts and details for export.
- Enhanced localization with new strings for payments management in both English and Russian.
2025-08-07 22:48:09 +03:00
machka-pasla 194f1b9e49 Enhance recent payment log retrieval to filter by succeeded status
- Updated the `get_recent_payment_logs_with_user` function to include a filter for payments with a 'succeeded' status, improving the relevance of retrieved payment logs.
- Adjusted the query structure for better readability and maintainability.
2025-08-07 19:00:27 +03:00
machka-pasla df15cfd25e Refactor promo handler functions to include session management
- Updated the promo_delete_handler, promo_edit_select_handler, and promo_edit_field_handler functions to accept an AsyncSession parameter, improving database interaction consistency.
- Enhanced the handling of expired subscriptions in the PanelWebhookService by modifying notification logic to only send messages if enabled, ensuring better control over user notifications.
- Added an import for the 'and_' function in payment_dal.py to support more complex query conditions.
2025-08-07 18:46:19 +03:00
machka-pasla 57e693fa37 Refactor synchronization messaging for clarity and simplicity
- Updated synchronization messages to provide simpler, more concise feedback to admins during the sync process.
- Replaced detailed sync status messages with straightforward notifications for success, failure, and errors.
- Enhanced localization for new message formats to improve user experience across languages.
2025-08-06 22:26:07 +03:00
Machka PaslaandGitHub 60ea6fff0d Merge pull request #67 from machka-pasla/dev
fix bug
2025-08-06 20:22:20 +03:00
machka-pasla 3cee4b243a Refactor details string handling in statistics display
- Simplified the logic for displaying synchronization details by removing the character limit, ensuring full visibility of the details or defaulting to "N/A" when not available.
- Improved code readability by streamlining the assignment of the details string.
2025-08-06 20:19:45 +03:00
machka-pasla a42f80160b Refactor broadcast confirmation prompt and enhance sync notification handling
- Updated the broadcast confirmation prompt to display the full message instead of a truncated preview.
- Improved error handling in the sync process by removing character limits on error details and ensuring comprehensive logging.
- Added a notification feature to inform admins about the panel synchronization status, including success and failure details.
- Enhanced localization for the broadcast confirmation prompt and added new log messages for sync notifications.
2025-08-06 19:04:03 +03:00
Machka PaslaandGitHub 74548527f1 Merge pull request #66 from machka-pasla/dev
autosync and bug fixes
2025-08-06 18:51:27 +03:00
Machka PaslaandGitHub f8e3bee52a Update .env.example 2025-08-06 18:49:21 +03:00
machka-pasla 649528e165 Refactor bot username retrieval in bulk promo code creation
- Updated the logic to retrieve the bot username dynamically, enhancing the accuracy of CSV links for promo codes.
- Improved error handling to log failures in fetching the bot username, ensuring better traceability.
- Adjusted the promo code service initialization in the start command handler to use the message context, improving consistency in bot interactions.
2025-08-06 18:03:14 +03:00
machka-pasla 859263dc2d Enhance bulk promo code creation with CSV export functionality
- Added the ability to generate and send a CSV file containing created promo codes, improving data accessibility for admins.
- Updated success messages to inform users about the CSV file and the number of created promo codes.
- Refactored the promo code creation logic to include detailed validity information and links for activation in the CSV output.
- Improved localization for bulk promo creation messages to enhance user experience.
2025-08-06 17:58:44 +03:00
machka-pasla a126c05365 Refactor variable assignment in promo detail retrieval
- Updated the variable assignment for the status emoji in the `get_promo_detail_text_and_keyboard` function to improve clarity and consistency in the code.
- Enhanced readability by using more descriptive variable names, aligning with recent refactoring efforts in the promo management handler.
2025-08-06 17:49:06 +03:00
machka-pasla d0c09f9e06 Enhance promo code creation and management logging
- Added logging for successful promo code creation, improving traceability of actions.
- Updated success message formatting to use a new variable for validity display, enhancing clarity for users.
- Refactored variable names in the promo management handler for better readability and consistency.
2025-08-06 17:41:58 +03:00
machka-pasla 9f7171a5c3 Refactor admin sync process for enhanced user and subscription tracking
- Updated the `perform_sync` function to improve tracking of users and subscriptions during synchronization.
- Introduced additional counters for detailed logging, including users without Telegram IDs and those not found in the database.
- Enhanced error handling and logging to provide clearer insights into the synchronization process and outcomes.
- Improved the summary details returned after synchronization, offering a comprehensive overview of the sync results.
2025-08-06 17:21:25 +03:00
machka-pasla 990b08cfdc Enhance subscription handling in admin sync process
- Added logic to retrieve the subscription UUID from the panel user data, improving the accuracy of subscription records.
- Updated the subscription creation process to use the actual subscription UUID when available, with fallback to the user UUID.
- Enhanced logging to provide clearer information on which UUID is being used for each user during synchronization.
2025-08-06 17:00:44 +03:00
machka-pasla 359a3c46a4 Implement panel synchronization functionality in admin handler
- Added a new `perform_sync` function to handle the synchronization of users and subscriptions from the admin panel.
- Enhanced error handling and logging during the sync process, providing detailed feedback on the synchronization status.
- Updated the `sync_command_handler` to utilize the new `perform_sync` function, improving code organization and readability.
- Improved messaging for sync results, including success and error details, to enhance admin user experience.
2025-08-06 16:46:52 +03:00
machka-pasla 00ffec13d8 Implement message queue management and automatic sync on bot startup
- Added initialization of the message queue manager during bot startup, enhancing message handling capabilities.
- Implemented automatic synchronization of the admin panel on startup, providing real-time updates and improved reliability.
- Updated admin handlers to utilize the message queue for broadcasting messages, improving efficiency and error handling.
- Introduced a new command for admins to check the status of message queues, enhancing monitoring and management capabilities.
- Enhanced localization for new features and messages related to queue management and synchronization.
2025-08-06 16:39:35 +03:00
Machka PaslaandGitHub 5c8099168d Merge pull request #63 from machka-pasla/dev
Dev
2025-08-06 01:24:06 +03:00
machka-pasla 4675141974 Enhance promo code management in admin panel with detailed views and export functionality
- Introduced a new method to retrieve detailed promo code information, improving the clarity of promo management.
- Added functionality to export promo code activations to CSV, enhancing data accessibility for admins.
- Streamlined the handling of promo code edits and improved user prompts for better interaction.
- Updated localization files to reflect new messages and ensure consistency across the admin panel.
2025-08-06 01:21:47 +03:00
machka-pasla 67f31d62e4 Refactor promo code management in admin panel for improved clarity and localization
- Simplified the handling of promo codes by consolidating text messages and improving localization for better user experience.
- Updated the structure of promo details and management views, enhancing readability and consistency in the admin panel.
- Removed deprecated text and streamlined the activation status messages, ensuring accurate and clear communication for admins.
2025-08-06 01:14:26 +03:00
Machka PaslaandGitHub c969d52ce1 Update README.md 2025-08-05 23:50:11 +03:00
machka-pasla 3bd29511e1 Refactor README.md for improved clarity and localization
- Translated the README.md content from English to Russian, enhancing accessibility for Russian-speaking users.
- Streamlined the structure of the README to better highlight key features, setup instructions, and project organization.
- Updated sections on user and admin functionalities, payment methods, and project technologies to reflect recent changes and improve user understanding.
2025-08-05 23:49:08 +03:00
Machka PaslaandGitHub 0c31256fd1 Merge pull request #62 from machka-pasla/dev
2.0.0 update
2025-08-05 23:36:31 +03:00
machka-pasla 04e45096be Update .env.example and settings.py for tribute payment configuration
- Revised the .env.example file to include updated settings for Telegram bot, database, and payment gateways, enhancing clarity for configuration.
- Added new fields for managing tribute payment notifications in settings.py, allowing for better control over cancellation notifications.
- Improved organization of payment method settings and localization options for a more streamlined user experience.
2025-08-05 23:33:46 +03:00
machka-pasla e537203209 Add auto-renewal and cancellation handling for tribute subscriptions
- Implemented a new method to handle expired tribute subscriptions, automatically renewing them if no cancellation was received.
- Added functionality to manage tribute subscription cancellations, setting a grace period and notifying users accordingly.
- Updated localization files to include messages for auto-renewal and cancellation notifications in both English and Russian, enhancing user communication.
2025-08-05 23:21:12 +03:00
machka-pasla 283fc6bad4 Update localization for user ban and unban messages in English and Russian
- Added new messages for successful ban and unban actions, as well as error messages for banning and unbanning users.
- Included a warning message for when a user is not banned, enhancing clarity in the admin panel's user management features.
2025-08-05 15:35:55 +03:00
machka-pasla a46e5a43d6 Add user ban and unban functionality with prompts in admin panel
- Implemented handlers for banning and unbanning users, allowing admins to input user IDs or usernames.
- Added prompts for user input during ban and unban processes, enhancing user experience and clarity.
- Updated localization for ban and unban messages in both English and Russian, ensuring consistency and improved communication.
- Refactored user display logic in the banned users list to use updated attributes, enhancing data accuracy.
2025-08-05 15:31:03 +03:00
machka-pasla 9cf5a93625 Refactor promo code handling in admin panel and clean up unused methods
- Replaced the promo_codes module with a new promo management structure, enhancing organization and clarity in the admin panel.
- Updated admin panel handlers to utilize the new promo management methods, improving the flow of promo code creation and management.
- Removed deprecated methods from payment and subscription data access layers, streamlining the codebase and improving maintainability.
- Ensured localization files are updated to reflect changes in promo management messaging, enhancing user experience.
2025-08-05 14:55:55 +03:00
machka-pasla c5f41a261b Update promo code success message and enhance subscription service settings
- Revised the success message for promo code creation to include updated variable names for clarity in the response.
- Added a new setting to control notification behavior for Tribute payments, improving customization options for subscription management.
- Updated Russian localization for admin panel buttons to enhance clarity and consistency in user interface elements.
2025-08-05 14:43:19 +03:00
machka-pasla 74719669fb Fix activation date reference and update localization for promo code toggle success message
- Changed the reference from `created_at` to `activated_at` in the promo activations handler to accurately reflect the activation date.
- Updated the localization files for both English and Russian to remove HTML tags from the promo code toggle success message, enhancing clarity.
2025-08-04 18:16:59 +03:00
machka-pasla c8831718ed Add promo code toggle and activations handling in admin panel
- Implemented a new handler to toggle the active status of promo codes, allowing admins to activate or deactivate codes easily.
- Added functionality to display activations for specific promo codes, enhancing visibility into their usage.
- Updated localization files to include new messages related to promo status changes and activations in both English and Russian, improving admin experience.
2025-08-04 18:10:33 +03:00
machka-pasla ad8f05e20b Refactor notification service to streamline admin notifications
- Removed the functionality to send suspicious promo attempt notifications to admins, simplifying the notification process.
- Updated comments in the code to reflect the changes made in the notification handling logic.
2025-08-04 14:32:53 +03:00
machka-pasla b53e6a2cc3 Update .env.example and enhance notification service for suspicious promo attempts
- Revised the .env.example file to include new configuration options for Telegram bot settings, database credentials, and payment gateways, ensuring clarity and ease of use.
- Implemented a new method in the NotificationService to notify admins of suspicious promo code attempts, enhancing security and monitoring capabilities.
- Updated localization files to include messages related to suspicious promo code attempts in both English and Russian, improving admin awareness and response.
2025-08-04 14:29:01 +03:00
machka-pasla bcd95fb56c Update localization files for admin panel buttons and sections
- Revised button labels in English and Russian localization files for improved clarity and conciseness, including changes to promo management, user management, and navigation buttons.
- Ensured consistency in terminology across both languages to enhance user experience in the admin panel.
2025-08-03 22:39:15 +03:00
machka-pasla ce041758f1 Enhance referral program features and localization updates
- Implemented referral statistics retrieval to provide users with insights on invited friends and subscription purchases.
- Updated referral command handler to include new statistics in the response message, improving user engagement.
- Revised localization files for both English and Russian to reflect changes in the referral program messaging, enhancing clarity and user experience.
2025-08-03 22:27:41 +03:00
machka-pasla b13883c039 Enhance promo code editing functionality in admin panel
- Implemented a four-step process for editing promo codes, allowing admins to update code, bonus days, max activations, and validity period with improved user prompts.
- Added new states to manage each step of the editing process, ensuring better flow and validation.
- Updated localization files to include new messages and prompts for the editing steps in both English and Russian, enhancing user experience.
2025-08-03 22:12:49 +03:00
machka-pasla fc48cd7a09 Update inline system statistics localization for clarity and consistency
- Revised the inline system statistics description in both English and Russian to include updated terminology and additional metrics, enhancing user understanding.
- Ensured consistency across localization files by aligning the descriptions in both languages.
2025-08-03 22:06:22 +03:00
machka-pasla 9688b0f773 Update localization files for enhanced system and user statistics
- Revised inline system statistics message to reflect updated terminology and additional metrics, including active and disabled users, memory usage, and traffic statistics.
- Enhanced inline user statistics message for clarity and added a new description field for better user understanding.
- Updated Russian localization to match the changes made in the English version, ensuring consistency across languages.
2025-08-03 22:00:24 +03:00
machka-pasla 52c4f83dd1 Enhance inline and admin statistics features
- Updated inline user statistics message to provide clearer information about bot usage and user status.
- Refactored system statistics retrieval to utilize the PanelApiService for improved data accuracy and detail, including memory usage and bandwidth statistics.
- Simplified the success message for bulk promo code creation by removing the codes list from the message, ensuring clarity.
- Enhanced localization files to reflect changes in success messages and added new labels for better user experience.
2025-08-03 21:50:44 +03:00
machka-pasla 23aa3934e4 Enhance inline query handling and admin statistics retrieval
- Updated inline query handler to include settings for thumbnail URLs, improving the customization of inline results.
- Refactored admin statistics retrieval to utilize new data structures for user status and bandwidth statistics, enhancing clarity and accuracy in reporting.
- Removed deprecated help result creation code to streamline the inline query functionality.
- Added new settings for inline mode thumbnail URLs to improve maintainability and flexibility in the codebase.
2025-08-03 21:37:39 +03:00
machka-pasla ebca408227 Add promo management action to admin panel and remove panel statistics retrieval
- Introduced a new action for managing promo codes within the admin panel, enhancing admin capabilities.
- Removed the panel statistics retrieval code from the statistics handler to streamline functionality and reduce complexity.
- Updated localization files to reflect changes related to promo management.
2025-08-03 21:27:46 +03:00
machka-pasla a5b408d204 Enhance promo code management and user statistics features
- Introduced a unified promo code management system, allowing admins to view and manage both active and inactive promo codes with detailed information.
- Updated the promo code detail view to include status indicators and management options for editing, toggling status, and viewing activation history.
- Enhanced user statistics display by restructuring the information layout and adding new labels for clarity.
- Improved localization files to support new messages and labels related to promo code management and user statistics.
- Implemented error handling for suspicious promo code attempts, integrating a notification system for better admin awareness.
2025-08-03 21:09:03 +03:00
machka-pasla aa97618165 Refactor notification service and enhance user management features
- Replaced legacy payment notification functions with a new NotificationService for improved code organization and maintainability.
- Updated user management handlers to utilize the new notification system for payment confirmations.
- Enhanced user card formatting with localized labels for better clarity and user experience.
- Introduced context management for the PanelApiService to ensure proper session handling.
- Updated localization files to include new labels and messages for user management and notifications.
2025-08-03 18:58:50 +03:00
machka-pasla 973e97c54e Refactor promo code creation process and remove unnecessary handlers
- Updated the promo code creation flow to a four-step process, enhancing user guidance and validation.
- Removed the optional description step from the promo code creation, streamlining the process.
- Updated localization files to reflect changes in the promo code creation steps and success messages.
- Enhanced error handling and logging for panel statistics retrieval in the admin statistics handler.
2025-08-03 18:44:12 +03:00
machka-pasla 4f6822f8e4 Enhance promo code management and add bulk creation functionality
- Refactored the promo code creation process into a step-by-step flow, improving user guidance and validation.
- Introduced bulk promo code creation with a structured multi-step process, allowing admins to create multiple codes efficiently.
- Added new states for managing the promo code creation process and bulk operations.
- Implemented panel statistics retrieval to provide admins with insights into system performance and user activity.
- Updated localization files to support new messages and prompts related to promo code management and statistics.
2025-08-03 18:20:47 +03:00
machka-pasla 0f26c9bdba Implement user subscription deletion and banned users management
- Replaced the trial reset functionality to delete all user subscriptions instead of deactivating them, ensuring a complete reset of trial eligibility.
- Added a new method to retrieve banned users from the database, enhancing the admin's ability to manage user bans.
- Updated user management handlers to utilize the new subscription deletion and banned users retrieval features.
- Enhanced localization files to support new messages related to banned users.
2025-08-03 17:51:37 +03:00
machka-pasla 39ba57951c Add field validation for optional integer settings
- Introduced a field validator for LOG_CHAT_ID and LOG_THREAD_ID to convert empty strings to None, ensuring proper handling of optional integer fields.
- This enhancement improves data integrity and validation within the settings configuration.
2025-08-03 17:37:29 +03:00
machka-pasla ffa61f673d Add Docker Buildx setup to development workflow
- Integrated Docker Buildx setup step in the GitHub Actions workflow for enhanced multi-platform builds.
- This addition streamlines the Docker image building process, improving efficiency and flexibility.
2025-08-03 17:32:03 +03:00
machka-pasla f0bbe90f80 Add Docker build workflow and enhance admin functionalities
- Introduced a new GitHub Actions workflow for building and pushing the development Docker image.
- Added inline mode handling for user interactions, allowing users to share referral links and view statistics.
- Enhanced admin functionalities with new sections for user management, statistics, and promo code management.
- Implemented CSV export for logs and improved notification services for various events, including new user registrations and payment notifications.
- Updated localization files to support new features and commands.
2025-08-03 15:59:37 +03:00
Machka PaslaandGitHub 35d798a429 Merge pull request #59 from machka-pasla/e4i3yn-codex/update-api-service-and-.env-variables
Update API service for squads
2025-07-29 15:31:49 +03:00
Machka Pasla b34eb733b9 Update API service for new squads API 2025-07-29 15:31:26 +03:00
Machka PaslaandGitHub fcd456dce9 Merge pull request #58 from machka-pasla/7was2g-codex/check-aiohttp-and-asyncio-interactions
Close services and update event loop usage
2025-07-29 14:45:50 +03:00
Machka Pasla 8246e9a590 Close sessions for all services on shutdown 2025-07-29 14:44:55 +03:00
Machka PaslaandGitHub 14f244ab79 tribute logger 2025-07-27 21:30:21 +03:00
47 changed files with 6065 additions and 1872 deletions
+23 -4
View File
@@ -18,6 +18,7 @@ SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
TERMS_OF_SERVICE_URL=https://example.com/tos
SUBSCRIPTION_MINI_APP_URL=
START_COMMAND_DESCRIPTION=
DISABLE_WELCOME_MESSAGE=
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
@@ -36,8 +37,11 @@ CRYPTOPAY_TOKEN=
CRYPTOPAY_NETWORK=mainnet
CRYPTOPAY_CURRENCY_TYPE=fiat
CRYPTOPAY_ASSET=RUB
# API key for verifying Tribute webhook signatures
TRIBUTE_API_KEY=
# Tribute Payment Gateway Configuration
TRIBUTE_API_KEY= # API key for verifying Tribute webhook signatures
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
# Payment Method Toggles
YOOKASSA_ENABLED=True
@@ -92,8 +96,8 @@ PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
USER_TRAFFIC_LIMIT_GB=0
USER_TRAFFIC_STRATEGY="NO_RESET"
# Default Inbounds for Users (Optional, comma-separated UUIDs)
USER_INBOUND_UUIDS=uuid1,uuid2,uuid3
# Default Internal Squads for Users (Optional, comma-separated UUIDs)
USER_SQUAD_UUIDS=uuid1,uuid2,uuid3
# Trial Settings
TRIAL_ENABLED=True
@@ -107,3 +111,18 @@ WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_NEW_USERS=True
LOG_PAYMENTS=True
LOG_PROMO_ACTIVATIONS=True
LOG_TRIAL_ACTIVATIONS=True
LOG_SUSPICIOUS_ACTIVITY=True
# Inline Mode Thumbnails
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
INLINE_FINANCIAL_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2769/2769339.png
INLINE_SYSTEM_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/2920/2920277.png
+59
View File
@@ -0,0 +1,59 @@
name: Build and Push Dev Docker Image
on:
push:
branches:
- dev
pull_request:
branches:
- dev
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image digest
run: echo ${{ steps.meta.outputs.digest }}
+139 -201
View File
@@ -1,245 +1,183 @@
# Telegram-бот для продажи подписок Remnawave
# Remnawave Subscription Sales Telegram Bot
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments.
## ✨ Ключевые возможности
## ✨ Features
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
* **User Interaction:**
* User registration with language selection (English/Russian).
* Display of main menu with available actions via inline keyboards.
* Ability for users to view their current subscription status, expiration date, and configuration link.
* Trial subscription system for new users (configurable, manual activation via button).
* Promo code system for users to apply discounts or bonuses.
* Referral program for users to earn bonus subscription days.
* **Subscription Management:**
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
* Supports **Crypto Pay** for payments with fiat currency (RUB by default).
* Automatic subscription activation/extension upon successful payment.
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
* **Admin Panel:**
* Protected by `ADMIN_IDS` (supports multiple administrators).
* **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status.
* **User Management:**
* Ban/Unban users by Telegram ID or @username (updates local DB and panel).
* View a paginated list of banned users.
* View a "user card" with detailed information and unban option.
* **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions.
* **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity).
* **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID.
* **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user.
* **Notifications:**
* Automated daily notifications to users about expiring subscriptions (via APScheduler).
* Notifications to users and inviters upon successful referral bonus application.
* Notifications to admin(s) about suspicious promo code input attempts.
* **Security & Technical:**
* Uses parameterized queries to prevent SQL injection.
* Proactive check for suspicious input in promo code field (notifies admin).
* Middleware for checking banned users on every interaction.
* Middleware for logging user actions.
* Webhook support for Telegram and YooKassa for efficient updates.
* Configurable via `.env` file.
* Dockerized for easy deployment.
### Для администраторов:
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
- **Управление промокодами:** Создание и просмотр промокодов.
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
- **Логи действий:** Просмотр логов всех действий пользователей.
## 🚀 Technologies Used
## 🚀 Технологии
* **Python 3.11**
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
* **aiohttp:** For running the webhook server
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
* **YooKassa SDK:** For payment processing
* **APScheduler:** For scheduled tasks (e.g., notifications)
* **Pydantic:** For settings management (loading from `.env`)
* **Docker & Docker Compose:** For containerization and deployment
- **Python 3.11**
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
- **aiohttp:** Для запуска веб-сервера (вебхуки).
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
- **APScheduler:** Для выполнения отложенных задач (например, уведомления об окончании подписки).
- **Pydantic:** Для управления настройками из `.env` файла.
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
## ⚙️ Setup and Configuration
## ⚙️ Установка и запуск
### Prerequisites
### Предварительные требования
* Docker and Docker Compose installed.
* A running instance of a Remnawave panel.
* A Telegram Bot Token.
* A YooKassa Shop ID and Secret Key.
- Установленные Docker и Docker Compose.
- Рабочая панель Remnawave.
- Токен Telegram-бота.
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
### Configuration Steps
### Шаги установки
1. **Clone the Repository:**
1. **Клонируйте репозиторий:**
```bash
git clone https://github.com/machka-pasla/remnawave-tg-shop
cd remnawave-tg-shop
```
2. **Create an `.env` File:**
Copy the `env.example` file to `.env` and fill in your specific values:
2. **Создайте и настройте файл `.env`:**
Скопируйте `env.example` в `.env` и заполните своими данными.
```bash
cp .env.example .env
nano .env
```
Key variables to configure in `.env`:
* `BOT_TOKEN`: Your Telegram Bot Token from BotFather.
* `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.**
* `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`).
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
* **YooKassa Settings:**
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
* `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`.
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
* **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`.
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`.
* **Subscription Options:** For each duration you can use variables like
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
* **Panel API Settings:**
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
* `PANEL_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel.
* `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
Ниже перечислены ключевые переменные.
3. **Locales:**
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
<details>
<summary><b>Основные настройки</b></summary>
4. **Run with Docker Compose:**
| Переменная | Описание | Пример |
| --- | --- | --- |
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
</details>
<details>
<summary><b>Настройки платежей и вебхуков</b></summary>
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
</details>
<details>
<summary><b>Настройки подписок</b></summary>
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
- `1_MONTH_ENABLED`: `true` или `false`
- `RUB_PRICE_1_MONTH`: Цена в рублях
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
</details>
<details>
<summary><b>Настройки панели Remnawave</b></summary>
| Переменная | Описание |
| --- | --- |
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
| `PANEL_API_KEY` | API ключ для доступа к панели. |
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
</gidetails>
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash
docker compose up -d
```
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
Эта команда скачает образ и запустит сервис в фоновом режиме.
5. **Webhook Setup (Important if using webhooks):**
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`.
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`.
* If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
4. **Настройка вебхуков (Обязательно):**
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
6. **Database:**
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
7. **Viewing Logs:**
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
5. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
```
## 🐳 Docker Setup
## 🐳 Docker
### `Dockerfile`
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
# Consider adding build arguments for proxy if needed in your environment
# ARG HTTP_PROXY
# ARG HTTPS_PROXY
# ENV http_proxy=$HTTP_PROXY
# ENV https_proxy=$HTTPS_PROXY
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Ensure main.py is executable if needed, though python command handles it
# RUN chmod +x main.py
CMD ["python", "main.py"]
```
### `docker-compose.yml`
```
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
# build: .
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
# networks:
# - remnawave-network
# volumes:
# - ./locales:/app/locales
restart: unless-stopped
postgres:
image: postgres:17
container_name: remnawave-tg-shop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks:
# - remnawave-network
restart: unless-stopped
# networks:
# remnawave-network:
# external: true
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
```
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
## 🛠️ Project Structure (Overview)
## 📁 Структура проекта
```
.
├── bot/
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter)
│ ├── handlers/ # Message and callback query handlers (admin and user)
│ ├── keyboards/ # Inline and reply keyboard generators
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger)
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction)
│ ├── states/ # FSM states
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
│ ├── filters/ # Пользовательские фильтры Aiogram
│ ├── handlers/ # Обработчики сообщений и колбэков
│ ├── keyboards/ # Клавиатуры
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
│ ├── services/ # Бизнес-логика (платежи, API панели)
│ ├── states/ # Состояния FSM
│ └── main_bot.py # Основная логика бота
├── config/
│ └── settings.py # Pydantic settings and config parser
│ └── settings.py # Настройки Pydantic
├── db/
│ ├── dal/ # Data Access Layer (queries, transactions)
│ ├── database_setup.py # DB connection/init setup
│ └── models.py # ORM models (e.g., SQLAlchemy)
├── locales/ # Localization files
├── en.json # English locale
│ └── ru.json # Russian locale
├── .env.example # Example environment variables for local setup
├── .env # Actual environment variables (ignored by Git)
── Dockerfile # Docker image build instructions
├── docker-compose.yml # Docker Compose orchestration config
├── requirements.txt # List of Python dependencies
├── README.md # Project documentation
└── main.py # Entry point to launch the bot
│ ├── dal/ # Слой доступа к данным (DAL)
│ ├── database_setup.py # Настройка БД
│ └── models.py # Модели SQLAlchemy
├── locales/ # Файлы локализации (ru, en)
├── .env.example # Пример файла с переменными окружения
├── Dockerfile # Инструкции для сборки Docker-образа
├── docker-compose.yml # Файл для оркестрации контейнеров
├── requirements.txt # Зависимости Python
── main.py # Точка входа в приложение
```
## 🤝 Contributing
## 🔮 Планы на будущее
Contributions are welcome!
- Расширенные типы промокодов (например, скидки в процентах).
## 🔮 Future Enhancements
- More detailed analytics for admin.
- Support for different payment methods.
- Advanced promo code types (e.g., percentage discounts).
## Donations (pls)
- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg)
## ❤️ Поддержка
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
+4 -2
View File
@@ -2,20 +2,22 @@ from aiogram import Router
from . import common
from . import broadcast
from . import promo_codes
from .promo import promo_router_aggregate
from . import user_management
from . import statistics
from . import sync_admin
from . import logs_admin
from . import payments
admin_router_aggregate = Router(name="admin_features_router")
admin_router_aggregate.include_router(common.router)
admin_router_aggregate.include_router(broadcast.router)
admin_router_aggregate.include_router(promo_codes.router)
admin_router_aggregate.include_router(promo_router_aggregate)
admin_router_aggregate.include_router(user_management.router)
admin_router_aggregate.include_router(statistics.router)
admin_router_aggregate.include_router(sync_admin.router)
admin_router_aggregate.include_router(logs_admin.router)
admin_router_aggregate.include_router(payments.router)
__all__ = ("admin_router_aggregate", )
+27 -8
View File
@@ -1,6 +1,7 @@
import logging
import asyncio
from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramRetryAfter
from aiogram.fsm.context import FSMContext
from typing import Optional
@@ -17,6 +18,7 @@ from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager
router = Router(name="admin_broadcast_router")
@@ -82,8 +84,7 @@ async def process_broadcast_message_handler(
broadcast_entities=entities,
)
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=text)
await message.answer(
confirmation_prompt,
@@ -171,22 +172,30 @@ async def confirm_broadcast_callback_handler(
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
)
# Get message queue manager
queue_manager = get_queue_manager()
if not queue_manager:
await callback.message.edit_text("❌ Ошибка: система очередей не инициализирована", reply_markup=None)
return
# Queue all messages for sending
for uid in user_ids:
try:
await bot.send_message(
await queue_manager.send_message(
chat_id=uid,
text=text,
entities=entities,
)
sent_count += 1
# Log successful queuing
await message_log_dal.create_message_log(
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_sent",
"event_type": "admin_broadcast_queued",
"content": f"To user {uid}: {text[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
@@ -195,7 +204,7 @@ async def confirm_broadcast_callback_handler(
except Exception as e:
failed_count += 1
logging.warning(
f"Failed to send broadcast to {uid}: {type(e).__name__} {e}"
f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}"
)
await message_log_dal.create_message_log(
session,
@@ -209,7 +218,6 @@ async def confirm_broadcast_callback_handler(
"target_user_id": uid,
},
)
await asyncio.sleep(0.05)
try:
await session.commit()
@@ -217,7 +225,18 @@ async def confirm_broadcast_callback_handler(
await session.rollback()
logging.error(f"Error committing broadcast logs: {e_commit}")
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
# Get queue stats for detailed report
queue_stats = queue_manager.get_queue_stats()
result_message = f"""🚀 Рассылка поставлена в очередь!
📤 В очередь добавлено: {sent_count}
❌ Ошибок: {failed_count}
📊 Статус очередей:
👥 Очередь пользователей: {queue_stats['user_queue_size']} сообщений
📢 Очередь групп: {queue_stats['group_queue_size']} сообщений
ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."""
await callback.message.answer(
result_message,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
+132 -5
View File
@@ -6,13 +6,20 @@ from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.admin_keyboards import get_admin_panel_keyboard
from bot.keyboards.inline.admin_keyboards import (
get_admin_panel_keyboard, get_stats_monitoring_keyboard,
get_user_management_keyboard, get_ban_management_keyboard,
get_promo_marketing_keyboard, get_system_functions_keyboard
)
from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.message_queue import get_queue_manager
from . import broadcast as admin_broadcast_handlers
from . import promo_codes as admin_promo_handlers
from .promo import create as admin_promo_create_handlers
from .promo import manage as admin_promo_manage_handlers
from .promo import bulk as admin_promo_bulk_handlers
from . import user_management as admin_user_mgmnt_handlers
from . import statistics as admin_stats_handlers
from . import sync_admin as admin_sync_handlers
@@ -73,13 +80,16 @@ async def admin_panel_actions_callback_handler(
await admin_broadcast_handlers.broadcast_message_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "create_promo":
await admin_promo_handlers.create_promo_prompt_handler(
await admin_promo_create_handlers.create_promo_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "create_bulk_promo":
await admin_promo_bulk_handlers.create_bulk_promo_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "manage_promos":
await admin_promo_handlers.manage_promo_codes_handler(
await admin_promo_manage_handlers.manage_promo_codes_handler(
callback, i18n_data, settings, session)
elif action == "view_promos":
await admin_promo_handlers.view_promo_codes_handler(
await admin_promo_manage_handlers.view_promo_codes_handler(
callback, i18n_data, settings, session)
elif action == "ban_user_prompt":
await admin_user_mgmnt_handlers.ban_user_prompt_handler(
@@ -87,6 +97,10 @@ async def admin_panel_actions_callback_handler(
elif action == "unban_user_prompt":
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "users_management":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_management_menu_handler(
callback, state, i18n_data, settings, session)
elif action == "view_banned":
await admin_user_mgmnt_handlers.view_banned_users_handler(
@@ -94,6 +108,9 @@ async def admin_panel_actions_callback_handler(
elif action == "view_logs_menu":
await admin_logs_handlers.display_logs_menu(callback, i18n_data,
settings, session)
elif action == "promo_management":
await admin_promo_manage_handlers.promo_management_handler(
callback, i18n_data, settings, session)
elif action == "sync_panel":
await admin_sync_handlers.sync_command_handler(
@@ -104,6 +121,12 @@ async def admin_panel_actions_callback_handler(
panel_service=panel_service,
session=session)
await callback.answer(_("admin_sync_initiated_from_panel"))
elif action == "queue_status":
await show_queue_status_handler(callback, i18n_data)
elif action == "view_payments":
from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session)
elif action == "main":
try:
await callback.message.edit_text(
@@ -121,3 +144,107 @@ async def admin_panel_actions_callback_handler(
f"Unknown admin_action received: {action} from callback {callback.data}"
)
await callback.answer(_("admin_unknown_action"), show_alert=True)
@router.callback_query(F.data.startswith("admin_section:"))
async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext,
settings: Settings, i18n_data: dict, session: AsyncSession):
section = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
await callback.answer("Error: message context lost.", show_alert=True)
return
try:
if section == "stats_monitoring":
await callback.message.edit_text(
_("admin_stats_and_monitoring_section"),
reply_markup=get_stats_monitoring_keyboard(i18n, current_lang)
)
elif section == "user_management":
await callback.message.edit_text(
_("admin_user_management_section"),
reply_markup=get_user_management_keyboard(i18n, current_lang)
)
elif section == "ban_management":
await callback.message.edit_text(
_("admin_ban_management_section"),
reply_markup=get_ban_management_keyboard(i18n, current_lang)
)
elif section == "promo_marketing":
await callback.message.edit_text(
_("admin_promo_marketing_section"),
reply_markup=get_promo_marketing_keyboard(i18n, current_lang)
)
elif section == "system_functions":
await callback.message.edit_text(
_("admin_system_functions_section"),
reply_markup=get_system_functions_keyboard(i18n, current_lang)
)
else:
await callback.answer(_("admin_unknown_action"), show_alert=True)
return
await callback.answer()
except Exception as e:
logging.error(f"Error handling admin section {section}: {e}")
await callback.message.answer(
_("error_occurred_try_again"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer()
async def show_queue_status_handler(callback: types.CallbackQuery, i18n_data: dict):
"""Show message queue status to admin"""
current_lang = i18n_data.get("current_language", "ru")
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
queue_manager = get_queue_manager()
if not queue_manager:
from aiogram.utils.keyboard import InlineKeyboardBuilder
await callback.message.edit_text(
"❌ Система очередей не инициализирована",
reply_markup=InlineKeyboardBuilder().button(
text=_("back_to_admin_panel_button"),
callback_data="admin_action:main"
).as_markup()
)
await callback.answer()
return
try:
stats = queue_manager.get_queue_stats()
message_text = _(
"admin_queue_status_info",
user_queue_size=stats['user_queue_size'],
user_processing="✅ Да" if stats['user_queue_processing'] else "❌ Нет",
user_recent=stats['user_recent_sends'],
group_queue_size=stats['group_queue_size'],
group_processing="✅ Да" if stats['group_queue_processing'] else "❌ Нет",
group_recent=stats['group_recent_sends']
)
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
except Exception as e:
logging.error(f"Error getting queue status: {e}")
await callback.answer("❌ Ошибка получения статуса очередей", show_alert=True)
+106
View File
@@ -1,6 +1,9 @@
import logging
import math
import re
import csv
import io
from datetime import datetime
from aiogram import Router, F, types, Bot
from aiogram.fsm.context import FSMContext
from typing import Optional, List, Dict, Any
@@ -321,3 +324,106 @@ async def cancel_log_user_input_state_to_menu(callback: types.CallbackQuery,
await state.clear()
await display_logs_menu(callback, i18n_data, settings, session)
@router.callback_query(F.data == "admin_logs:export_csv")
async def export_logs_csv_handler(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
if not i18n or not callback.message:
await callback.answer("Error processing CSV export.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_(
"admin_logs_csv_export_started",
default="🔄 Начинаю экспорт логов в CSV..."
))
try:
# Get all logs (limit to 10000 for performance)
logs_models = await message_log_dal.get_all_message_logs(
session, limit=10000, offset=0)
if not logs_models:
await callback.message.answer(_(
"admin_logs_csv_no_data",
default="❌ Нет данных для экспорта"
))
return
# Create CSV content
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
# Write header
headers = [
_("admin_csv_header_log_id", default="Log ID"),
_("admin_csv_header_timestamp", default="Timestamp"),
_("admin_csv_header_user_id", default="User ID"),
_("admin_csv_header_telegram_username", default="Telegram Username"),
_("admin_csv_header_telegram_first_name", default="Telegram First Name"),
_("admin_csv_header_event_type", default="Event Type"),
_("admin_csv_header_content", default="Content"),
_("admin_csv_header_is_admin_event", default="Is Admin Event"),
_("admin_csv_header_target_user_id", default="Target User ID"),
_("admin_csv_header_raw_update_preview", default="Raw Update Preview")
]
csv_writer.writerow(headers)
# Write data rows
for log in logs_models:
# Format timestamp
timestamp_str = log.timestamp.strftime('%Y-%m-%d %H:%M:%S UTC') if log.timestamp else ''
# Clean content and raw_update_preview (remove newlines and quotes for CSV)
content_clean = (log.content or '').replace('\n', ' ').replace('\r', ' ').strip()
raw_update_clean = (log.raw_update_preview or '').replace('\n', ' ').replace('\r', ' ').strip()
row = [
log.log_id or '',
timestamp_str,
log.user_id or '',
log.telegram_username or '',
log.telegram_first_name or '',
log.event_type or '',
content_clean,
'Yes' if log.is_admin_event else 'No',
log.target_user_id or '',
raw_update_clean
]
csv_writer.writerow(row)
# Create file
csv_content = csv_buffer.getvalue()
csv_buffer.close()
# Generate filename with current timestamp
now = datetime.now()
filename = f"message_logs_{now.strftime('%Y%m%d_%H%M%S')}.csv"
# Send as document
csv_file = types.BufferedInputFile(
csv_content.encode('utf-8-sig'), # BOM for Excel compatibility
filename=filename
)
await callback.message.answer_document(
csv_file,
caption=_(
"admin_logs_csv_export_success",
default="✅ Экспорт логов завершен!\n\n📊 Записей: {count}\n📅 Дата экспорта: {date}",
count=len(logs_models),
date=now.strftime('%Y-%m-%d %H:%M:%S')
)
)
except Exception as e:
logging.error(f"Error exporting logs to CSV: {e}", exc_info=True)
await callback.message.answer(_(
"admin_logs_csv_export_failed",
default="❌ Ошибка при экспорте логов: {error}",
error=str(e)
))
+245
View File
@@ -0,0 +1,245 @@
import logging
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_payments_router")
async def get_payments_with_pagination(session: AsyncSession, page: int = 0,
page_size: int = 10) -> tuple[List[Payment], int]:
"""Get payments with pagination and total count."""
offset = page * page_size
# Get total count
total_count = await payment_dal.get_payments_count(session)
# Get payments for current page
payments = await payment_dal.get_recent_payment_logs_with_user(
session, limit=page_size, offset=offset
)
return payments, total_count
def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status in ['pending', 'pending_yookassa'] else ""
)
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
user_info += f" (@{payment.user.username})"
elif payment.user and payment.user.first_name:
user_info += f" ({payment.user.first_name})"
payment_date = payment.created_at.strftime('%Y-%m-%d %H:%M') if payment.created_at else "N/A"
provider_text = {
'yookassa': 'YooKassa',
'tribute': 'Tribute',
'telegram_stars': 'Telegram Stars',
'cryptopay': 'CryptoPay'
}.get(payment.provider, payment.provider or 'Unknown')
return (
f"{status_emoji} <b>{payment.amount} {payment.currency}</b>\n"
f"👤 {user_info}\n"
f"💳 {provider_text}\n"
f"📅 {payment_date}\n"
f"📋 {payment.status}\n"
f"📝 {payment.description or 'N/A'}"
)
async def view_payments_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession, page: int = 0):
"""Display paginated list of all payments."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = 5 # Показываем по 5 платежей на странице
payments, total_count = await get_payments_with_pagination(session, page, page_size)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
if not payments and page == 0:
await callback.message.edit_text(
_("admin_no_payments_found", default="Платежи не найдены."),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
return
# Format payments text
text_parts = [_("admin_payments_header", default="💰 <b>Все платежи</b>")]
text_parts.append(f"📊 Показано {len(payments)} из {total_count} платежей (стр. {page + 1}/{total_pages})\n")
for i, payment in enumerate(payments, 1):
text_parts.append(f"<b>{page * page_size + i}.</b> {format_payment_text(payment, i18n, current_lang)}")
text_parts.append("") # Empty line between payments
# Build keyboard with pagination and export
builder = InlineKeyboardBuilder()
# Pagination buttons
nav_buttons = []
if page > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"payments_page:{page-1}"))
nav_buttons.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="noop"))
if page < total_pages - 1:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"payments_page:{page+1}"))
if nav_buttons:
builder.row(*nav_buttons)
# Export and refresh buttons
builder.row(
InlineKeyboardButton(
text=_("admin_export_payments_csv", default="📊 Экспорт CSV"),
callback_data="payments_export_csv"
),
InlineKeyboardButton(
text=_("admin_refresh_payments", default="🔄 Обновить"),
callback_data=f"payments_page:{page}"
)
)
# Back button
builder.row(InlineKeyboardButton(
text=_("back_to_admin_panel_button"),
callback_data="admin_section:stats_monitoring"
))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=builder.as_markup(),
parse_mode="HTML"
)
await callback.answer()
@router.callback_query(F.data.startswith("payments_page:"))
async def payments_pagination_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
"""Handle pagination for payments list."""
try:
page = int(callback.data.split(":")[1])
await view_payments_handler(callback, i18n_data, settings, session, page)
except (ValueError, IndexError):
await callback.answer("Error processing pagination.", show_alert=True)
@router.callback_query(F.data == "payments_export_csv")
async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
"""Export all successful payments to CSV file."""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
# Get all successful payments
all_payments = await payment_dal.get_all_succeeded_payments_with_user(session)
if not all_payments:
await callback.answer(
_("admin_no_payments_to_export", default="Нет платежей для экспорта."),
show_alert=True
)
return
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
_("admin_csv_payment_id", default="ID"),
_("admin_csv_user_id", default="User ID"),
_("admin_csv_username", default="Username"),
_("admin_csv_first_name", default="First Name"),
_("admin_csv_amount", default="Amount"),
_("admin_csv_currency", default="Currency"),
_("admin_csv_provider", default="Provider"),
_("admin_csv_status", default="Status"),
_("admin_csv_description", default="Description"),
_("admin_csv_months", default="Months"),
_("admin_csv_created_at", default="Created At"),
_("admin_csv_provider_payment_id", default="Provider Payment ID")
])
# Write payment data
for payment in all_payments:
writer.writerow([
payment.payment_id,
payment.user_id,
payment.user.username if payment.user and payment.user.username else "",
payment.user.first_name if payment.user and payment.user.first_name else "",
payment.amount,
payment.currency,
payment.provider or "",
payment.status,
payment.description or "",
payment.subscription_duration_months or "",
payment.created_at.strftime('%Y-%m-%d %H:%M:%S') if payment.created_at else "",
payment.provider_payment_id or ""
])
# Prepare file
csv_content = output.getvalue().encode('utf-8-sig') # UTF-8 with BOM for Excel
output.close()
# Generate filename with current date
current_time = datetime.now().strftime('%Y-%m-%d_%H-%M')
filename = f"payments_export_{current_time}.csv"
# Send file
from aiogram.types import BufferedInputFile
file = BufferedInputFile(csv_content, filename=filename)
await callback.message.reply_document(
document=file,
caption=_("admin_payments_export_success",
default="📊 Экспорт платежей завершен!\nВсего записей: {count}",
count=len(all_payments))
)
await callback.answer(
_("admin_export_sent", default="Файл отправлен!"),
show_alert=False
)
except Exception as e:
logging.error(f"Failed to export payments CSV: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
@router.callback_query(F.data == "noop")
async def noop_handler(callback: types.CallbackQuery):
"""Handle no-op callback (for pagination display)."""
await callback.answer()
+13
View File
@@ -0,0 +1,13 @@
from aiogram import Router
from . import create
from . import manage
from . import bulk
promo_router_aggregate = Router(name="promo_features_router")
promo_router_aggregate.include_router(create.router)
promo_router_aggregate.include_router(manage.router)
promo_router_aggregate.include_router(bulk.router)
__all__ = ("promo_router_aggregate",)
+568
View File
@@ -0,0 +1,568 @@
import logging
import random
import string
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import promo_code_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_bulk_router")
async def create_bulk_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing bulk promo creation.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for quantity
prompt_text = _(
"admin_bulk_promo_step1_quantity",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество\n\nВведите количество промокодов для создания (1-100):"
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
except Exception as e:
logging.warning(
f"Could not edit message for bulk promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
await callback.answer()
await state.set_state(AdminStates.waiting_for_bulk_promo_quantity)
def generate_unique_promo_code(length: int = 8) -> str:
"""Generate a unique random promo code"""
characters = string.ascii_uppercase + string.digits
return ''.join(random.choice(characters) for _ in range(length))
# Step 1: Process quantity
@router.message(AdminStates.waiting_for_bulk_promo_quantity, F.text)
async def process_bulk_promo_quantity_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
quantity = int(message.text.strip())
if not (1 <= quantity <= 100):
await message.answer(_(
"admin_bulk_promo_invalid_quantity",
default="❌ Количество промокодов должно быть от 1 до 100"
))
return
await state.update_data(quantity=quantity)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_bulk_promo_step2_bonus_days",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней для каждого промокода (1-365):",
quantity=quantity
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_bulk_promo_bonus_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing bulk promo quantity: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_bulk_promo_bonus_days, F.text)
async def process_bulk_promo_bonus_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days",
default="❌ Количество бонусных дней должно быть от 1 до 365"
))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step3_max_activations",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
quantity=data.get("quantity"),
bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_bulk_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing bulk promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_bulk_promo_max_activations, F.text)
async def process_bulk_promo_max_activations_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations",
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_step4_validity",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
callback_data="bulk_promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
callback_data="bulk_promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel", default="🔙 В админ панель"),
callback_data="admin_action:main"
)
)
await message.answer(
prompt_text,
reply_markup=builder.as_markup(),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_bulk_promo_validity_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing bulk promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(F.data == "bulk_promo_unlimited_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
async def process_bulk_promo_unlimited_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
await state.update_data(validity_days=None)
await create_bulk_promo_codes_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(F.data == "bulk_promo_set_validity", StateFilter(AdminStates.waiting_for_bulk_promo_validity_days))
async def process_bulk_promo_set_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing validity.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_bulk_promo_enter_validity_days",
default="🎟 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокодов (1-365):",
quantity=data.get("quantity"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_bulk_promo_validity_days, F.text)
async def process_bulk_promo_validity_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days",
default="❌ Срок действия должен быть от 1 до 365 дней"
))
return
await state.update_data(validity_days=validity_days)
await create_bulk_promo_codes_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing bulk promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_bulk_promo_codes_final(callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
"""Final step - create multiple promo codes in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
data = await state.get_data()
quantity = data["quantity"]
# Show progress message
progress_text = _(
"admin_bulk_promo_creating",
default="🔄 Создание {quantity} промокодов...",
quantity=quantity
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
await callback_or_message.message.edit_text(progress_text, parse_mode="HTML")
except Exception:
await callback_or_message.message.answer(progress_text, parse_mode="HTML")
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(progress_text, parse_mode="HTML")
# Generate and create promo codes
created_codes = []
failed_codes = []
for i in range(quantity):
try:
# Generate unique code
attempts = 0
while attempts < 10: # Max 10 attempts to generate unique code
promo_code = generate_unique_promo_code()
existing_promo = await promo_code_dal.get_promo_code_by_code(session, promo_code)
if not existing_promo:
break
attempts += 1
if attempts >= 10:
failed_codes.append(f"Код #{i+1} (не удалось сгенерировать уникальный)")
continue
# Prepare promo code data
promo_data = {
"code": promo_code,
"bonus_days": data["bonus_days"],
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
created_codes.append(created_promo.code)
except Exception as e:
logging.error(f"Error creating bulk promo code #{i+1}: {e}")
failed_codes.append(f"Код #{i+1} ({str(e)[:50]})")
await session.commit()
# Success message
success_lines = [
_(
"admin_bulk_promo_created_title",
default="✅ <b>Массовое создание завершено!</b>\n"
),
_(
"admin_bulk_promo_created_stats",
default="📊 Создано: <b>{created}</b> из <b>{total}</b>",
created=len(created_codes),
total=quantity
)
]
if data.get("validity_days"):
validity_text = f"{data['validity_days']} дней"
else:
validity_text = _("admin_promo_unlimited", default="Без ограничений")
success_lines.append(
_(
"admin_bulk_promo_settings",
default="🎁 Бонусные дни: <b>{bonus_days}</b>\n"
"📊 Макс. активаций: <b>{max_activations}</b>\n"
"⏰ Срок действия: <b>{validity}</b>",
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
validity=validity_text
)
)
# Create CSV file with promo codes if any were created
csv_file = None
if created_codes:
success_lines.append(f"\n🎟 <b>Создано {len(created_codes)} промокодов</b>")
success_lines.append("📄 CSV файл с промокодами отправлен отдельным сообщением")
# Create CSV file
output = io.StringIO()
writer = csv.writer(output)
# CSV headers
writer.writerow([
"Промокод", "Бонусные дни", "Макс. активации", "Действителен до",
"Команда для старта", "Ссылка для активации"
])
# Get real bot username
bot_username = 'your_bot' # fallback
try:
if hasattr(callback_or_message, 'message'):
bot = callback_or_message.message.bot
else:
bot = callback_or_message.bot
bot_info = await bot.get_me()
bot_username = bot_info.username or 'your_bot'
except Exception as e:
logging.error(f"Failed to get bot username for CSV links: {e}")
bot_username = 'your_bot'
for code in created_codes:
# Determine validity info
if data.get("validity_days"):
valid_until = (datetime.now(timezone.utc) + timedelta(days=data["validity_days"])).strftime("%Y-%m-%d %H:%M:%S")
else:
valid_until = "Без ограничений"
start_command = f"/start promo_{code}"
telegram_link = f"https://t.me/{bot_username}?start=promo_{code}"
writer.writerow([
code,
data["bonus_days"],
data["max_activations"],
valid_until,
start_command,
telegram_link
])
output.seek(0)
# Create file for sending
filename = f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
csv_file = types.BufferedInputFile(
output.getvalue().encode('utf-8-sig'), # BOM for correct Excel display
filename=filename
)
if failed_codes:
success_lines.append(f"\n❌ <b>Ошибки ({len(failed_codes)}):</b>")
for error in failed_codes[:5]: # Show first 5 errors
success_lines.append(error)
if len(failed_codes) > 5:
success_lines.append(f"... и еще {len(failed_codes) - 5} ошибок")
success_text = "\n".join(success_lines)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
message_obj = callback_or_message.message
except Exception:
message_obj = await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback_or_message.answer()
else: # Message
message_obj = await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
# Send CSV file if created
if csv_file:
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый"
await message_obj.answer_document(csv_file, caption=csv_caption)
await state.clear()
except Exception as e:
logging.error(f"Error creating bulk promo codes: {e}")
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
if hasattr(callback_or_message, 'message'): # CallbackQuery
await callback_or_message.message.answer(error_text)
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
# Cancel bulk promo creation
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_bulk_promo_quantity,
AdminStates.waiting_for_bulk_promo_bonus_days,
AdminStates.waiting_for_bulk_promo_max_activations,
AdminStates.waiting_for_bulk_promo_validity_days,
),
)
async def cancel_bulk_promo_creation_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer(_("admin_bulk_promo_creation_cancelled", default="Массовое создание промокодов отменено"))
await state.clear()
+436
View File
@@ -0,0 +1,436 @@
import logging
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import promo_code_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_create_router")
async def create_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing promo creation.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):"
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
except Exception as e:
logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
# Step 1: Process promo code
@router.message(AdminStates.waiting_for_promo_code, F.text)
async def process_promo_code_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
code_str = message.text.strip().upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
await message.answer(_(
"admin_promo_invalid_code_format",
default="❌ Код промокода должен содержать 3-30 символов (только буквы и цифры)"
))
return
# Check if code already exists
existing_promo = await promo_code_dal.get_promo_code_by_code(session, code_str)
if existing_promo:
await message.answer(_(
"admin_promo_code_already_exists",
default="❌ Промокод с таким кодом уже существует"
))
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_promo_step2_bonus_days",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
code=code_str
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 2: Process bonus days
@router.message(AdminStates.waiting_for_promo_bonus_days, F.text)
async def process_promo_bonus_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
bonus_days = int(message.text.strip())
if not (1 <= bonus_days <= 365):
await message.answer(_(
"admin_promo_invalid_bonus_days",
default="❌ Количество бонусных дней должно быть от 1 до 365"
))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Лимит активаций\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
code=data.get("promo_code"),
bonus_days=bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing promo bonus days: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
max_activations = int(message.text.strip())
if not (1 <= max_activations <= 10000):
await message.answer(_(
"admin_promo_invalid_max_activations",
default="❌ Максимальное количество активаций должно быть от 1 до 10000"
))
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_unlimited_validity", default="🔄 Без ограничений"),
callback_data="promo_unlimited_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_set_validity_days", default="📅 Указать дни"),
callback_data="promo_set_validity"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel", default="🔙 В админ панель"),
callback_data="admin_action:main"
)
)
await message.answer(
prompt_text,
reply_markup=builder.as_markup(),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_validity_days)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing promo max activations: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 4: Handle unlimited validity
@router.callback_query(F.data == "promo_unlimited_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
async def process_promo_unlimited_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
await state.update_data(validity_days=None)
await create_promo_code_final(callback, state, i18n_data, settings, session)
# Step 4: Handle set validity
@router.callback_query(F.data == "promo_set_validity", StateFilter(AdminStates.waiting_for_promo_validity_days))
async def process_promo_set_validity(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing validity.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
default="🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активаций: <b>{max_activations}</b>\n\nВведите количество дней действия промокода (1-365):",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
)
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
except Exception:
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
# Step 4: Process validity days
@router.message(AdminStates.waiting_for_promo_validity_days, F.text)
async def process_promo_validity_days_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
validity_days = int(message.text.strip())
if not (1 <= validity_days <= 365):
await message.answer(_(
"admin_promo_invalid_validity_days",
default="❌ Срок действия должен быть от 1 до 365 дней"
))
return
await state.update_data(validity_days=validity_days)
await create_promo_code_final(message, state, i18n_data, settings, session)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number",
default="❌ Введите корректное число"
))
except Exception as e:
logging.error(f"Error processing promo validity days: {e}")
await message.answer(_("error_occurred_try_again"))
async def create_promo_code_final(callback_or_message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
"""Final step - create the promo code in database"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
data = await state.get_data()
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"created_at": datetime.now(timezone.utc)
}
# Set validity
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
# Success message
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
success_text = _(
"admin_promo_created_success",
default="✅ <b>Промокод успешно создан!</b>\n\n"
"🎟 Код: <code>{code}</code>\n"
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
"📊 Макс. активаций: <b>{max_activations}</b>\n"
"⏰ Срок действия: <b>{valid_until_str}</b>",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
await callback_or_message.message.edit_text(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
except Exception:
await callback_or_message.message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(
success_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.clear()
except Exception as e:
logging.error(f"Error creating promo code: {e}")
error_text = _("error_occurred_try_again", default="❌ Произошла ошибка. Попробуйте снова.")
if hasattr(callback_or_message, 'message'): # CallbackQuery
await callback_or_message.message.answer(error_text)
await callback_or_message.answer()
else: # Message
await callback_or_message.answer(error_text)
await state.clear()
# Cancel promo creation
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
)
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
except Exception:
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
)
await callback.answer(_("admin_promo_creation_cancelled", default="Создание промокода отменено"))
await state.clear()
+439
View File
@@ -0,0 +1,439 @@
import logging
import csv
import io
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings, get_settings
from db.dal import promo_code_dal
from db.models import PromoCode, PromoCodeActivation
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="promo_manage_router")
def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_lang: str):
"""Determine promo code status and return emoji + text"""
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
return "", _("admin_promo_status_expired")
elif promo.current_activations >= promo.max_activations:
return "🔄", _("admin_promo_status_used_up")
elif promo.is_active:
return "", _("admin_promo_status_active")
else:
return "🚫", _("admin_promo_status_inactive")
async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str):
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return None, None
status_emoji, status = get_promo_status_emoji_and_text(promo, i18n, current_lang)
validity = _("admin_promo_valid_indefinitely")
if promo.valid_until:
validity = promo.valid_until.strftime("%d.%m.%Y %H:%M")
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
text = "\n".join([
_("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days),
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status),
_("admin_promo_card_created", created=created),
_("admin_promo_card_created_by", creator=promo.created_by_admin_id)
])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}:0"))
builder.row(InlineKeyboardButton(text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"))
return text, builder.as_markup()
async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
[_("admin_active_promos_list_header"), ""] + [
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
for p in promo_models
]
)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer()
async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession, page: int = 0):
current_lang = i18n_data.get("current_language", "ru")
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing request.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
page_size = 10 # Количество промокодов на странице
offset = page * page_size
# Получаем общее количество промокодов
total_count = await promo_code_dal.get_promo_codes_count(session)
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=page_size, offset=offset)
if not promo_models and page == 0:
await callback.message.edit_text(_("admin_promo_management_empty"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer()
return
builder = InlineKeyboardBuilder()
for promo in promo_models:
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"))
# Добавляем кнопки пагинации если есть больше одной страницы
if total_pages > 1:
pagination_buttons = []
if page > 0:
pagination_buttons.append(InlineKeyboardButton(text=_("prev_page_button"), callback_data=f"promo_management:{page-1}"))
if page < total_pages - 1:
pagination_buttons.append(InlineKeyboardButton(text=_("next_page_button"), callback_data=f"promo_management:{page+1}"))
if pagination_buttons:
builder.row(*pagination_buttons)
# Добавляем кнопки экспорта и возврата
builder.row(InlineKeyboardButton(text="📄 Экспорт CSV", callback_data="promo_export_all"))
builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main"))
# Формируем заголовок с информацией о страницах
title = _("admin_promo_management_title")
if total_pages > 1:
title += f"\n{_('admin_promo_list_page_info', current=page+1, total=total_pages, count=total_count)}"
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data.startswith("promo_management:"))
async def promo_management_pagination_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
try:
page = int(callback.data.split(":")[1])
await promo_management_handler(callback, i18n_data, settings, session, page)
except (ValueError, IndexError):
await callback.answer("Error processing pagination.", show_alert=True)
@router.callback_query(F.data.startswith("promo_detail:"))
async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
await callback.answer("Error processing request.", show_alert=True)
return
try:
promo_id = int(callback.data.split(":")[1])
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data.startswith("promo_toggle:"))
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
new_status = not promo.is_active
if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
await session.commit()
status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated")
await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text))
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
if text:
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
else:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
@router.callback_query(F.data.startswith("promo_activations:"))
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
parts = callback.data.split(":")
promo_id = int(parts[1])
page = int(parts[2])
page_size = settings.LOGS_PAGE_SIZE
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
total_activations = await promo_code_dal.count_promo_activations_by_code_id(session, promo_id)
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id, limit=page_size, offset=page * page_size)
builder = InlineKeyboardBuilder()
if not activations:
text = _("admin_promo_no_activations", code=promo.code)
else:
text = _("admin_promo_activations_header", code=promo.code) + "\n\n"
text += "\n".join([_("admin_promo_activation_item", user_id=a.user_id, date=a.activated_at.strftime("%d.%m.%Y %H:%M")) for a in activations])
nav_buttons = []
if page > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"promo_activations:{promo_id}:{page-1}"))
if (page + 1) * page_size < total_activations:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"promo_activations:{promo_id}:{page+1}"))
if nav_buttons:
builder.row(*nav_buttons)
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data.startswith("promo_export:"))
async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
if not activations:
return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["User ID", "Activation Date"])
for act in activations:
writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
output.seek(0)
file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv")
await callback.message.answer_document(file, caption=_("admin_promo_export_caption", code=promo.code))
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
await callback.answer()
@router.callback_query(F.data == "promo_export_all")
async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Error processing request.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.answer("📄 Создаю CSV файл...", show_alert=True)
# Получаем все промокоды
all_promos = await promo_code_dal.get_all_promo_codes_with_details(session, limit=10000, offset=0)
output = io.StringIO()
writer = csv.writer(output)
# Заголовки CSV
writer.writerow([
"Код", "Бонусные дни", "Максимальные активации", "Текущие активации",
"Статус", "Активен", "Действителен до", "Создан", "Создал (Admin ID)"
])
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
# Формируем данные для CSV
row = [
promo.code,
promo.bonus_days,
promo.max_activations,
promo.current_activations,
status_text,
"Да" if promo.is_active else "Нет",
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S") if promo.valid_until else "Без ограничений",
promo.created_at.strftime("%Y-%m-%d %H:%M:%S") if promo.created_at else "N/A",
promo.created_by_admin_id or "N/A"
]
writer.writerow(row)
output.seek(0)
# Создаем файл для отправки
filename = f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
file = types.BufferedInputFile(
output.getvalue().encode('utf-8-sig'), # BOM для корректного отображения в Excel
filename=filename
)
caption = f"📄 Экспорт всех промокодов\n📊 Всего: {len(all_promos)} промокодов"
await callback.message.answer_document(file, caption=caption)
except Exception as e:
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return await callback.answer("Language service error.", show_alert=True)
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.delete_promo_code(session, promo_id)
if promo:
await session.commit()
await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
await promo_management_handler(callback, i18n_data, settings, session, 0)
else:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
except (ValueError, IndexError):
await callback.answer(_("admin_promo_not_found"), show_alert=True)
# --- Promo Edit Handlers ---
@router.callback_query(F.data.startswith("promo_edit_select:"))
async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1])
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer()
@router.callback_query(F.data.startswith("promo_edit_field:"))
async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not callback.message or not current_lang: return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days"
}
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
async def process_promo_edit_details(message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language")
if not i18n or not message or not current_lang: return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
promo_id = data.get("promo_id")
field = data.get("field_to_edit")
try:
value = message.text
update_data = {}
if field == "bonus_days":
update_data["bonus_days"] = int(value)
elif field == "max_activations":
update_data["max_activations"] = int(value)
elif field == "valid_until":
if value.lower() in ['0', 'вечно', 'бессрочно', 'indefinite']:
update_data["valid_until"] = None
else:
days = int(value)
update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days)
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
if text:
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
else:
await message.answer(_("error_occurred_try_again"))
await state.clear()
except (ValueError, TypeError):
await message.answer(_("admin_promo_invalid_input"))
# Don't clear state, let them try again
async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
await promo_management_handler(callback, i18n_data, settings, session)
-367
View File
@@ -1,367 +0,0 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import promo_code_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_promo_codes_router")
async def create_promo_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error preparing promo creation.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
prompt_text = _("admin_promo_create_prompt",
example_format="MYPROMO20 7 100 30")
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
except Exception as e:
logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.")
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_details)
@router.message(AdminStates.waiting_for_promo_details, F.text)
async def process_promo_code_details_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not message.text:
await message.answer(_("admin_promo_invalid_format"))
return
parts = message.text.strip().split()
if not (3 <= len(parts) <= 4):
await message.answer(_("admin_promo_invalid_format"))
return
try:
code_str = parts[0].upper()
if not (3 <= len(code_str) <= 30 and code_str.isalnum()):
raise ValueError(_("admin_promo_invalid_code_format"))
bonus_days = int(parts[1])
max_activations = int(parts[2])
valid_until_date: Optional[datetime] = None
valid_until_str_display = _("admin_promo_valid_indefinitely")
if len(parts) == 4:
valid_days_from_now = int(parts[3])
if valid_days_from_now <= 0:
raise ValueError(_("admin_promo_invalid_validity_days"))
valid_until_date = datetime.now(
timezone.utc) + timedelta(days=valid_days_from_now)
valid_until_str_display = _(
"admin_promo_valid_until_display",
date=valid_until_date.strftime('%Y-%m-%d'))
if bonus_days <= 0 or max_activations <= 0:
raise ValueError(_("admin_promo_invalid_bonus_or_activations"))
except ValueError as e:
await message.answer(_("admin_promo_invalid_values", error=str(e)))
return
except Exception as e_parse:
logging.error(
f"Error parsing promo details '{message.text}': {e_parse}")
await message.answer(_("admin_promo_invalid_format_general"))
return
admin_id = message.from_user.id if message.from_user else 0
promo_data_to_create = {
"code": code_str,
"bonus_days": bonus_days,
"max_activations": max_activations,
"created_by_admin_id": admin_id,
"valid_until": valid_until_date,
"is_active": True,
"current_activations": 0
}
try:
created_promo = await promo_code_dal.create_promo_code(
session, promo_data_to_create)
await session.commit()
if created_promo:
success_text = _("admin_promo_created_success",
code=created_promo.code,
bonus_days=created_promo.bonus_days,
max_activations=created_promo.max_activations,
valid_until_str=valid_until_str_display)
await message.answer(success_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
else:
await message.answer(
_("admin_promo_creation_failed_duplicate", code=code_str))
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Failed to create promo code '{code_str}' in DB: {e_db_create}",
exc_info=True)
await message.answer(_("admin_promo_creation_failed"))
await state.clear()
async def view_promo_codes_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying promo codes.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
limit=20,
offset=0)
if not promo_models:
await callback.message.edit_text(
_("admin_no_active_promos"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
return
response_text_parts = [f"<b>{_('admin_active_promos_list_header')}</b>\n"]
for promo in promo_models:
valid_until_display_text = _("admin_promo_valid_indefinitely")
if promo.valid_until:
valid_until_display_text = promo.valid_until.strftime('%Y-%m-%d')
response_text_parts.append(
_("admin_promo_list_item",
code=promo.code,
bonus=promo.bonus_days,
current=promo.current_activations,
max=promo.max_activations,
valid_until=valid_until_display_text))
final_text = "\n".join(response_text_parts)
try:
await callback.message.edit_text(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
except Exception as e:
logging.warning(
f"Failed to edit message for promo list: {e}. Sending new.")
if callback.message:
await callback.message.answer(
final_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n),
parse_mode="HTML")
await callback.answer()
@router.callback_query(F.data == "admin_action:manage_promos")
async def manage_promo_codes_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying promo codes.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
limit=20,
offset=0)
if not promo_models:
await callback.message.edit_text(
_("admin_no_active_promos"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
return
kb = InlineKeyboardBuilder()
for promo in promo_models:
kb.row(
InlineKeyboardButton(
text=promo.code,
callback_data=f"promo_edit:{promo.promo_code_id}"),
InlineKeyboardButton(
text=_("admin_promo_delete_button"),
callback_data=f"promo_delete:{promo.promo_code_id}"),
)
kb.row(
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
callback_data="admin_action:main"))
await callback.message.edit_text(
_("admin_manage_promos_title"),
reply_markup=kb.as_markup())
await callback.answer()
@router.callback_query(F.data.startswith("promo_edit:"))
async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext,
i18n_data: dict, settings: Settings,
session: AsyncSession):
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo or not callback.message:
await callback.answer("Error", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await state.update_data(edit_promo_id=promo_id)
await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(
_("admin_promo_edit_prompt", code=promo.code),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
await callback.answer()
@router.message(AdminStates.waiting_for_promo_edit_details, F.text)
async def process_promo_edit_details(message: types.Message, state: FSMContext,
i18n_data: dict, settings: Settings,
session: AsyncSession):
data = await state.get_data()
promo_id = data.get("edit_promo_id")
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not promo_id:
await message.answer("Error")
await state.clear()
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
parts = message.text.strip().split()
if not (3 <= len(parts) <= 4):
await message.answer(_("admin_promo_invalid_format"))
return
try:
code_str = parts[0].upper()
bonus = int(parts[1])
max_act = int(parts[2])
valid_until = None
if len(parts) == 4:
days = int(parts[3])
valid_until = datetime.now(timezone.utc) + timedelta(days=days)
except Exception:
await message.answer(_("admin_promo_invalid_format_general"))
return
update_data = {
"code": code_str,
"bonus_days": bonus,
"max_activations": max_act,
"valid_until": valid_until,
}
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
if updated:
await session.commit()
await message.answer(
_("admin_promo_updated_success", code=code_str),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML",
)
else:
await session.rollback()
await message.answer(_("admin_promo_not_found"))
await state.clear()
@router.callback_query(F.data.startswith("promo_delete:"))
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
settings: Settings, session: AsyncSession):
promo_id = int(callback.data.split(":")[1])
promo = await promo_code_dal.delete_promo_code(session, promo_id)
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if promo:
await session.commit()
await callback.message.edit_text(
_("admin_promo_deleted_success", code=promo.code),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML")
else:
await session.rollback()
await callback.message.edit_text(
_("admin_promo_not_found"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
await callback.answer()
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_details,
AdminStates.waiting_for_promo_edit_details,
),
)
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error cancelling.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
await callback.message.edit_text(_("admin_action_cancelled_default"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang, settings))
except Exception:
await callback.message.answer(_("admin_action_cancelled_default"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang, settings))
await callback.answer(_("admin_action_cancelled_default_alert"))
await state.clear()
+125 -9
View File
@@ -8,6 +8,7 @@ from config.settings import Settings
from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
@@ -29,13 +30,130 @@ async def show_statistics_handler(callback: types.CallbackQuery,
stats_text_parts = [f"<b>{_('admin_stats_header')}</b>"]
user_stats_dict = await user_dal.get_user_count_stats_dal(session)
# Enhanced user statistics
user_stats = await user_dal.get_enhanced_user_statistics(session)
stats_text_parts.append(
_("admin_stats_users",
total_users=user_stats_dict.get("total_users", 0),
banned_users=user_stats_dict.get("banned_users", 0),
active_subs=user_stats_dict.get("users_with_active_subscriptions",
0)))
f"\n<b>👥 {_('admin_enhanced_users_stats_header', default='Пользователи')}</b>"
)
stats_text_parts.append(
f"📊 {_('admin_user_stats_total_label', default='Всего')}: <b>{user_stats['total_users']}</b>"
)
# Removed: Active today moved to panel stats
stats_text_parts.append(
f"💳 {_('admin_user_stats_paid_subs_label', default='С платной подпиской')}: <b>{user_stats['paid_subscriptions']}</b>"
)
stats_text_parts.append(
f"🆓 {_('admin_user_stats_trial_label', default='На пробном периоде')}: <b>{user_stats['trial_users']}</b>"
)
stats_text_parts.append(
f"😴 {_('admin_user_stats_inactive_label', default='Неактивных')}: <b>{user_stats['inactive_users']}</b>"
)
stats_text_parts.append(
f"🚫 {_('admin_user_stats_banned_label', default='Заблокированных')}: <b>{user_stats['banned_users']}</b>"
)
stats_text_parts.append(
f"🎁 {_('admin_user_stats_referral_label', default='Привлечено по реферальной программе')}: <b>{user_stats['referral_users']}</b>"
)
# Panel Statistics - moved above financial
stats_text_parts.append(f"\n<b>🖥 {_('admin_panel_stats_header', default='Статистика панели')}</b>")
try:
async with PanelApiService(settings) as panel_service:
# Get system stats
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
logging.info(f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}")
if system_stats:
users = system_stats.get('users', {})
status_counts = users.get('statusCounts', {})
online_stats = system_stats.get('onlineStats', {})
active_users = status_counts.get('ACTIVE', 0)
disabled_users = status_counts.get('DISABLED', 0)
expired_users = status_counts.get('EXPIRED', 0)
limited_users = status_counts.get('LIMITED', 0)
total_users = users.get('totalUsers', 0)
online_now = online_stats.get('onlineNow', 0)
stats_text_parts.append(f"🟢 {_('admin_panel_online_label', default='Онлайн')}: <b>{online_now}</b>")
stats_text_parts.append(f"📊 {_('admin_panel_active_label', default='Активных')}: <b>{active_users}</b>")
stats_text_parts.append(f"🔴 {_('admin_panel_disabled_label', default='Отключенных')}: <b>{disabled_users}</b>")
stats_text_parts.append(f"{_('admin_panel_expired_label', default='Истекшие')}: <b>{expired_users}</b>")
stats_text_parts.append(f"⚠️ {_('admin_panel_limited_label', default='Ограниченные')}: <b>{limited_users}</b>")
stats_text_parts.append(f"👥 {_('admin_panel_total_users_label', default='Всего пользователей')}: <b>{total_users}</b>")
# System resources
memory = system_stats.get('memory', {})
if memory:
memory_total = memory.get('total', 1)
memory_used = memory.get('used', 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
stats_text_parts.append(f"💾 {_('admin_panel_memory_usage_label', default='Использование RAM')}: <b>{memory_usage:.1f}%</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_system_stats_error', default='Ошибка получения системной статистики')}")
# Bandwidth stats
if bandwidth_stats:
week_traffic = bandwidth_stats.get('bandwidthLastSevenDays', {})
month_traffic = bandwidth_stats.get('bandwidthLast30Days', {})
# Fallback to the actual key name from API if the above doesn't exist
if not month_traffic:
month_traffic = bandwidth_stats.get('bandwidthLastThirtyDays', {})
if week_traffic:
week_total = week_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_week_label', default='Трафик за неделю')}: <b>{week_total}</b>")
if month_traffic:
month_total = month_traffic.get('current', '0 B')
stats_text_parts.append(f"📊 {_('admin_panel_traffic_month_label', default='Трафик за месяц')}: <b>{month_total}</b>")
else:
stats_text_parts.append(f"⚠️ {_('admin_panel_bandwidth_stats_error', default='Ошибка получения статистики трафика')}")
# Nodes stats
if nodes_stats and 'lastSevenDays' in nodes_stats:
last_seven_days = nodes_stats.get('lastSevenDays', [])
# Get unique node names from the data
unique_nodes = set()
for node_data in last_seven_days:
unique_nodes.add(node_data.get('nodeName', ''))
total_nodes_count = len(unique_nodes)
# Assume all nodes are active since we don't have status info
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_nodes_count}/{total_nodes_count}</b>")
else:
# Use nodes total from system stats as fallback
nodes_info = system_stats.get('nodes', {}) if system_stats else {}
total_online = nodes_info.get('totalOnline', 0)
stats_text_parts.append(f"🔗 {_('admin_panel_nodes_label', default='Активных нод')}: <b>{total_online}</b>")
except Exception as e:
logging.error(f"Failed to fetch panel statistics: {e}", exc_info=True)
stats_text_parts.append(f"{_('admin_panel_stats_fetch_error', default='Ошибка получения данных с панели')}")
stats_text_parts.append(f"⚠️ {_('admin_panel_stats_error_details', default='Детали')}: {str(e)}")
# Financial statistics
financial_stats = await payment_dal.get_financial_statistics(session)
stats_text_parts.append(
f"\n<b>💰 {_('admin_financial_stats_header', default='Финансовая статистика')}</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_today_label', default='За сегодня')}: <b>{financial_stats['today_revenue']:.2f} RUB</b> ({financial_stats['today_payments_count']} {_('admin_financial_payments_label', default='платежей')})"
)
stats_text_parts.append(
f"📅 {_('admin_financial_week_label', default='За неделю')}: <b>{financial_stats['week_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"📅 {_('admin_financial_month_label', default='За месяц')}: <b>{financial_stats['month_revenue']:.2f} RUB</b>"
)
stats_text_parts.append(
f"🏆 {_('admin_financial_all_time_label', default='За все время')}: <b>{financial_stats['all_time_revenue']:.2f} RUB</b>"
)
last_payments_models: List[
Payment] = await payment_dal.get_recent_payment_logs_with_user(session,
@@ -79,9 +197,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
details_val = sync_status_model.details
details_str = (details_val[:100] +
"...") if details_val and len(details_val) > 100 else (
details_val or "N/A")
details_str = details_val or "N/A"
stats_text_parts.append(
f" {_('admin_stats_sync_time')}: {sync_time_str}")
+296 -263
View File
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from config.settings import Settings
from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import NotificationService
from db.dal import user_dal, subscription_dal, panel_sync_dal
@@ -15,6 +16,262 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router")
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
settings: Settings, i18n_instance: JsonI18n) -> dict:
"""
Perform panel synchronization and return results
Returns dict with status, details, and sync statistics
"""
panel_records_checked = 0
users_found_in_db = 0
users_updated = 0
subscriptions_synced_count = 0
sync_errors = []
# Additional counters for detailed logging
users_without_telegram_id = 0
users_not_found_in_db = 0
users_uuid_updated = 0
subscriptions_created = 0
subscriptions_updated = 0
try:
panel_users_data = await panel_service.get_all_panel_users()
if panel_users_data is None:
error_msg = "Failed to fetch users from panel or panel API issue."
sync_errors.append(error_msg)
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
await session.commit()
return {"status": "failed", "details": error_msg, "errors": sync_errors}
if not panel_users_data:
status_msg = "No users found in the panel to sync."
await panel_sync_dal.update_panel_sync_status(
session, "success", status_msg, 0, 0
)
await session.commit()
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
for panel_user_dict in panel_users_data:
try:
panel_records_checked += 1
panel_uuid = panel_user_dict.get("uuid")
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
telegram_id_from_panel = panel_user_dict.get("telegramId")
if not panel_uuid:
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
continue
# Track users without telegram ID
if not telegram_id_from_panel:
users_without_telegram_id += 1
# Try to find existing user in local DB
existing_user = None
# First, try to find by telegram ID if available
if telegram_id_from_panel:
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
if existing_user:
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
# If not found by telegram ID, try to find by panel UUID
if not existing_user:
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
if existing_user:
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
# Update telegram ID if it was missing in panel data but we have local user
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
if not existing_user:
users_not_found_in_db += 1
if telegram_id_from_panel:
logging.debug(f"Panel user with telegramId {telegram_id_from_panel} and UUID {panel_uuid} not found in local DB")
else:
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB")
continue
# User found in local DB
users_found_in_db += 1
user_was_updated = False
# Get the actual user_id for subscription operations
actual_user_id = existing_user.user_id
# Update panel UUID if different
if existing_user.panel_user_uuid != panel_uuid:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
users_uuid_updated += 1
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
# Sync subscription data
panel_expire_at_iso = panel_user_dict.get("expireAt")
panel_status = panel_user_dict.get("status", "UNKNOWN")
if panel_expire_at_iso:
try:
panel_expire_at = datetime.fromisoformat(
panel_expire_at_iso.replace("Z", "+00:00")
)
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
subscription_uuid_from_panel = (
panel_user_dict.get("subscriptionUuid")
or panel_user_dict.get("shortUuid")
)
if subscription_uuid_from_panel:
# Try to find subscription by its panel_subscription_uuid first (idempotent)
existing_sub_by_uuid = (
await subscription_dal.get_subscription_by_panel_subscription_uuid(
session, subscription_uuid_from_panel
)
)
if existing_sub_by_uuid:
# Atomic update of all relevant fields
await subscription_dal.update_subscription(
session,
existing_sub_by_uuid.subscription_id,
{
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
)
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.info(
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else:
# Create a new subscription only when we have a concrete subscription UUID
sub_payload = {
"user_id": actual_user_id,
"panel_user_uuid": panel_uuid,
"panel_subscription_uuid": subscription_uuid_from_panel,
# Do not guess precise start_date from panel; keep nullable
"start_date": None,
"end_date": panel_expire_at,
"duration_months": None,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
}
created_sub = await subscription_dal.upsert_subscription(
session, sub_payload
)
subscriptions_synced_count += 1
subscriptions_created += 1
user_was_updated = True
logging.info(
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
)
else:
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, actual_user_id, panel_uuid
)
if active_sub:
await subscription_dal.update_subscription(
session,
active_sub.subscription_id,
{
"end_date": panel_expire_at,
"is_active": panel_status == "ACTIVE",
"status_from_panel": panel_status,
},
)
subscriptions_synced_count += 1
subscriptions_updated += 1
user_was_updated = True
logging.info(
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
)
else:
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
logging.debug(
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
)
except Exception as e:
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
if user_was_updated:
users_updated += 1
except Exception as e_user:
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
logging.error(f"Error syncing user: {e_user}")
# Update sync status
status = "completed_with_errors" if sync_errors else "completed"
details = (f"📊 Статистика синхронизации:\n"
f"🔍 Проверено записей панели: {panel_records_checked}\n"
f"👥 Найдено пользователей в БД: {users_found_in_db}\n"
f"🔄 Пользователей обновлено: {users_updated}\n"
f"📋 Подписок синхронизировано: {subscriptions_synced_count}\n"
f" ├── Создано новых: {subscriptions_created}\n"
f" └── Обновлено существующих: {subscriptions_updated}")
if users_without_telegram_id > 0:
details += f"\n⚠️ Записей без telegramId: {users_without_telegram_id}"
if users_not_found_in_db > 0:
details += f"\n❌ Не найдено в БД: {users_not_found_in_db}"
if sync_errors:
details += f"\n🚫 Ошибок: {len(sync_errors)}"
await panel_sync_dal.update_panel_sync_status(
session, status, details, panel_records_checked, subscriptions_synced_count
)
await session.commit()
# Detailed logging summary
logging.info(f"Sync completed - Summary:")
logging.info(f" Panel records checked: {panel_records_checked}")
logging.info(f" Users without telegramId: {users_without_telegram_id}")
logging.info(f" Users not found in local DB: {users_not_found_in_db}")
logging.info(f" Users found in local DB: {users_found_in_db}")
logging.info(f" Users with UUID updated: {users_uuid_updated}")
logging.info(f" Users updated overall: {users_updated}")
logging.info(f" Subscriptions total synced: {subscriptions_synced_count}")
logging.info(f" Subscriptions created: {subscriptions_created}")
logging.info(f" Subscriptions updated: {subscriptions_updated}")
logging.info(f" Sync errors: {len(sync_errors)}")
return {
"status": status,
"details": details,
"users_processed": panel_records_checked,
"users_synced": users_found_in_db,
"subs_synced": subscriptions_synced_count,
"errors": sync_errors
}
except Exception as e_sync_global:
await session.rollback()
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
await panel_sync_dal.update_panel_sync_status(
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
)
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
@router.message(Command("sync"))
async def sync_command_handler(
message_event: Union[types.Message, types.CallbackQuery],
@@ -48,269 +305,49 @@ async def sync_command_handler(
return
if isinstance(message_event, types.Message):
await message_event.answer(_("sync_started"))
await message_event.answer(_("sync_started_simple"))
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
users_processed_count = 0
users_synced_successfully = 0
subscriptions_synced_count = 0
sync_errors = []
# Use the extracted perform_sync function
try:
panel_users_data = await panel_service.get_all_panel_users()
if panel_users_data is None:
error_msg = "Failed to fetch users from panel or panel API issue."
sync_errors.append(error_msg)
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
await session.commit()
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
return
if not panel_users_data:
status_msg = "No users found in the panel to sync."
await panel_sync_dal.update_panel_sync_status(
session, "success", status_msg, 0, 0
)
await session.commit()
await bot.send_message(
target_chat_id,
_("sync_completed", status="Success", details=status_msg),
)
return
total_panel_users = len(panel_users_data)
logging.info(f"Starting sync for {total_panel_users} panel users.")
for panel_user_dict in panel_users_data:
users_processed_count += 1
panel_uuid = panel_user_dict.get("uuid")
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
panel_username = panel_user_dict.get("username")
if not panel_uuid:
logging.warning(
f"Sync: Panel user data missing 'uuid'. Data: {str(panel_user_dict)[:200]}. Skipping."
)
sync_errors.append(
f"Panel user data (username: {panel_username or 'N/A'}) missing UUID."
)
continue
telegram_id_from_panel: Optional[int] = None
if telegram_id_from_panel_str:
try:
telegram_id_from_panel = int(telegram_id_from_panel_str)
except ValueError:
logging.warning(
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has invalid 'telegramId': {telegram_id_from_panel_str}. Skipping TG ID based sync."
)
if not telegram_id_from_panel:
logging.info(
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no valid 'telegramId'. Skipping full sync for this user."
)
continue
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
if not bot_user:
user_data_to_create = {
"user_id": telegram_id_from_panel,
"username": panel_username,
"panel_user_uuid": panel_uuid,
"language_code": settings.DEFAULT_LANGUAGE,
"registration_date": (
datetime.fromisoformat(
panel_user_dict["createdAt"].replace("Z", "+00:00")
)
if panel_user_dict.get("createdAt")
else datetime.now(timezone.utc)
),
}
bot_user = await user_dal.create_user(session, user_data_to_create)
logging.info(
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
)
else:
if bot_user.panel_user_uuid != panel_uuid:
if bot_user.panel_user_uuid is not None:
logging.warning(
f"Sync: Local user {telegram_id_from_panel} was linked to {bot_user.panel_user_uuid}, panel now gives {panel_uuid}. Updating."
)
conflicting_user = await user_dal.get_user_by_panel_uuid(
session, panel_uuid
)
if (
conflicting_user
and conflicting_user.user_id != telegram_id_from_panel
):
sync_errors.append(
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
)
logging.error(sync_errors[-1])
continue
await user_dal.update_user(
session,
telegram_id_from_panel,
{"panel_user_uuid": panel_uuid, "username": panel_username},
)
logging.info(
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
)
panel_sub_link_id = panel_user_dict.get(
"subscriptionUuid"
) or panel_user_dict.get("shortUuid")
if panel_sub_link_id:
end_date_str = panel_user_dict.get("expireAt")
start_date_str = panel_user_dict.get("createdAt")
if end_date_str:
try:
end_date_obj = datetime.fromisoformat(
end_date_str.replace("Z", "+00:00")
)
start_date_obj = (
datetime.fromisoformat(
start_date_str.replace("Z", "+00:00")
)
if start_date_str
else datetime.now(timezone.utc)
)
status_from_panel = panel_user_dict.get(
"status", "UNKNOWN"
).upper()
is_active_flag = (
1
if status_from_panel == "ACTIVE"
and end_date_obj > datetime.now(timezone.utc)
else 0
)
sub_payload = {
"user_id": telegram_id_from_panel,
"panel_user_uuid": panel_uuid,
"panel_subscription_uuid": panel_sub_link_id,
"start_date": start_date_obj,
"end_date": end_date_obj,
"is_active": is_active_flag,
"status_from_panel": status_from_panel,
"traffic_limit_bytes": panel_user_dict.get(
"trafficLimitBytes"
),
"traffic_used_bytes": panel_user_dict.get(
"usedTrafficBytes"
),
}
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_link_id
)
await subscription_dal.upsert_subscription(session, sub_payload)
subscriptions_synced_count += 1
users_synced_successfully += 1
except ValueError as e_date:
logging.warning(
f"Sync: Bad date format for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}). Sub data: {str(panel_user_dict)[:100]}. Error: {e_date}"
)
sync_errors.append(
f"Bad date for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
)
except Exception as e_sub_sync:
logging.error(
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
exc_info=True,
)
sync_errors.append(
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
)
else:
logging.warning(
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has sub link but no expireAt date. Skipping subscription sync."
)
else:
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, None
)
logging.info(
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
)
users_synced_successfully += 1
if users_processed_count % 20 == 0:
logging.info(
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
)
panel_uuid_set = {u.get("uuid") for u in panel_users_data if u.get("uuid")}
local_users_with_uuid = await user_dal.get_all_users_with_panel_uuid(session)
for local_user in local_users_with_uuid:
if local_user.panel_user_uuid not in panel_uuid_set:
await subscription_dal.deactivate_other_active_subscriptions(
session, local_user.panel_user_uuid, None
)
logging.info(
f"Sync: Local user {local_user.user_id} with panel UUID {local_user.panel_user_uuid} not found on panel. Deactivated local subs."
)
status_msg_key = "sync_completed_details"
final_status_type = "success"
if sync_errors:
final_status_type = "partial_success"
status_msg_key = "sync_completed_with_errors_details"
error_preview = "\n".join(sync_errors[:3])
details_for_db = f"Users processed: {users_processed_count}. Subs synced: {subscriptions_synced_count}. Errors: {len(sync_errors)}. First few: {error_preview}"
sync_result = await perform_sync(panel_service, session, settings, i18n)
status = sync_result.get("status")
details = sync_result.get("details", "No details available")
errors = sync_result.get("errors", [])
# Simple confirmation message to admin
if status == "failed":
await bot.send_message(target_chat_id, _("sync_failed_simple"))
elif status == "completed_with_errors":
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
else:
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
await panel_sync_dal.update_panel_sync_status(
session,
final_status_type,
details_for_db,
users_processed_count,
subscriptions_synced_count,
)
await session.commit()
final_user_message = _(
status_msg_key,
total_checked=total_panel_users,
users_synced=users_synced_successfully,
subs_synced=subscriptions_synced_count,
errors_count=len(sync_errors),
error_details_preview=(
error_preview if sync_errors else _("no_errors_placeholder")
),
)
await bot.send_message(target_chat_id, final_user_message)
await bot.send_message(target_chat_id, _("sync_success_simple"))
# Send notification to log channel with proper thread handling
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync(
status, details,
sync_result.get("users_processed", 0),
sync_result.get("subs_synced", 0)
)
except Exception as e_notification:
logging.error(f"Failed to send sync notification: {e_notification}")
except Exception as e_sync_global:
await session.rollback()
logging.error(
f"Global error during /sync command: {e_sync_global}", exc_info=True
)
error_detail_for_db = (
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
)
await panel_sync_dal.update_panel_sync_status(
session,
"failed",
error_detail_for_db,
users_processed_count,
subscriptions_synced_count,
)
await bot.send_message(
target_chat_id, _("sync_failed", details=error_detail_for_db)
)
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
await bot.send_message(target_chat_id, _("sync_critical_error"))
# Send notification to log channel about failure
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_panel_sync(
"failed", str(e_sync_global), 0, 0
)
except Exception as e_notification:
logging.error(f"Failed to send sync failure notification: {e_notification}")
@router.message(Command("syncstatus"))
@@ -333,11 +370,7 @@ async def sync_status_command_handler(
)
details_val = status_record_model.details
details_str = (
(details_val[:200] + "...")
if details_val and len(details_val) > 200
else (details_val or "N/A")
)
details_str = details_val or "N/A"
response_text = (
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
@@ -350,4 +383,4 @@ async def sync_status_command_handler(
else:
response_text = _("admin_sync_status_never_run")
await message.answer(response_text, parse_mode="HTML")
await message.answer(response_text, parse_mode="HTML")
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
import logging
from aiogram import Router, types, Bot
from aiogram.types import InlineQuery, InlineQueryResultArticle, InputTextMessageContent
from typing import List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal, payment_dal
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="inline_mode_router")
@router.inline_query()
async def inline_query_handler(inline_query: InlineQuery,
settings: Settings,
i18n_data: dict,
referral_service: ReferralService,
bot: Bot,
session: AsyncSession):
"""Handle inline queries for referral links and admin statistics"""
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
user_id = inline_query.from_user.id
query = inline_query.query.lower().strip()
results: List[InlineQueryResultArticle] = []
# Check if user is admin
is_admin = user_id in settings.ADMIN_IDS if settings.ADMIN_IDS else False
try:
# For all users: referral functionality
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
referral_result = await create_referral_result(
inline_query, bot, referral_service, i18n, current_lang, settings
)
if referral_result:
results.append(referral_result)
# For admins: statistics
if is_admin and (not query or "стат" in query or "stat" in query or "админ" in query or "admin" in query):
stats_results = await create_admin_stats_results(
session, i18n, current_lang, settings
)
results.extend(stats_results)
# Limit results to 50 (Telegram limit)
results = results[:50]
await inline_query.answer(
results=results,
cache_time=30, # Cache for 30 seconds
is_personal=True # Results are personalized
)
except Exception as e:
logging.error(f"Error handling inline query from user {user_id}: {e}")
# Send empty results in case of error
await inline_query.answer(results=[], cache_time=10)
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
referral_service: ReferralService,
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
"""Create referral link result for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
return None
user_id = inline_query.from_user.id
referral_link = referral_service.generate_referral_link(bot_username, user_id)
# Create message content (use same text as friend message)
message_text = _(
"referral_friend_message",
default="🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n"
"🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
referral_link=referral_link
)
return InlineQueryResultArticle(
id="referral_link",
title=_(
"inline_referral_title",
default="🎁 Пригласить друга"
),
description=_(
"inline_referral_description",
default="Поделиться реферальной ссылкой для получения бонусов"
),
input_message_content=InputTextMessageContent(
message_text=message_text,
disable_web_page_preview=True
),
thumbnail_url=settings.INLINE_REFERRAL_THUMBNAIL_URL
)
except Exception as e:
logging.error(f"Error creating referral result: {e}")
return None
async def create_admin_stats_results(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> List[InlineQueryResultArticle]:
"""Create admin statistics results for inline query"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
results = []
try:
# Quick user stats
user_stats_result = await create_user_stats_result(session, i18n_instance, lang, settings)
if user_stats_result:
results.append(user_stats_result)
# Quick financial stats
financial_stats_result = await create_financial_stats_result(session, i18n_instance, lang, settings)
if financial_stats_result:
results.append(financial_stats_result)
# Quick system stats
system_stats_result = await create_system_stats_result(session, i18n_instance, lang, settings)
if system_stats_result:
results.append(system_stats_result)
except Exception as e:
logging.error(f"Error creating admin stats results: {e}")
return results
async def create_user_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
"""Create user statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.user_dal import get_enhanced_user_statistics
user_stats = await get_enhanced_user_statistics(session)
stats_text = _(
"inline_user_stats_message",
default="📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n"
"📊 Всего: <b>{total}</b>\n"
"💳 С платной подпиской: <b>{paid}</b>\n"
"🆓 На пробном периоде: <b>{trial}</b>\n"
"😴 Неактивных: <b>{inactive}</b>\n"
"🚫 Заблокированных: <b>{banned}</b>\n"
"🎁 Привлечено по реферальной программе: <b>{referral}</b>",
total=user_stats['total_users'],
paid=user_stats['paid_subscriptions'],
trial=user_stats['trial_users'],
inactive=user_stats['inactive_users'],
banned=user_stats['banned_users'],
referral=user_stats['referral_users']
)
return InlineQueryResultArticle(
id="admin_user_stats",
title=_(
"inline_admin_user_stats_title",
default="📊 Статистика пользователей"
),
description=_(
"inline_user_stats_description",
default="Всего: {total}, Платных: {active}",
total=user_stats['total_users'],
active=user_stats['paid_subscriptions']
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
),
thumbnail_url=settings.INLINE_USER_STATS_THUMBNAIL_URL
)
except Exception as e:
logging.error(f"Error creating user stats result: {e}")
return None
async def create_financial_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
"""Create financial statistics result"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from db.dal.payment_dal import get_financial_statistics
financial_stats = await get_financial_statistics(session)
stats_text = _(
"inline_financial_stats_message",
default="💰 <b>Финансовая статистика</b>\n\n"
"📅 За сегодня: <b>{today:.2f} RUB</b>\n"
" ({today_count} платежей)\n"
"📅 За неделю: <b>{week:.2f} RUB</b>\n"
"📅 За месяц: <b>{month:.2f} RUB</b>\n"
"🏆 За все время: <b>{all_time:.2f} RUB</b>",
today=financial_stats['today_revenue'],
today_count=financial_stats['today_payments_count'],
week=financial_stats['week_revenue'],
month=financial_stats['month_revenue'],
all_time=financial_stats['all_time_revenue']
)
return InlineQueryResultArticle(
id="admin_financial_stats",
title=_(
"inline_admin_financial_stats_title",
default="💰 Финансовая статистика"
),
description=_(
"inline_financial_description",
default="Сегодня: {today} RUB",
today=f"{financial_stats['today_revenue']:.2f}"
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
),
thumbnail_url=settings.INLINE_FINANCIAL_STATS_THUMBNAIL_URL
)
except Exception as e:
logging.error(f"Error creating financial stats result: {e}")
return None
async def create_system_stats_result(session: AsyncSession, i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
"""Create panel statistics result with system/nodes/bandwidth info"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
try:
from bot.services.panel_api_service import PanelApiService
# Get panel stats similar to main statistics
async with PanelApiService(settings) as panel_service:
system_stats = await panel_service.get_system_stats()
bandwidth_stats = await panel_service.get_bandwidth_stats()
nodes_stats = await panel_service.get_nodes_statistics()
if system_stats:
users = system_stats.get('users', {})
status_counts = users.get('statusCounts', {})
online_stats = system_stats.get('onlineStats', {})
active_users = status_counts.get('ACTIVE', 0)
disabled_users = status_counts.get('DISABLED', 0)
expired_users = status_counts.get('EXPIRED', 0)
limited_users = status_counts.get('LIMITED', 0)
total_users = users.get('totalUsers', 0)
online_now = online_stats.get('onlineNow', 0)
# Memory usage
memory = system_stats.get('memory', {})
memory_usage = 0
if memory:
memory_total = memory.get('total', 1)
memory_used = memory.get('used', 0)
memory_usage = (memory_used / memory_total) * 100 if memory_total > 0 else 0
# Bandwidth
week_traffic = "N/A"
month_traffic = "N/A"
if bandwidth_stats:
week_data = bandwidth_stats.get('bandwidthLastSevenDays', {})
month_data = bandwidth_stats.get('bandwidthLast30Days', {}) or bandwidth_stats.get('bandwidthLastThirtyDays', {})
week_traffic = week_data.get('current', 'N/A') if week_data else 'N/A'
month_traffic = month_data.get('current', 'N/A') if month_data else 'N/A'
# Nodes
active_nodes = 0
total_nodes = 0
if nodes_stats and 'lastSevenDays' in nodes_stats:
unique_nodes = set()
for node_data in nodes_stats.get('lastSevenDays', []):
unique_nodes.add(node_data.get('nodeName', ''))
total_nodes = len(unique_nodes)
active_nodes = total_nodes # Assume all are active
elif system_stats and 'nodes' in system_stats:
active_nodes = system_stats.get('nodes', {}).get('totalOnline', 0)
total_nodes = active_nodes
stats_text = _(
"inline_system_stats_message",
default="🖥 <b>Статистика панели</b>\n\n"
"🟢 Онлайн: <b>{online}</b>\n"
"📊 Активных: <b>{active}</b>\n"
"🔴 Отключенных: <b>{disabled}</b>\n"
"⏰ Истекшие: <b>{expired}</b>\n"
"⚠️ Ограниченные: <b>{limited}</b>\n"
"👥 Всего пользователей: <b>{total}</b>\n"
"💾 Использование RAM: <b>{memory:.1f}%</b>\n"
"📊 Трафик за неделю: <b>{week_traffic}</b>\n"
"📊 Трафик за месяц: <b>{month_traffic}</b>\n"
"🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
online=online_now,
active=active_users,
disabled=disabled_users,
expired=expired_users,
limited=limited_users,
total=total_users,
memory=memory_usage,
week_traffic=week_traffic,
month_traffic=month_traffic,
active_nodes=active_nodes,
total_nodes=total_nodes
)
else:
stats_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title",
default="🖥 Системная статистика"
),
description=_(
"inline_system_description",
default="🟢 Онлайн: {online}, 📊 Активных: {active}",
online=online_now,
active=active_users
),
input_message_content=InputTextMessageContent(
message_text=stats_text,
parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
)
except Exception as e:
logging.error(f"Error creating system stats result: {e}")
# Fallback error message
error_text = _("inline_panel_stats_error", default="❌ Ошибка получения данных с панели")
return InlineQueryResultArticle(
id="admin_system_stats",
title=_(
"inline_admin_system_stats_title",
default="🖥 Системная статистика"
),
description=_("inline_system_error", default="Ошибка получения данных"),
input_message_content=InputTextMessageContent(
message_text=error_text,
parse_mode="HTML"
),
thumbnail_url=settings.INLINE_SYSTEM_STATS_THUMBNAIL_URL
)
return None
+15 -9
View File
@@ -20,7 +20,7 @@ from bot.services.panel_api_service import PanelApiService
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import notify_admin_new_payment
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
payment_processing_lock = asyncio.Lock()
@@ -195,14 +195,20 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
f"Failed to send payment details message to user {user_id}: {e_notify}"
)
await notify_admin_new_payment(
bot,
settings,
i18n,
user_id,
subscription_months,
payment_value,
)
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
months=subscription_months,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
except Exception as e_process:
logging.error(
+14 -21
View File
@@ -105,30 +105,23 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
response_to_user_text = ""
if is_suspicious:
if settings.ADMIN_IDS:
admin_notify_key = "admin_suspicious_promo_attempt_notification" if user.username else "admin_suspicious_promo_attempt_notification_no_username"
admin_lang = settings.DEFAULT_LANGUAGE
_admin = lambda k, **kw: i18n.gettext(admin_lang, k, **kw)
admin_notification_text = _admin(
admin_notify_key,
user_id=user.id,
user_username=user.username or "N/A",
user_first_name=user.first_name or "N/A",
promo_code_input=hcode(code_input))
for admin_id in settings.ADMIN_IDS:
try:
await bot.send_message(admin_id,
admin_notification_text,
parse_mode="HTML")
except Exception as e_admin_notify:
logging.error(
f"Failed to send suspicious promo notification to admin {admin_id}: {e_admin_notify}"
)
# Send notification through NotificationService if enabled
if settings.LOG_SUSPICIOUS_ACTIVITY:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_suspicious_promo_attempt(
user_id=user.id,
username=user.username,
first_name=user.first_name,
suspicious_input=code_input
)
except Exception as e:
logging.error(f"Failed to send suspicious promo notification: {e}")
response_to_user_text = _("promo_code_not_found",
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
success, result = await promo_code_service.apply_promo_code(
+42 -2
View File
@@ -83,11 +83,17 @@ async def referral_command_handler(event: Union[types.Message,
bonus_details_str = "\n".join(bonus_info_parts) if bonus_info_parts else _(
"referral_no_bonuses_configured")
# Get referral statistics
referral_stats = await referral_service.get_referral_stats(session, inviter_user_id)
text = _("referral_program_info_new",
referral_link=referral_link,
bonus_details=bonus_details_str)
bonus_details=bonus_details_str,
invited_count=referral_stats["invited_count"],
purchased_count=referral_stats["purchased_count"])
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
from bot.keyboards.inline.user_keyboards import get_referral_link_keyboard
reply_markup_val = get_referral_link_keyboard(current_lang, i18n)
if isinstance(event, types.Message):
await event.answer(text,
@@ -106,3 +112,37 @@ async def referral_command_handler(event: Union[types.Message,
reply_markup=reply_markup_val,
disable_web_page_preview=True)
await event.answer()
@router.callback_query(F.data.startswith("referral_action:"))
async def referral_action_handler(callback: types.CallbackQuery, settings: Settings,
i18n_data: dict, referral_service: ReferralService,
bot: Bot, session: AsyncSession):
action = callback.data.split(":")[1]
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if action == "share_message":
try:
bot_info = await bot.get_me()
bot_username = bot_info.username
if not bot_username:
await callback.answer("Ошибка получения имени бота", show_alert=True)
return
inviter_user_id = callback.from_user.id
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
friend_message = _("referral_friend_message", referral_link=referral_link)
await callback.message.answer(
friend_message,
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Error in referral share message: {e}")
await callback.answer("Произошла ошибка", show_alert=True)
await callback.answer()
+72 -1
View File
@@ -129,6 +129,8 @@ async def start_command_handler(message: types.Message,
user_id = user.id
referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None
if command and command.args:
arg_payload = command.args
if arg_payload.startswith("ref_"):
@@ -142,6 +144,14 @@ async def start_command_handler(message: types.Message,
logging.warning(
f"Could not parse referral from /start args '{arg_payload}': {e}"
)
elif arg_payload.startswith("promo_"):
try:
promo_code_to_apply = arg_payload.split("_")[1]
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
except (IndexError, ValueError) as e:
logging.warning(
f"Could not parse promo code from /start args '{arg_payload}': {e}"
)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
@@ -160,6 +170,19 @@ async def start_command_handler(message: types.Message,
logging.info(
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
)
# Send notification about new user registration
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(message.bot, settings, i18n)
await notification_service.notify_new_user_registration(
user_id=user_id,
username=user.username,
first_name=user.first_name,
referred_by_id=referred_by_user_id
)
except Exception as e:
logging.error(f"Failed to send new user notification: {e}")
except Exception as e_create:
logging.error(
@@ -193,7 +216,55 @@ async def start_command_handler(message: types.Message,
f"Failed to update existing user {user_id} in session: {e_update}",
exc_info=True)
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
# Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
# Auto-apply promo code if provided via start parameter
if promo_code_to_apply:
try:
from bot.services.promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
success, result = await promo_code_service.apply_promo_code(
session, user_id, promo_code_to_apply, current_lang
)
if success:
await session.commit()
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
# Get updated subscription details
active = await subscription_service.get_active_subscription_details(session, user_id)
config_link = active.get("config_link") if active else None
config_link = config_link or _("config_link_not_available")
new_end_date = result if isinstance(result, datetime) else None
promo_success_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link,
)
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
await message.answer(
promo_success_text,
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
parse_mode="HTML"
)
# Don't show main menu if promo was successfully applied
return
else:
await session.rollback()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
# Continue to show main menu if promo failed
except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
await session.rollback()
await send_main_menu(message,
settings,
i18n_data,
+84 -21
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from config.settings import Settings
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.notification_service import notify_admin_new_trial
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard,
@@ -59,21 +59,89 @@ async def request_trial_confirmation_handler(
await callback.answer()
return
traffic_gb_display = (
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
else _("traffic_unlimited")
# Directly activate trial without confirmation
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
await callback.message.edit_text(
text=_(
"trial_confirm_prompt",
days=settings.TRIAL_DURATION_DAYS,
traffic_gb=traffic_gb_display,
),
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
)
await callback.answer()
final_message_text_in_chat = ""
show_trial_button_after_action = False
if activation_result and activation_result.get("activated"):
await callback.answer(_("trial_activated_alert"), show_alert=True)
end_date_obj = activation_result.get("end_date")
config_link_for_trial = activation_result.get("subscription_url") or _(
"config_link_not_available"
)
traffic_gb_val = activation_result.get(
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
)
traffic_display = (
f"{traffic_gb_val} GB"
if traffic_gb_val and traffic_gb_val > 0
else _("traffic_unlimited")
)
final_message_text_in_chat = _(
"trial_activated_details_message",
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
end_date=(
end_date_obj.strftime("%Y-%m-%d")
if isinstance(end_date_obj, datetime)
else "N/A"
),
config_link=config_link_for_trial,
traffic_gb=traffic_display,
)
# Send notification to admin about new trial
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
else:
message_key_from_service = (
activation_result.get("message_key", "trial_activation_failed")
if activation_result
else "trial_activation_failed"
)
final_message_text_in_chat = _(message_key_from_service)
await callback.answer(final_message_text_in_chat, show_alert=True)
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
):
show_trial_button_after_action = True
try:
await callback.message.edit_text(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
),
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one."
)
if (
callback.message
and hasattr(callback.message, "chat")
and callback.message.chat
):
await callback.message.chat.send_message(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
),
disable_web_page_preview=True,
)
@router.callback_query(F.data == "trial_action:confirm_activate")
@@ -191,13 +259,8 @@ async def confirm_activate_trial_handler(
)
if activation_result and activation_result.get("activated") and end_date_obj:
await notify_admin_new_trial(
callback.bot,
settings,
i18n,
user_id,
end_date_obj,
)
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
@router.callback_query(F.data == "main_action:cancel_trial")
+94 -11
View File
@@ -12,27 +12,107 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Статистика и мониторинг
builder.button(text=_(key="admin_stats_and_monitoring_section"),
callback_data="admin_section:stats_monitoring")
# Управление пользователями
builder.button(text=_(key="admin_user_management_section"),
callback_data="admin_section:user_management")
# Промокоды и маркетинг
builder.button(text=_(key="admin_promo_marketing_section"),
callback_data="admin_section:promo_marketing")
# Системные функции
builder.button(text=_(key="admin_system_functions_section"),
callback_data="admin_section:system_functions")
builder.adjust(1)
return builder.as_markup()
def get_stats_monitoring_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_stats_button"),
callback_data="admin_action:stats")
builder.button(text=_(key="admin_broadcast_button"),
callback_data="admin_action:broadcast")
builder.button(text=_(key="admin_create_promo_button"),
callback_data="admin_action:create_promo")
builder.button(text=_(key="admin_manage_promos_button"),
callback_data="admin_action:manage_promos")
builder.button(text=_(key="admin_view_promos_button"),
callback_data="admin_action:view_promos")
builder.button(text=_(key="admin_view_payments_button", default="💰 Платежи"),
callback_data="admin_action:view_payments")
builder.button(text=_(key="admin_view_logs_menu_button"),
callback_data="admin_action:view_logs_menu")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(2, 1, 1)
return builder.as_markup()
def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_users_management_button"),
callback_data="admin_action:users_management")
builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(2, 1)
return builder.as_markup()
def get_ban_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_ban_user_button"),
callback_data="admin_action:ban_user_prompt")
builder.button(text=_(key="admin_unban_user_button"),
callback_data="admin_action:unban_user_prompt")
builder.button(text=_(key="admin_view_banned_users_button"),
callback_data="admin_action:view_banned:0")
builder.button(text=_(key="admin_view_logs_menu_button"),
callback_data="admin_action:view_logs_menu")
builder.button(text=_(key="back_to_user_management_button"),
callback_data="admin_section:user_management")
builder.adjust(2, 1, 1)
return builder.as_markup()
def get_promo_marketing_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_create_promo_button"),
callback_data="admin_action:create_promo")
builder.button(text=_(key="admin_create_bulk_promo_button"),
callback_data="admin_action:create_bulk_promo")
builder.button(text=_(key="admin_promo_management_button"),
callback_data="admin_action:promo_management")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(2, 1, 1)
return builder.as_markup()
def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_broadcast_button"),
callback_data="admin_action:broadcast")
builder.button(text=_(key="admin_sync_panel_button"),
callback_data="admin_action:sync_panel")
builder.adjust(2, 2, 2, 2, 1)
builder.button(text=_(key="admin_queue_status_button"),
callback_data="admin_action:queue_status")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(2, 1, 1)
return builder.as_markup()
@@ -43,9 +123,12 @@ def get_logs_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
callback_data="admin_logs:view_all:0")
builder.button(text=_(key="admin_view_user_logs_prompt_button"),
callback_data="admin_logs:prompt_user")
builder.button(text=_(key="admin_export_logs_csv_button"),
callback_data="admin_logs:export_csv")
builder.row(
InlineKeyboardButton(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main"))
builder.adjust(2, 1, 1)
return builder.as_markup()
+3
View File
@@ -158,8 +158,11 @@ def get_referral_link_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="referral_share_message_button"),
callback_data="referral_action:share_message")
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
builder.adjust(1)
return builder.as_markup()
+69 -62
View File
@@ -1,17 +1,10 @@
import logging
import asyncio
from typing import Callable, Dict, Any, Awaitable, Optional
from typing import Dict, Any, Optional
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
from aiogram.types import (
Update,
MenuButtonDefault,
MenuButtonWebApp,
WebAppInfo,
BotCommand,
)
from aiogram import Bot, Dispatcher
from aiogram.types import (MenuButtonDefault, MenuButtonWebApp, WebAppInfo, BotCommand)
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart, Command
from aiogram.client.default import DefaultBotProperties
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiogram.fsm.storage.memory import MemoryStorage
@@ -24,12 +17,11 @@ from config.settings import Settings
from db.database_setup import init_db_connection
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.handlers.user import user_router_aggregate
from bot.handlers.admin import admin_router_aggregate
from bot.filters.admin_filter import AdminFilter
from bot.routers import build_root_router
from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService
@@ -41,53 +33,12 @@ from bot.services.tribute_service import TributeService, tribute_webhook_route
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
from bot.handlers.user import payment as user_payment_webhook_module
class DBSessionMiddleware(BaseMiddleware):
def __init__(self, async_session_factory: sessionmaker):
super().__init__()
self.async_session_factory = async_session_factory
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if self.async_session_factory is None:
logging.critical("DBSessionMiddleware: async_session_factory is None!")
raise RuntimeError(
"async_session_factory not provided to DBSessionMiddleware"
)
async with self.async_session_factory() as session:
data["session"] = session
try:
result = await handler(event, data)
await session.commit()
return result
except Exception:
await session.rollback()
logging.error(
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
)
raise
from bot.handlers.admin.sync_admin import perform_sync
from bot.utils.message_queue import init_queue_manager
async def register_all_routers(dp: Dispatcher, settings: Settings):
dp.include_router(user_router_aggregate)
admin_main_router = Router(name="admin_main_filtered_router")
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
admin_main_router.message.filter(admin_filter_instance)
admin_main_router.callback_query.filter(admin_filter_instance)
admin_main_router.include_router(admin_router_aggregate)
dp.include_router(admin_main_router)
dp.include_router(build_root_router(settings))
logging.info("All application routers registered.")
@@ -187,17 +138,72 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception as e:
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
# Initialize message queue manager
try:
queue_manager = init_queue_manager(bot)
dispatcher["queue_manager"] = queue_manager
logging.info("STARTUP: Message queue manager initialized")
except Exception as e:
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
# Automatic sync on startup
try:
logging.info("STARTUP: Running automatic panel sync...")
async with async_session_factory() as session:
sync_result = await perform_sync(
panel_service=panel_service,
session=session,
settings=settings,
i18n_instance=i18n_instance
)
if sync_result.get("status") == "completed":
logging.info(f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}")
else:
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
except Exception as e:
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
logging.info("STARTUP: Bot on_startup_configured completed.")
async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
async def close_service(key: str) -> None:
service = dispatcher.get(key)
if not service:
return
close_coro = getattr(service, "close", None)
if callable(close_coro):
try:
await close_coro()
logging.info(f"{key} closed on shutdown.")
except Exception as e:
logging.warning(f"Failed to close {key}: {e}")
else:
close_session = getattr(service, "close_session", None)
if callable(close_session):
try:
await close_session()
logging.info(f"{key} session closed on shutdown.")
except Exception as e:
logging.warning(f"Failed to close session for {key}: {e}")
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
if panel_service and hasattr(panel_service, "close_session"):
await panel_service.close_session()
logging.info("Panel API service session closed on shutdown.")
for service_key in (
"panel_service",
"cryptopay_service",
"tribute_service",
"panel_webhook_service",
"yookassa_service",
"promo_code_service",
"stars_service",
"subscription_service",
"referral_service",
):
await close_service(service_key)
bot: Bot = dispatcher["bot_instance"]
if bot and bot.session:
@@ -314,7 +320,8 @@ async def run_bot(settings_param: Settings):
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
dp.startup.register(on_startup_configured)
dp.shutdown.register(lambda: on_shutdown_configured(dp))
# Register shutdown callback directly so Dispatcher instance is provided
dp.shutdown.register(on_shutdown_configured)
await register_all_routers(dp, settings_param)
+40
View File
@@ -0,0 +1,40 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.types import Update
from sqlalchemy.orm import sessionmaker
class DBSessionMiddleware(BaseMiddleware):
def __init__(self, async_session_factory: sessionmaker):
super().__init__()
self.async_session_factory = async_session_factory
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
if self.async_session_factory is None:
logging.critical("DBSessionMiddleware: async_session_factory is None!")
raise RuntimeError(
"async_session_factory not provided to DBSessionMiddleware"
)
async with self.async_session_factory() as session:
data["session"] = session
try:
result = await handler(event, data)
await session.commit()
return result
except Exception:
await session.rollback()
logging.error(
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
)
raise
+26
View File
@@ -0,0 +1,26 @@
from aiogram import Router
from bot.handlers.user import user_router_aggregate
from bot.handlers import inline_mode
from bot.handlers.admin import admin_router_aggregate
from bot.filters.admin_filter import AdminFilter
from config.settings import Settings
def build_root_router(settings: Settings) -> Router:
root = Router(name="root")
# Public routers
root.include_router(user_router_aggregate)
root.include_router(inline_mode.router)
# Admin routers behind filter
admin_main_router = Router(name="admin_main_filtered_router")
admin_filter_instance = AdminFilter(admin_ids=settings.ADMIN_IDS)
admin_main_router.message.filter(admin_filter_instance)
admin_main_router.callback_query.filter(admin_filter_instance)
admin_main_router.include_router(admin_router_aggregate)
root.include_router(admin_main_router)
return root
+24 -10
View File
@@ -14,7 +14,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import notify_admin_new_payment
from bot.services.notification_service import NotificationService
from db.dal import payment_dal, user_dal
@@ -46,6 +46,15 @@ class CryptoPayService:
self.client = None
self.configured = False
async def close(self):
"""Close underlying AioCryptoPay session if initialized."""
if self.client:
try:
await self.client.close()
logging.info("CryptoPay client session closed.")
except Exception as e:
logging.warning(f"Failed to close CryptoPay client: {e}")
async def create_invoice(
self,
session: AsyncSession,
@@ -185,15 +194,20 @@ class CryptoPayService:
except Exception as e:
logging.error(f"Failed to send CryptoPay success message: {e}")
await notify_admin_new_payment(
bot,
settings,
i18n,
user_id,
months,
float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
)
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_payment_received(
user_id=user_id,
amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=months,
payment_provider="crypto_pay",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send crypto_pay payment notification: {e}")
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client:
+279 -49
View File
@@ -2,67 +2,297 @@ import logging
import asyncio
from aiogram import Bot
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.exceptions import TelegramRetryAfter
from datetime import datetime, timezone
from typing import Optional, Union, Dict, Any
from config.settings import Settings
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
message_key: str, parse_mode: str | None = None,
**kwargs) -> None:
if not settings.ADMIN_IDS:
return
admin_lang = settings.DEFAULT_LANGUAGE
msg = i18n.gettext(admin_lang, message_key, **kwargs)
for admin_id in settings.ADMIN_IDS:
class NotificationService:
"""Enhanced notification service for sending messages to admins and log channels"""
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
self.bot = bot
self.settings = settings
self.i18n = i18n
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
"""Send message to configured log channel/group using message queue"""
if not self.settings.LOG_CHAT_ID:
return
queue_manager = get_queue_manager()
if not queue_manager:
logging.warning("Message queue manager not available, falling back to direct send")
try:
await self.bot.send_message(
chat_id=self.settings.LOG_CHAT_ID,
text=message,
parse_mode="HTML",
disable_web_page_preview=True,
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
)
except Exception as e:
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
return
try:
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
# Use thread_id if provided, otherwise use from settings
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
kwargs = {
"text": message,
"parse_mode": "HTML",
"disable_web_page_preview": True
}
# Add thread ID for supergroups if specified
if final_thread_id:
kwargs["message_thread_id"] = final_thread_id
# Queue message for sending (groups are rate limited to 15/minute)
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
except Exception as e:
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
async def _send_to_admins(self, message: str):
"""Send message to all admin users using message queue"""
if not self.settings.ADMIN_IDS:
return
queue_manager = get_queue_manager()
if not queue_manager:
logging.warning("Message queue manager not available, falling back to direct send")
for admin_id in self.settings.ADMIN_IDS:
try:
await self.bot.send_message(
chat_id=admin_id,
text=message,
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
return
for admin_id in self.settings.ADMIN_IDS:
try:
await queue_manager.send_message(
chat_id=admin_id,
text=message,
parse_mode="HTML",
disable_web_page_preview=True
)
except Exception as e:
logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
first_name: Optional[str] = None,
referred_by_id: Optional[int] = None):
"""Send notification about new user registration"""
if not self.settings.LOG_NEW_USERS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = first_name or f"ID {user_id}"
if username:
user_display += f" (@{username})"
referral_text = ""
if referred_by_id:
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
message = _(
"log_new_user_registration",
default="👤 <b>Новый пользователь</b>\n\n"
"🆔 ID: <code>{user_id}</code>\n"
"👤 Имя: {user_display}{referral_text}\n"
"📅 Время: {timestamp}",
user_id=user_id,
user_display=user_display,
referral_text=referral_text,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
months: int, payment_provider: str,
username: Optional[str] = None):
"""Send notification about successful payment"""
if not self.settings.LOG_PAYMENTS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}"
if username:
user_display += f" (@{username})"
provider_emoji = {
"yookassa": "💳",
"cryptopay": "",
"stars": "",
"tribute": "💎"
}.get(payment_provider.lower(), "💰")
message = _(
"log_payment_received",
default="{provider_emoji} <b>Получен платеж</b>\n\n"
"👤 Пользователь: {user_display}\n"
"💰 Сумма: <b>{amount} {currency}</b>\n"
"📅 Период: <b>{months} мес.</b>\n"
"🏦 Провайдер: {payment_provider}\n"
"🕐 Время: {timestamp}",
provider_emoji=provider_emoji,
user_display=user_display,
amount=amount,
currency=currency,
months=months,
payment_provider=payment_provider,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
username: Optional[str] = None):
"""Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}"
if username:
user_display += f" (@{username})"
message = _(
"log_promo_activation",
default="🎁 <b>Активирован промокод</b>\n\n"
"👤 Пользователь: {user_display}\n"
"🏷 Код: <code>{promo_code}</code>\n"
"🎯 Бонус: <b>+{bonus_days} дн.</b>\n"
"🕐 Время: {timestamp}",
user_display=user_display,
promo_code=promo_code,
bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_trial_activation(self, user_id: int, end_date: datetime,
username: Optional[str] = None):
"""Send notification about trial activation"""
if not self.settings.LOG_TRIAL_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = f"ID {user_id}"
if username:
user_display += f" (@{username})"
message = _(
"log_trial_activation",
default="🆓 <b>Активирован триал</b>\n\n"
"👤 Пользователь: {user_display}\n"
"⏰ Действует до: <b>{end_date}</b>\n"
"🕐 Время: {timestamp}",
user_display=user_display,
end_date=end_date.strftime("%Y-%m-%d %H:%M"),
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_panel_sync(self, status: str, details: str,
users_processed: int, subs_synced: int,
username: Optional[str] = None):
"""Send notification about panel synchronization"""
if not getattr(self.settings, 'LOG_PANEL_SYNC', True):
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
# Status emoji based on sync result
status_emoji = {
"completed": "",
"completed_with_errors": "⚠️",
"failed": ""
}.get(status, "🔄")
message = _(
"log_panel_sync",
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
"📊 Статус: <b>{status}</b>\n"
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
"🕐 Время: {timestamp}\n\n"
"📝 Детали:\n{details}",
status_emoji=status_emoji,
status=status,
users_processed=users_processed,
subs_synced=subs_synced,
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
details=details
)
# Send to log channel
await self._send_to_log_channel(message)
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
user_id: int, end_date: datetime) -> None:
end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date)
await notify_admins(
bot,
settings,
i18n,
"admin_new_trial_notification",
user_id=user_id,
end_date=end_date_str,
)
async def notify_suspicious_promo_attempt(
self, user_id: int, suspicious_input: str,
username: Optional[str] = None, first_name: Optional[str] = None):
"""Send notification about a suspicious promo code attempt."""
if not self.settings.LOG_SUSPICIOUS_ACTIVITY:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(
admin_lang, k, **kw) if self.i18n else k
async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
user_id: int, months: int, amount: float,
currency: str | None = None) -> None:
currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL
await notify_admins(
bot,
settings,
i18n,
"admin_new_payment_notification",
user_id=user_id,
months=months,
amount=f"{amount:.2f}",
currency=currency_symbol,
)
user_display = first_name or f"ID {user_id}"
if username:
user_display += f" (@{username})"
message = _(
"log_suspicious_promo",
default="⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n"
"👤 Пользователь: {user_display}\n"
"🆔 ID: <code>{user_id}</code>\n"
"📝 Ввод: <pre>{suspicious_input}</pre>\n"
"🕐 Время: {timestamp}",
user_display=hd.quote(user_display),
user_id=user_id,
suspicious_input=hd.quote(suspicious_input),
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
async def notify_admin_promo_activation(bot: Bot, settings: Settings,
i18n: JsonI18n, user_id: int,
code: str,
bonus_days: int) -> None:
await notify_admins(
bot,
settings,
i18n,
"admin_promo_activation_notification",
user_id=user_id,
code=code,
bonus_days=bonus_days,
)
# Send to log channel
await self._send_to_log_channel(message)
async def send_custom_notification(self, message: str, to_admins: bool = False,
to_log_channel: bool = True, thread_id: Optional[int] = None):
"""Send custom notification message"""
if to_log_channel:
await self._send_to_log_channel(message, thread_id)
if to_admins:
await self._send_to_admins(message)
# Removed legacy helper functions that duplicated NotificationService API
+38 -8
View File
@@ -21,6 +21,14 @@ class PanelApiService:
self.api_key = settings.PANEL_API_KEY
self._session: Optional[aiohttp.ClientSession] = None
self.default_client_ip = "127.0.0.1"
async def __aenter__(self):
"""Context manager entry"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - automatically close session"""
await self.close_session()
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
@@ -32,7 +40,11 @@ class PanelApiService:
if self._session and not self._session.closed:
await self._session.close()
self._session = None
logging.info("Panel API service HTTP session closed.")
logging.debug("Panel API service HTTP session closed.")
async def close(self):
"""Alias for close_session for API consistency."""
await self.close_session()
async def _prepare_headers(self) -> Dict[str, str]:
headers = {
@@ -325,8 +337,7 @@ class PanelApiService:
default_expire_days: int = 1,
default_traffic_limit_bytes: int = 0,
default_traffic_limit_strategy: str = "NO_RESET",
specific_inbound_uuids: Optional[List[str]] = None,
activate_all_inbounds_default_flag: bool = True,
specific_squad_uuids: Optional[List[str]] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
status: str = "ACTIVE",
@@ -357,11 +368,8 @@ class PanelApiService:
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
"trafficLimitBytes": default_traffic_limit_bytes,
}
if specific_inbound_uuids:
payload["activeUserInbounds"] = specific_inbound_uuids
payload["activateAllInbounds"] = False
else:
payload["activateAllInbounds"] = activate_all_inbounds_default_flag
if specific_squad_uuids:
payload["activeInternalSquads"] = specific_squad_uuids
if telegram_id is not None: payload["telegramId"] = telegram_id
if email: payload["email"] = email
if description: payload["description"] = description
@@ -460,3 +468,25 @@ class PanelApiService:
async def get_bot_db_last_sync_status(
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
return await panel_sync_dal.get_panel_sync_status(session)
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
"""Get system statistics (CPU, memory, users counts)"""
response_data = await self._request("GET", "/system/stats", log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
"""Get bandwidth statistics"""
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
"""Get nodes statistics"""
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
return None
+78 -9
View File
@@ -41,6 +41,70 @@ class PanelWebhookService:
except Exception as e:
logging.error(f"Failed to send notification to {user_id}: {e}")
async def _handle_expired_subscription(self, session, user_id: int, user_payload: dict,
lang: str, markup, first_name: str):
"""Handle expired subscription - auto-renew tribute users if no cancellation was received"""
from db.dal import subscription_dal, payment_dal
from datetime import datetime, timezone, timedelta
try:
# Check if user has tribute subscriptions that weren't cancelled
user_subs = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
for sub in user_subs:
# Check if this subscription was marked as cancelled (from tribute cancellation webhook)
if sub.status_from_panel == 'CANCELLED':
logging.info(f"Subscription {sub.subscription_id} for user {user_id} was cancelled, skipping auto-renewal")
continue
# Check if this user has tribute payments
last_tribute_duration = await payment_dal.get_last_tribute_payment_duration(session, user_id)
if last_tribute_duration is not None:
# This user has tribute payments, auto-renew for the same duration
logging.info(f"Auto-renewing tribute subscription for user {user_id} for {last_tribute_duration} months")
# Extend subscription by the last payment duration
new_end_date = datetime.now(timezone.utc) + timedelta(days=last_tribute_duration * 30)
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
'end_date': new_end_date,
'status_from_panel': 'ACTIVE',
'is_active': True
}
)
# Send auto-renewal notification
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
auto_renewal_msg = _(
"tribute_auto_renewal",
default="🔄 <b>Подписка автоматически продлена</b>\n\n"
"Ваша подписка Tribute была автоматически продлена на {months} мес.\n"
"Новая дата окончания: {end_date}",
user_name=first_name,
months=last_tribute_duration,
end_date=new_end_date.strftime('%Y-%m-%d')
)
try:
await self.bot.send_message(
user_id,
auto_renewal_msg,
reply_markup=markup,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
await session.commit()
except Exception as e:
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
await session.rollback()
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
@@ -69,15 +133,20 @@ class PanelWebhookService:
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired":
# Check if this is a tribute user that should be auto-renewed (regardless of notification settings)
await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
# Send notification only if enabled
if self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
await self._send_message(
user_id,
+14 -11
View File
@@ -11,7 +11,7 @@ from db.models import PromoCode, User
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
from .notification_service import notify_admin_promo_activation
from .notification_service import NotificationService
class PromoCodeService:
@@ -55,21 +55,25 @@ class PromoCodeService:
reason=f"promo code {code_input_upper}")
if new_end_date:
activation_recorded = await promo_code_dal.record_promo_activation(
session, promo_data.promo_code_id, user_id, payment_id=None)
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_data.promo_code_id)
if activation_recorded and promo_incremented:
await notify_admin_promo_activation(
self.bot,
self.settings,
self.i18n,
user_id,
code_input_upper,
bonus_days,
)
# Send notification about promo activation
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_promo_activation(
user_id=user_id,
promo_code=code_input_upper,
bonus_days=bonus_days,
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send promo activation notification: {e}")
return True, new_end_date
else:
@@ -78,5 +82,4 @@ class PromoCodeService:
)
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
+37
View File
@@ -1,5 +1,6 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional, Dict, Any
from aiogram import Bot
from datetime import datetime, timezone, timedelta
@@ -219,3 +220,39 @@ class ReferralService:
def generate_referral_link(self, bot_username: str,
inviter_user_id: int) -> str:
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
"""Get referral statistics for a user"""
from db.dal import user_dal, payment_dal
try:
# Count total invited users (referrals)
invited_count_result = await session.execute(
text("SELECT COUNT(*) FROM users WHERE referred_by_id = :user_id"),
{"user_id": user_id}
)
invited_count = invited_count_result.scalar() or 0
# Count users who made successful payments (purchased subscription)
purchased_count_result = await session.execute(
text("""
SELECT COUNT(DISTINCT u.user_id)
FROM users u
JOIN payments p ON u.user_id = p.user_id
WHERE u.referred_by_id = :user_id
AND p.status = 'succeeded'
"""),
{"user_id": user_id}
)
purchased_count = purchased_count_result.scalar() or 0
return {
"invited_count": invited_count,
"purchased_count": purchased_count
}
except Exception as e:
logging.error(f"Error getting referral stats for user {user_id}: {e}")
return {
"invited_count": 0,
"purchased_count": 0
}
+15 -10
View File
@@ -10,7 +10,7 @@ from db.dal import payment_dal, user_dal
from .subscription_service import SubscriptionService
from .referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
from .notification_service import notify_admin_new_payment
from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
@@ -153,13 +153,18 @@ class StarsService:
logging.error(
f"Failed to send stars payment success message: {e_send}")
await notify_admin_new_payment(
self.bot,
self.settings,
self.i18n,
message.from_user.id,
months,
float(stars_amount),
currency="XTR",
)
# Send notification about payment
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
user = await user_dal.get_user_by_id(session, message.from_user.id)
await notification_service.notify_payment_received(
user_id=message.from_user.id,
amount=float(stars_amount),
currency="XTR",
months=months,
payment_provider="stars",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}")
+55 -43
View File
@@ -34,10 +34,7 @@ class SubscriptionService:
else self.settings.DEFAULT_LANGUAGE
)
async def has_had_any_subscription(
self, session: AsyncSession, user_id: int
) -> bool:
async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool:
return await subscription_dal.has_any_subscription_for_user(session, user_id)
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
@@ -105,7 +102,7 @@ class SubscriptionService:
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id,
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
@@ -128,7 +125,7 @@ class SubscriptionService:
creation_response = await self.panel_service.create_panel_user(
username_on_panel=panel_username_on_panel_standard,
telegram_id=user_id,
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
specific_squad_uuids=self.settings.parsed_user_squad_uuids,
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
)
@@ -348,21 +345,12 @@ class SubscriptionService:
"message_key": "trial_activation_failed_db",
}
panel_update_payload: Dict[str, Any] = {
"uuid": panel_user_uuid,
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"status": "ACTIVE",
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
}
if self.settings.parsed_user_inbound_uuids:
panel_update_payload["activeUserInbounds"] = (
self.settings.parsed_user_inbound_uuids
)
elif panel_user_created_now:
panel_update_payload["activateAllInbounds"] = True
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.trial_traffic_limit_bytes,
)
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -484,7 +472,7 @@ class SubscriptionService:
"status_from_panel": "ACTIVE",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"provider": provider,
"skip_notifications": provider == "tribute",
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -497,21 +485,12 @@ class SubscriptionService:
)
return None
panel_update_payload = {
"uuid": panel_user_uuid,
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
"+00:00", "Z"
),
"status": "ACTIVE",
"trafficLimitBytes": self.settings.user_traffic_limit_bytes,
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
}
if self.settings.parsed_user_inbound_uuids:
panel_update_payload["activeUserInbounds"] = (
self.settings.parsed_user_inbound_uuids
)
elif panel_user_created_now:
panel_update_payload["activateAllInbounds"] = True
panel_update_payload = self._build_panel_update_payload(
panel_user_uuid=panel_user_uuid,
expire_at=final_end_date,
status="ACTIVE",
traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
)
updated_panel_user = await self.panel_service.update_user_details_on_panel(
panel_user_uuid, panel_update_payload
@@ -567,6 +546,10 @@ class SubscriptionService:
)
start_date = datetime.now(timezone.utc)
new_end_date_obj = start_date + timedelta(days=bonus_days)
# For promo code activations, use the configured user traffic limit
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
bonus_sub_payload = {
"user_id": user_id,
"panel_user_uuid": panel_uuid,
@@ -576,7 +559,7 @@ class SubscriptionService:
"duration_months": 0,
"is_active": True,
"status_from_panel": "ACTIVE_BONUS",
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
"traffic_limit_bytes": traffic_limit,
}
await subscription_dal.deactivate_other_active_subscriptions(
session, panel_uuid, panel_sub_uuid
@@ -597,14 +580,19 @@ class SubscriptionService:
)
if updated_sub_model:
# Prepare panel update payload
panel_update_payload = self._build_panel_update_payload(
expire_at=new_end_date_obj,
traffic_limit_bytes=(
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
),
include_uuid=False,
)
panel_update_success = (
await self.panel_service.update_user_details_on_panel(
panel_uuid,
{
"expireAt": new_end_date_obj.isoformat(
timespec="milliseconds"
).replace("+00:00", "Z")
},
panel_update_payload,
)
)
if not panel_update_success:
@@ -766,3 +754,27 @@ class SubscriptionService:
logging.warning(
f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time."
)
# Helpers
def _build_panel_update_payload(
self,
*,
panel_user_uuid: Optional[str] = None,
expire_at: Optional[datetime] = None,
status: Optional[str] = None,
traffic_limit_bytes: Optional[int] = None,
include_uuid: bool = True,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {}
if include_uuid and panel_user_uuid:
payload["uuid"] = panel_user_uuid
if expire_at is not None:
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
if status is not None:
payload["status"] = status
if traffic_limit_bytes is not None:
payload["trafficLimitBytes"] = traffic_limit_bytes
payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
if self.settings.parsed_user_squad_uuids:
payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids
return payload
+120 -58
View File
@@ -13,7 +13,7 @@ from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from .notification_service import notify_admin_new_payment
from .notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal
@@ -39,11 +39,16 @@ def convert_period_to_months(period: Optional[str]) -> int:
class TributeService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService):
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
@@ -52,8 +57,7 @@ class TributeService:
self.subscription_service = subscription_service
self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes,
signature_header: Optional[str]) -> web.Response:
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
settings = self.settings
bot = self.bot
i18n = self.i18n
@@ -74,60 +78,65 @@ class TributeService:
except Exception:
return web.Response(status=400, text="bad_request")
event_name = payload.get('name')
data = payload.get('payload', {})
user_id = data.get('telegram_user_id')
price_val = (
data.get('amount')
or data.get('amount_paid')
or data.get('price')
logging.info(
"Tribute webhook data: %s",
json.dumps(payload, ensure_ascii=False),
)
if not user_id or price_val is None:
return web.Response(status=200, text="ok_missing_fields")
# Tribute webhook spec: only two events are sent
# name: new_subscription | cancelled_subscription
event_name = payload.get("name")
data = payload.get("payload", {})
period_val = data.get('period')
# Mandatory routing fields
user_id = data.get("telegram_user_id")
if not user_id:
return web.Response(status=400, text="missing_telegram_user_id")
period_val = data.get("period")
months = convert_period_to_months(period_val)
price_rub = price_val / 100
# Tribute sends amount in minor units (kopecks/cents). Convert to major units before persisting.
amount_value = data.get("amount") or data.get("price")
currency = (data.get("currency") or settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
if amount_value is not None:
try:
amount_minor_units = float(amount_value)
except (TypeError, ValueError):
amount_minor_units = 0.0
amount_float = round(amount_minor_units / 100.0, 2)
else:
amount_float = 0.0
async with async_session_factory() as session:
if event_name == 'new_subscription':
provider_payment_id = str(data.get('subscription_id'))
existing_payment = await payment_dal.get_payment_by_provider_payment_id(
session, provider_payment_id)
if existing_payment:
logging.info(
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
provider_payment_id,
)
payment_record = existing_payment
else:
payment_record = await payment_dal.create_payment_record(
session,
{
'user_id': user_id,
'amount': float(price_rub),
'currency': 'RUB',
'status': 'succeeded',
'description': 'Tribute subscription',
'subscription_duration_months': months,
'provider_payment_id': provider_payment_id,
'provider': 'tribute',
},
)
if event_name == "new_subscription":
# Build a stable provider payment id from subscription and timestamps
provider_payment_id = str(data.get("subscription_id"))
# Idempotent ensure payment
payment_record = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=int(user_id),
amount=amount_float,
currency=currency,
months=months,
description="Tribute subscription",
provider="tribute",
provider_payment_id=provider_payment_id,
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
int(user_id),
months,
float(price_rub),
float(amount_float),
payment_record.payment_id,
provider='tribute',
provider="tribute",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, months)
session, int(user_id), months)
await session.commit()
db_user = await user_dal.get_user_by_id(session, user_id)
db_user = await user_dal.get_user_by_id(session, int(user_id))
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
@@ -172,7 +181,7 @@ class TributeService:
try:
await bot.send_message(
user_id,
int(user_id),
success_msg,
reply_markup=markup,
parse_mode="HTML",
@@ -182,19 +191,72 @@ class TributeService:
logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}")
await notify_admin_new_payment(
bot,
settings,
i18n,
user_id,
months,
float(price_rub),
currency="RUB",
)
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, int(user_id))
await notification_service.notify_payment_received(
user_id=int(user_id),
amount=float(amount_float),
currency=currency,
months=months,
payment_provider="tribute",
username=user.username if user else None
)
except Exception as e:
logging.error(f"Failed to send tribute payment notification: {e}")
elif event_name == "cancelled_subscription":
await self._handle_tribute_cancellation(session, int(user_id), bot, i18n)
else:
await session.commit()
return web.Response(status=200, text="ok")
async def _handle_tribute_cancellation(self, session, user_id: int, bot: Bot, i18n: JsonI18n):
"""Handle tribute subscription cancellation - set subscription to 1 day grace period"""
from datetime import datetime, timezone, timedelta
from db.dal import subscription_dal, user_dal
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
try:
# Set all user's subscriptions to expire in 1 day (grace period)
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
await session.commit()
# Send notification about cancellation if enabled
if not self.settings.TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS:
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) if i18n else k
markup = get_subscribe_only_markup(lang, i18n)
cancellation_msg = _(
"tribute_subscription_cancelled",
default="🚨 <b>Подписка отменена</b>\n\n"
"Ваша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, "
"после чего подписка будет заблокирована.\n\n"
"Для продления подписки нажмите кнопку ниже.",
user_name=first_name
)
try:
await bot.send_message(
int(user_id),
cancellation_msg,
reply_markup=markup,
parse_mode="HTML"
)
except Exception as e:
logging.error(f"Failed to send tribute cancellation notification to user {user_id}: {e}")
logging.info(f"Tribute subscription cancelled for user {user_id}, grace period set to 1 day")
except Exception as e:
logging.error(f"Error handling tribute cancellation for user {user_id}: {e}")
await session.rollback()
async def tribute_webhook_route(request: web.Request):
"""AIOHTTP route handler for Tribute webhook calls."""
+2 -2
View File
@@ -142,7 +142,7 @@ class YooKassaService:
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request,
idempotence_key))
@@ -196,7 +196,7 @@ class YooKassaService:
f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}"
)
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
payment_info_yk = await loop.run_in_executor(
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
+17
View File
@@ -6,8 +6,25 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State()
confirming_broadcast = State()
waiting_for_promo_details = State()
waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State()
waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State()
waiting_for_promo_edit_code = State()
waiting_for_promo_edit_bonus_days = State()
waiting_for_promo_edit_max_activations = State()
waiting_for_promo_edit_validity_days = State()
waiting_for_bulk_promo_quantity = State()
waiting_for_bulk_promo_bonus_days = State()
waiting_for_bulk_promo_max_activations = State()
waiting_for_bulk_promo_validity_days = State()
waiting_for_user_id_to_ban = State()
waiting_for_user_id_to_unban = State()
waiting_for_user_id_for_logs = State()
# User management states
waiting_for_user_search = State()
waiting_for_subscription_days_to_add = State()
waiting_for_direct_message_to_user = State()
+1
View File
@@ -0,0 +1 @@
# Bot utilities package
+183
View File
@@ -0,0 +1,183 @@
import asyncio
import logging
from typing import Dict, Any, Callable, Awaitable, Optional
from dataclasses import dataclass
from datetime import datetime, timedelta
from collections import deque
from aiogram import Bot
@dataclass
class QueuedMessage:
"""Represents a queued message with all necessary parameters"""
chat_id: int
method_name: str # 'send_message', 'edit_message_text', etc.
kwargs: Dict[str, Any]
callback: Optional[Callable[[Any], Awaitable[None]]] = None # Optional callback for result
class MessageQueue:
"""Message queue with rate limiting for Telegram API"""
def __init__(self, messages_per_second: float, burst_size: int = 5):
self.messages_per_second = messages_per_second
self.burst_size = burst_size
self.queue: deque[QueuedMessage] = deque()
self.last_send_times: deque[datetime] = deque()
self.is_processing = False
self.delay_between_messages = 1.0 / messages_per_second
async def add_message(self, message: QueuedMessage) -> None:
"""Add message to queue"""
self.queue.append(message)
if not self.is_processing:
asyncio.create_task(self._process_queue())
async def _process_queue(self) -> None:
"""Process messages from queue with rate limiting"""
if self.is_processing:
return
self.is_processing = True
try:
while self.queue:
# Check if we need to wait
await self._wait_if_needed()
# Get and process next message
message = self.queue.popleft()
try:
await self._send_message(message)
self.last_send_times.append(datetime.now())
# Keep only recent send times (last minute)
cutoff_time = datetime.now() - timedelta(seconds=60)
while self.last_send_times and self.last_send_times[0] < cutoff_time:
self.last_send_times.popleft()
except Exception as e:
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
finally:
self.is_processing = False
async def _wait_if_needed(self) -> None:
"""Wait if we need to respect rate limits"""
if not self.last_send_times:
return
# Calculate time since last message
time_since_last = (datetime.now() - self.last_send_times[-1]).total_seconds()
if time_since_last < self.delay_between_messages:
wait_time = self.delay_between_messages - time_since_last
await asyncio.sleep(wait_time)
async def _send_message(self, message: QueuedMessage) -> Any:
"""Send a single message - to be implemented by subclass"""
raise NotImplementedError("Subclass must implement _send_message")
class TelegramMessageQueue(MessageQueue):
"""Telegram-specific message queue"""
def __init__(self, bot: Bot, messages_per_second: float, burst_size: int = 5):
super().__init__(messages_per_second, burst_size)
self.bot = bot
async def _send_message(self, message: QueuedMessage) -> Any:
"""Send message using bot method"""
method = getattr(self.bot, message.method_name)
result = await method(chat_id=message.chat_id, **message.kwargs)
# Call callback if provided
if message.callback:
await message.callback(result)
return result
class MessageQueueManager:
"""Manager for different types of message queues"""
def __init__(self, bot: Bot):
self.bot = bot
# Different queues for different types of chats
self.group_queue = TelegramMessageQueue(
bot=bot,
messages_per_second=15/60, # 15 messages per minute for groups
burst_size=3
)
self.user_queue = TelegramMessageQueue(
bot=bot,
messages_per_second=25, # 25 messages per second for users
burst_size=10
)
def _is_group_chat(self, chat_id: int) -> bool:
"""Check if chat_id belongs to a group or channel"""
return str(chat_id).startswith('-100')
async def send_message(self, chat_id: int, **kwargs) -> None:
"""Queue a send_message call"""
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
message = QueuedMessage(
chat_id=chat_id,
method_name='send_message',
kwargs=kwargs
)
await queue.add_message(message)
async def edit_message_text(self, chat_id: int, **kwargs) -> None:
"""Queue an edit_message_text call"""
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
message = QueuedMessage(
chat_id=chat_id,
method_name='edit_message_text',
kwargs=kwargs
)
await queue.add_message(message)
async def send_document(self, chat_id: int, **kwargs) -> None:
"""Queue a send_document call"""
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
message = QueuedMessage(
chat_id=chat_id,
method_name='send_document',
kwargs=kwargs
)
await queue.add_message(message)
async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None:
"""Send callback query answer immediately (not rate limited)"""
await self.bot.answer_callback_query(callback_query_id, **kwargs)
def get_queue_stats(self) -> Dict[str, Any]:
"""Get statistics about queues"""
return {
"group_queue_size": len(self.group_queue.queue),
"user_queue_size": len(self.user_queue.queue),
"group_queue_processing": self.group_queue.is_processing,
"user_queue_processing": self.user_queue.is_processing,
"group_recent_sends": len(self.group_queue.last_send_times),
"user_recent_sends": len(self.user_queue.last_send_times)
}
# Global queue manager instance
_queue_manager: Optional[MessageQueueManager] = None
def init_queue_manager(bot: Bot) -> MessageQueueManager:
"""Initialize global queue manager"""
global _queue_manager
_queue_manager = MessageQueueManager(bot)
return _queue_manager
def get_queue_manager() -> Optional[MessageQueueManager]:
"""Get global queue manager instance"""
return _queue_manager
+34 -6
View File
@@ -1,6 +1,6 @@
import logging
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, ValidationError, computed_field
from pydantic import Field, ValidationError, computed_field, field_validator
from typing import Optional, List, Dict, Any
@@ -66,6 +66,8 @@ class Settings(BaseSettings):
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
TRIBUTE_SKIP_NOTIFICATIONS: bool = Field(default=True, description="Skip renewal notifications for Tribute payments")
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS: bool = Field(default=False, description="Skip cancellation notifications for Tribute payments")
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
@@ -95,10 +97,10 @@ class Settings(BaseSettings):
PANEL_API_KEY: Optional[str] = None
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
USER_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
USER_INBOUND_UUIDS: Optional[str] = Field(
USER_SQUAD_UUIDS: Optional[str] = Field(
default=None,
description=
"Comma-separated UUIDs of inbounds to activate for new panel users")
"Comma-separated UUIDs of internal squads to assign to new panel users")
TRIAL_ENABLED: bool = Field(default=True)
TRIAL_DURATION_DAYS: int = Field(default=3)
@@ -111,6 +113,13 @@ class Settings(BaseSettings):
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
# Inline mode thumbnail URLs
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
INLINE_USER_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/681/681494.png")
INLINE_FINANCIAL_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2769/2769339.png")
INLINE_SYSTEM_STATS_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/2920/2920277.png")
@computed_field
@property
@@ -156,11 +165,11 @@ class Settings(BaseSettings):
@computed_field
@property
def parsed_user_inbound_uuids(self) -> Optional[List[str]]:
if self.USER_INBOUND_UUIDS:
def parsed_user_squad_uuids(self) -> Optional[List[str]]:
if self.USER_SQUAD_UUIDS:
return [
uuid.strip()
for uuid in self.USER_INBOUND_UUIDS.split(',')
for uuid in self.USER_SQUAD_UUIDS.split(',')
if uuid.strip()
]
return None
@@ -288,6 +297,25 @@ class Settings(BaseSettings):
if self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS is not None:
bonuses[12] = self.REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS
return bonuses
# Logging Configuration
LOG_CHAT_ID: Optional[int] = Field(default=None, description="Telegram chat/group ID for sending notifications")
LOG_THREAD_ID: Optional[int] = Field(default=None, description="Thread ID for supergroup messages (optional)")
@field_validator('LOG_CHAT_ID', 'LOG_THREAD_ID', mode='before')
@classmethod
def validate_optional_int_fields(cls, v):
"""Convert empty strings to None for optional integer fields"""
if isinstance(v, str) and v.strip() == '':
return None
return v
# Notification types
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
LOG_PAYMENTS: bool = Field(default=True, description="Send notifications for successful payments")
LOG_PROMO_ACTIVATIONS: bool = Field(default=True, description="Send notifications for promo code activations")
LOG_TRIAL_ACTIVATIONS: bool = Field(default=True, description="Send notifications for trial activations")
LOG_SUSPICIOUS_ACTIVITY: bool = Field(default=True, description="Send notifications for suspicious promo attempts")
model_config = SettingsConfigDict(env_file='.env',
env_file_encoding='utf-8',
+131 -52
View File
@@ -2,7 +2,7 @@ import logging
from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import update, func
from sqlalchemy import update, func, and_
from sqlalchemy.orm import selectinload
from db.models import Payment, User
@@ -38,14 +38,6 @@ async def create_payment_record(session: AsyncSession,
return new_payment
async def get_payment_by_yookassa_id(
session: AsyncSession, yookassa_payment_id: str) -> Optional[Payment]:
stmt = select(Payment).where(
Payment.yookassa_payment_id == yookassa_payment_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_payment_by_provider_payment_id(
session: AsyncSession, provider_payment_id: str) -> Optional[Payment]:
"""Fetch a payment by provider-specific identifier."""
@@ -55,6 +47,38 @@ async def get_payment_by_provider_payment_id(
return result.scalar_one_or_none()
async def ensure_payment_with_provider_id(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
months: int,
description: str,
provider: str,
provider_payment_id: str) -> Payment:
"""Idempotently create a payment record for a provider event.
If a payment with the same provider_payment_id already exists, returns it.
Otherwise creates a new succeeded payment with provided data.
"""
existing = await get_payment_by_provider_payment_id(session, provider_payment_id)
if existing:
return existing
payment_payload: Dict[str, Any] = {
"user_id": user_id,
"amount": float(amount),
"currency": currency,
"status": "succeeded",
"description": description,
"subscription_duration_months": months,
"provider_payment_id": provider_payment_id,
"provider": provider,
}
return await create_payment_record(session, payment_payload)
async def get_payment_by_db_id(session: AsyncSession,
payment_db_id: int) -> Optional[Payment]:
@@ -64,15 +88,6 @@ async def get_payment_by_db_id(session: AsyncSession,
return result.scalar_one_or_none()
async def get_payment_by_db_id_with_promo(
session: AsyncSession, payment_db_id: int) -> Optional[Payment]:
stmt = select(Payment).where(Payment.payment_id == payment_db_id).options(
selectinload(Payment.promo_code_used))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_payment_status_by_db_id(
session: AsyncSession,
payment_db_id: int,
@@ -96,43 +111,29 @@ async def update_payment_status_by_db_id(
return payment
async def user_has_successful_payment_for_provider(
session: AsyncSession, user_id: int, provider: str) -> bool:
"""Check if a user has at least one successful payment for the provider."""
stmt = (select(Payment.payment_id)
.where(Payment.user_id == user_id,
Payment.provider == provider,
Payment.status == 'succeeded')
.limit(1))
result = await session.execute(stmt)
return result.scalar_one_or_none() is not None
async def update_payment_status_by_yk_id(session: AsyncSession,
yookassa_payment_id: str,
new_status: str) -> Optional[Payment]:
payment = await get_payment_by_yookassa_id(session, yookassa_payment_id)
if payment:
payment.status = new_status
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record with YK ID {yookassa_payment_id} status updated to {new_status}."
)
else:
logging.warning(
f"Payment record with YK ID {yookassa_payment_id} not found for status update."
)
return payment
async def get_recent_payment_logs_with_user(session: AsyncSession,
limit: int = 20,
offset: int = 0) -> List[Payment]:
stmt = (select(Payment).options(selectinload(Payment.user)).order_by(
Payment.created_at.desc()).limit(limit).offset(offset))
stmt = (select(Payment).options(selectinload(Payment.user))
.where(Payment.status == 'succeeded')
.order_by(Payment.created_at.desc())
.limit(limit).offset(offset))
result = await session.execute(stmt)
return result.scalars().all()
async def get_payments_count(session: AsyncSession) -> int:
"""Get total count of successful payments."""
stmt = select(func.count(Payment.payment_id)).where(Payment.status == 'succeeded')
result = await session.execute(stmt)
return result.scalar() or 0
async def get_all_succeeded_payments_with_user(session: AsyncSession) -> List[Payment]:
"""Get all successful payments with user data for export."""
stmt = (select(Payment).options(selectinload(Payment.user))
.where(Payment.status == 'succeeded')
.order_by(Payment.created_at.desc()))
result = await session.execute(stmt)
return result.scalars().all()
@@ -155,3 +156,81 @@ async def update_provider_payment_and_status(
f"Payment record with DB ID {payment_db_id} not found for provider update."
)
return payment
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
from sqlalchemy import and_, text
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=7)
month_start = today_start - timedelta(days=30)
# Today's revenue
stmt_today = select(func.sum(Payment.amount)).where(
and_(
Payment.status == 'succeeded',
Payment.created_at >= today_start
)
)
today_revenue = await session.execute(stmt_today)
today_amount = today_revenue.scalar() or 0
# Week revenue
stmt_week = select(func.sum(Payment.amount)).where(
and_(
Payment.status == 'succeeded',
Payment.created_at >= week_start
)
)
week_revenue = await session.execute(stmt_week)
week_amount = week_revenue.scalar() or 0
# Month revenue
stmt_month = select(func.sum(Payment.amount)).where(
and_(
Payment.status == 'succeeded',
Payment.created_at >= month_start
)
)
month_revenue = await session.execute(stmt_month)
month_amount = month_revenue.scalar() or 0
# All time revenue
stmt_all = select(func.sum(Payment.amount)).where(Payment.status == 'succeeded')
all_revenue = await session.execute(stmt_all)
all_amount = all_revenue.scalar() or 0
# Count of successful payments today
stmt_count_today = select(func.count(Payment.payment_id)).where(
and_(
Payment.status == 'succeeded',
Payment.created_at >= today_start
)
)
today_count = await session.execute(stmt_count_today)
today_payments_count = today_count.scalar() or 0
return {
"today_revenue": float(today_amount),
"week_revenue": float(week_amount),
"month_revenue": float(month_amount),
"all_time_revenue": float(all_amount),
"today_payments_count": today_payments_count
}
async def get_last_tribute_payment_duration(session: AsyncSession, user_id: int) -> Optional[int]:
"""Get duration in months from the last successful tribute payment for a user."""
stmt = select(Payment.subscription_duration_months).where(
and_(
Payment.user_id == user_id,
Payment.provider == 'tribute',
Payment.status == 'succeeded'
)
).order_by(Payment.created_at.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
+48 -4
View File
@@ -10,7 +10,6 @@ from db.models import PromoCode, PromoCodeActivation, User, Payment
async def create_promo_code(session: AsyncSession,
promo_data: Dict[str, Any]) -> PromoCode:
new_promo = PromoCode(**promo_data)
session.add(new_promo)
await session.flush()
@@ -26,6 +25,13 @@ async def get_promo_code_by_id(session: AsyncSession,
return await session.get(PromoCode, promo_code_id)
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get promo code by code string (regardless of active status)"""
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_active_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
stmt = select(PromoCode).where(
@@ -49,6 +55,42 @@ async def get_all_active_promo_codes(session: AsyncSession,
return result.scalars().all()
async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 50,
offset: int = 0) -> List[PromoCode]:
"""Get all promo codes (active and inactive) with pagination for management"""
stmt = (select(PromoCode).order_by(
PromoCode.created_at.desc()).limit(limit).offset(offset))
result = await session.execute(stmt)
return result.scalars().all()
async def get_promo_codes_count(session: AsyncSession) -> int:
"""Get total count of all promo codes"""
from sqlalchemy import func
stmt = select(func.count(PromoCode.promo_code_id))
result = await session.execute(stmt)
return result.scalar_one()
async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0) -> List[PromoCodeActivation]:
"""Get activation history for a specific promo code with optional pagination."""
stmt = (select(PromoCodeActivation)
.where(PromoCodeActivation.promo_code_id == promo_code_id)
.order_by(PromoCodeActivation.activated_at.desc())
.offset(offset))
if limit is not None:
stmt = stmt.limit(limit)
result = await session.execute(stmt)
return result.scalars().all()
async def count_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> int:
"""Count total activations for a specific promo code."""
stmt = select(func.count()).select_from(PromoCodeActivation).where(PromoCodeActivation.promo_code_id == promo_code_id)
result = await session.execute(stmt)
return result.scalar_one()
async def update_promo_code(session: AsyncSession, promo_id: int,
update_data: Dict[str, Any]) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_id)
@@ -65,6 +107,11 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
# First, delete related activations due to foreign key constraint
activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations:
await session.delete(activation)
await session.delete(promo)
await session.flush()
return promo
@@ -90,7 +137,6 @@ async def increment_promo_code_usage(
async def get_user_activation_for_promo(
session: AsyncSession, promo_code_id: int,
user_id: int) -> Optional[PromoCodeActivation]:
stmt = select(PromoCodeActivation).where(
PromoCodeActivation.promo_code_id == promo_code_id,
PromoCodeActivation.user_id == user_id).limit(1)
@@ -103,7 +149,6 @@ async def record_promo_activation(
promo_code_id: int,
user_id: int,
payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]:
existing_activation = await get_user_activation_for_promo(
session, promo_code_id, user_id)
if existing_activation:
@@ -128,7 +173,6 @@ async def record_promo_activation(
logging.error(
f"Cannot record promo activation: Payment {payment_id} not found."
)
return None
activation_data = {
+43 -62
View File
@@ -31,27 +31,14 @@ async def get_subscription_by_panel_subscription_uuid(
return result.scalar_one_or_none()
async def create_subscription(session: AsyncSession,
sub_data: Dict[str, Any]) -> Subscription:
from .user_dal import get_user_by_id
if "user_id" not in sub_data or sub_data["user_id"] is None:
raise ValueError(
"user_id is required to create a subscription directly.")
user = await get_user_by_id(session, sub_data["user_id"])
if not user:
raise ValueError(
f"User with id {sub_data['user_id']} not found for creating subscription."
)
new_sub = Subscription(**sub_data)
session.add(new_sub)
await session.flush()
await session.refresh(new_sub)
logging.info(
f"Subscription {new_sub.subscription_id} created for user {new_sub.user_id}"
)
return new_sub
async def get_active_subscriptions_for_user(session: AsyncSession, user_id: int) -> List[Subscription]:
"""Get all active subscriptions for a user."""
stmt = select(Subscription).where(
Subscription.user_id == user_id,
Subscription.is_active == True
).order_by(Subscription.end_date.desc())
result = await session.execute(stmt)
return result.scalars().all()
async def update_subscription(
@@ -66,6 +53,29 @@ async def update_subscription(
return sub
async def set_user_subscriptions_cancelled_with_grace(
session: AsyncSession, user_id: int, grace_days: int = 1) -> int:
"""Mark all active user subscriptions as cancelled with a short grace period.
Sets end_date to now + grace_days, status_from_panel to 'CANCELLED', and
skip future notifications to reduce noise after cancellation.
Returns number of updated rows.
"""
from datetime import datetime, timezone, timedelta
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.values(
end_date=grace_end,
status_from_panel="CANCELLED",
skip_notifications=True,
)
)
result = await session.execute(stmt)
return result.rowcount or 0
async def upsert_subscription(session: AsyncSession,
sub_payload: Dict[str, Any]) -> Subscription:
panel_sub_uuid = sub_payload.get("panel_subscription_uuid")
@@ -144,6 +154,18 @@ async def deactivate_all_user_subscriptions(
return result.rowcount
async def delete_all_user_subscriptions(
session: AsyncSession, user_id: int) -> int:
"""Completely delete all user subscriptions (for trial reset)"""
stmt = delete(Subscription).where(Subscription.user_id == user_id)
result = await session.execute(stmt)
if result.rowcount > 0:
logging.info(
f"Deleted {result.rowcount} subscription records for user {user_id} for trial reset."
)
return result.rowcount
async def update_subscription_end_date(
session: AsyncSession, subscription_id: int,
new_end_date: datetime) -> Optional[Subscription]:
@@ -193,17 +215,6 @@ async def update_subscription_notification_time(
{"last_notification_sent": notification_time})
async def get_user_active_subscription_end_date_str(
session: AsyncSession, user_id: int) -> Optional[str]:
stmt = (select(Subscription.end_date).where(
Subscription.user_id == user_id, Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc)).order_by(
Subscription.end_date.desc()).limit(1))
result = await session.execute(stmt)
end_date_obj = result.scalar_one_or_none()
return end_date_obj.strftime('%Y-%m-%d') if end_date_obj else None
async def find_subscription_for_notification_update(
session: AsyncSession, user_id: int,
subscription_end_date_to_match: datetime) -> Optional[Subscription]:
@@ -222,34 +233,4 @@ async def find_subscription_for_notification_update(
return result.scalar_one_or_none()
async def set_skip_notifications_for_provider(
session: AsyncSession, user_id: int, provider: str,
skip: bool) -> int:
stmt = (update(Subscription).where(
Subscription.user_id == user_id,
Subscription.is_active == True,
Subscription.provider == provider).values(skip_notifications=skip))
result = await session.execute(stmt)
return result.rowcount
async def get_active_subscriptions_for_autorenew(
session: AsyncSession, provider: str,
days_threshold: int = 1,
require_skip_flag: bool = True) -> List[Subscription]:
"""Fetch active subscriptions nearing expiration for auto-renew logic."""
now_utc = datetime.now(timezone.utc)
threshold_date = now_utc + timedelta(days=days_threshold)
conditions = [
Subscription.provider == provider,
Subscription.is_active == True,
Subscription.end_date <= threshold_date,
]
if require_skip_flag:
conditions.append(Subscription.skip_notifications == True)
stmt = select(Subscription).where(*conditions)
result = await session.execute(stmt)
return result.scalars().all()
+73 -66
View File
@@ -30,20 +30,7 @@ async def get_user_by_panel_uuid(
return result.scalar_one_or_none()
async def get_user(
session: AsyncSession,
*,
user_id: Optional[int] = None,
username: Optional[str] = None,
panel_uuid: Optional[str] = None,
) -> Optional[User]:
if user_id is not None:
return await get_user_by_id(session, user_id)
if username is not None:
return await get_user_by_username(session, username)
if panel_uuid is not None:
return await get_user_by_panel_uuid(session, panel_uuid)
return None
## Removed unused generic get_user helper to keep DAL explicit and simple
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
@@ -81,36 +68,15 @@ async def update_user_language(
return result.rowcount > 0
async def set_user_ban_status(
session: AsyncSession, user_id: int, is_banned: bool
) -> bool:
user = await get_user_by_id(session, user_id)
if user:
user.is_banned = is_banned
await session.flush()
await session.refresh(user)
return True
return False
async def get_banned_users_paginated(
session: AsyncSession, limit: int, offset: int
) -> Tuple[List[User], int]:
stmt_users = (
async def get_banned_users(session: AsyncSession) -> List[User]:
"""Get all banned users"""
stmt = (
select(User)
.where(User.is_banned == True)
.order_by(User.registration_date.desc())
.limit(limit)
.offset(offset)
)
result_users = await session.execute(stmt_users)
users_list = result_users.scalars().all()
stmt_count = select(func.count()).select_from(User).where(User.is_banned == True)
result_count = await session.execute(stmt_count)
total_banned = result_count.scalar_one()
return users_list, total_banned
result = await session.execute(stmt)
return result.scalars().all()
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
@@ -119,33 +85,74 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i
return result.scalars().all()
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
total_users_stmt = select(func.count(User.user_id)).select_from(User)
banned_users_stmt = (
select(func.count(User.user_id)).select_from(User).where(User.is_banned == True)
)
active_subs_stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(Subscription.is_active == True)
.where(Subscription.end_date > datetime.now())
)
total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0
banned_users = (await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
active_subs_users = (
await session.execute(active_subs_stmt)
).scalar_one_or_none() or 0
return {
"total_users": total_users,
"banned_users": banned_users,
"users_with_active_subscriptions": active_subs_users,
}
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
stmt = select(User).where(User.panel_user_uuid.is_not(None))
result = await session.execute(stmt)
return result.scalars().all()
async def get_enhanced_user_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive user statistics including active users, trial users, etc."""
from datetime import datetime, timedelta
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
# Total users
total_users_stmt = select(func.count(User.user_id))
total_users = (await session.execute(total_users_stmt)).scalar() or 0
# Banned users
banned_users_stmt = select(func.count(User.user_id)).where(User.is_banned == True)
banned_users = (await session.execute(banned_users_stmt)).scalar() or 0
# Active users today (users with login activity - for now using registration as proxy)
active_today_stmt = select(func.count(User.user_id)).where(
User.registration_date >= today_start
)
active_today = (await session.execute(active_today_stmt)).scalar() or 0
# Users with active paid subscriptions
paid_subs_stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.provider.is_not(None) # Not trial
)
)
)
paid_subs_users = (await session.execute(paid_subs_stmt)).scalar() or 0
# Users on trial period
trial_subs_stmt = (
select(func.count(func.distinct(Subscription.user_id)))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
Subscription.provider.is_(None) # Trial subscriptions
)
)
)
trial_users = (await session.execute(trial_subs_stmt)).scalar() or 0
# Inactive users (no active subscription)
inactive_users = total_users - paid_subs_users - trial_users - banned_users
# Users attracted via referral
referral_users_stmt = select(func.count(User.user_id)).where(User.referred_by_id.is_not(None))
referral_users = (await session.execute(referral_users_stmt)).scalar() or 0
return {
"total_users": total_users,
"banned_users": banned_users,
"active_today": active_today,
"paid_subscriptions": paid_subs_users,
"trial_users": trial_users,
"inactive_users": max(0, inactive_users),
"referral_users": referral_users
}
+305 -16
View File
@@ -16,6 +16,7 @@
"choose_language": "Choose language / Выберите язык:",
"language_set_alert": "Language changed!",
"error_occurred_try_again": "An error occurred, please try again.",
"error_try_again": "Please try again.",
"error_displaying_menu": "Error displaying menu.",
"main_menu_unknown_action": "Unknown action.",
@@ -77,11 +78,13 @@
"no_button": "No",
"trial_cancelled_short": "Activation cancelled.",
"referral_program_info_new": "Invite friends and earn bonuses!\n\nYour referral link:\n<code>{referral_link}</code>\n\n<b>Bonuses:</b>{bonus_details}",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"no_bonus_days": "0",
"referral_no_bonuses_configured": "\nNo referral bonuses configured.",
"referral_link_for_copying_reminder": "The link is above. Press and hold to copy.",
"referral_share_message_button": "📩 Message for friend",
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"friend_placeholder": "friend",
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
@@ -92,23 +95,56 @@
"admin_stats_button": "📊 Statistics",
"admin_broadcast_button": "📢 Broadcast",
"admin_create_promo_button": "🎁 Create Promo",
"admin_manage_promos_button": "🛠 Manage Promos",
"admin_view_promos_button": "👀 Promo List",
"admin_ban_user_button": "🚫 Ban User",
"admin_unban_user_button": "✅ Unban User",
"admin_create_bulk_promo_button": "📦 Bulk Create",
"admin_manage_promos_button": "🛠 Manage",
"admin_view_promos_button": "👀 List",
"admin_ban_user_button": "🚫 Ban",
"admin_unban_user_button": "✅ Unban",
"admin_view_banned_users_button": "📜 Ban List",
"admin_view_logs_menu_button": "📄 Logs",
"admin_sync_panel_button": "🔄 Sync",
"admin_unknown_action": "Unknown admin action.",
"admin_stats_and_monitoring_section": "📊 Statistics",
"admin_user_management_section": "👥 Users",
"admin_promo_marketing_section": "🎁 Promos",
"admin_system_functions_section": "⚙️ System",
"admin_ban_management_section": "🚫 Bans",
"admin_users_management_button": "👤 Management",
"back_to_user_management_button": "⬅️ To Users",
"admin_action_cancelled_default": "Action cancelled. Returning to menu.",
"admin_action_cancelled_default_alert": "Action cancelled",
"back_to_admin_panel_button": "⬅️ Back to Admin",
"back_to_admin_panel_button": "⬅️ To Admin",
"admin_stats_header": "📊 Bot Statistics",
"admin_enhanced_users_stats_header": "Users",
"admin_financial_stats_header": "Financial Statistics",
"admin_stats_users": "👥 Users: Total - {total_users}, Banned - {banned_users}, Active Subs - {active_subs}",
"admin_stats_recent_payments_header": "Recent Payments:",
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "No payments found yet.",
"admin_view_payments_button": "💰 Payments",
"admin_payments_header": "💰 <b>All Payments</b>",
"admin_no_payments_found": "No payments found.",
"admin_export_payments_csv": "📊 Export CSV",
"admin_refresh_payments": "🔄 Refresh",
"admin_no_payments_to_export": "No payments to export.",
"admin_payments_export_success": "📊 Payments export completed!\nTotal records: {count}",
"admin_export_sent": "File sent!",
"admin_csv_payment_id": "ID",
"admin_csv_user_id": "User ID",
"admin_csv_username": "Username",
"admin_csv_first_name": "First Name",
"admin_csv_amount": "Amount",
"admin_csv_currency": "Currency",
"admin_csv_provider": "Provider",
"admin_csv_status": "Status",
"admin_csv_description": "Description",
"admin_csv_months": "Months",
"admin_csv_created_at": "Created At",
"admin_csv_provider_payment_id": "Provider Payment ID",
"admin_stats_last_sync_header": "Last Panel Sync:",
"admin_stats_sync_time": "Time",
"admin_stats_sync_status": "Status",
@@ -118,7 +154,7 @@
"admin_sync_status_never_run": "Panel sync never run.",
"admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):",
"admin_broadcast_confirm_prompt": "You are about to send the following message (first 200 characters):\n\n{message_preview}\n\nConfirm sending?",
"admin_broadcast_confirm_prompt": "You are about to send the following message:\n\n{message_preview}\n\nConfirm sending?",
"confirm_broadcast_send_button": "✅ Send",
"cancel_broadcast_button": "❌ Cancel",
"admin_broadcast_sending_started": "Starting broadcast...",
@@ -132,6 +168,8 @@
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
"admin_promo_invalid_code_format": "Code must be 330 alphanumeric characters.",
"admin_promo_invalid_bonus_days": "Bonus days must be a positive number.",
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_invalid_values": "Invalid values. {error}",
@@ -148,9 +186,20 @@
"admin_promo_edit_button": "✏️ Edit",
"admin_promo_delete_button": "🗑 Delete",
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
"admin_promo_updated_success": "Promo <code>{code}</code> updated.",
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.",
"admin_promo_updated_success": "Promo {code} updated.",
"admin_promo_deleted_success": "Promo {code} deleted.",
"admin_promo_not_found": "Promo not found.",
"admin_promo_export_csv_button": "📄 Export to CSV",
"admin_promo_export_caption": "📄 Activations for promo code {code}",
"admin_promo_edit_select_field": "Select a field to edit:",
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
"admin_promo_edit_success": "Promo code updated successfully.",
"admin_promo_invalid_input": "Invalid input, please try again.",
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
@@ -193,6 +242,7 @@
"admin_logs_menu_title": "Logs Menu:",
"admin_view_all_logs_button": "📜 All Message Logs",
"admin_view_user_logs_prompt_button": "👤 User Logs",
"admin_export_logs_csv_button": "📄 Export to CSV",
"admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):",
"admin_no_logs_found": "No logs found.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
@@ -201,11 +251,11 @@
"admin_log_user_not_found": "User \"{input}\" not found in bot database.",
"admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):",
"sync_started": "🔄 Starting data sync with panel...",
"sync_failed": " Sync with panel failed. Details: {details}",
"sync_completed": " Sync with panel completed. Status: {status}. Details: {details}",
"sync_completed_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.",
"sync_completed_with_errors_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.\nErrors: {errors_count}.\n\nFirst errors:\n{error_details_preview}",
"sync_started_simple": "🔄 Starting synchronization...",
"sync_success_simple": " Synchronization completed successfully",
"sync_failed_simple": " Synchronization failed",
"sync_errors_simple": "⚠️ Synchronization completed with errors ({errors_count} errors)",
"sync_critical_error": "❌ Critical synchronization error",
"no_errors_placeholder": "none",
"admin_sync_initiated_from_panel": "Sync initiated...",
"admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?",
@@ -218,10 +268,249 @@
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"tribute_subscription_cancelled": "🚨 <b>Subscription Cancelled</b>\n\nYour Tribute subscription has been cancelled. You have 24 hours to restore access, after which the subscription will be blocked.\n\nTo renew your subscription, press the button below.",
"tribute_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour Tribute subscription has been automatically renewed for {months} months.\nNew expiration date: {end_date}",
"admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.",
"admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).",
"error_unknown": "An unknown error occurred."
"error_unknown": "An unknown error occurred.",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"admin_user_card_title": "User Card",
"admin_user_subscription_info": "Subscription Information:",
"admin_user_reset_trial_button": "🔄 Reset Trial",
"admin_user_add_subscription_button": " Add Days",
"admin_user_toggle_ban_button": "🚫 Block/Unblock",
"admin_user_send_message_button": "✉️ Send Message",
"admin_user_view_logs_button": "📜 User Actions",
"admin_user_refresh_button": "🔄 Refresh",
"admin_user_search_new_button": "🔍 Find Another",
"admin_user_view_all_logs_button": "📋 All Actions",
"admin_user_back_to_card_button": "🔙 Back to Card",
"admin_user_not_found": "❌ User not found: {input}",
"admin_user_not_found_action": "User not found",
"admin_user_card_error": "❌ Error displaying user card",
"admin_user_trial_reset_success": "✅ Trial reset! User can activate trial again.",
"admin_user_trial_reset_error": "❌ Error resetting trial",
"admin_user_add_subscription_prompt": " Adding subscription days for user {user_id}\n\nEnter number of days to add:",
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
"admin_user_subscription_added_error": "❌ Error adding subscription days",
"admin_user_ban_toggle_success": "✅ User {status}",
"admin_user_ban_toggle_error": "❌ Error changing ban status",
"admin_user_ban_success": "✅ User {input} has been banned",
"admin_user_unban_success": "✅ User {input} has been unbanned",
"admin_user_ban_error": "❌ Error banning user",
"admin_user_unban_error": "❌ Error unbanning user",
"admin_user_not_banned": "⚠️ User is not banned",
"admin_user_send_message_prompt": "✉️ Sending message to user {user_id}\n\nEnter message text:",
"admin_user_message_too_long": "❌ Message too long (maximum 4000 characters)",
"admin_user_message_sent_success": "✅ Message sent to user {user_id}",
"admin_user_message_sent_error": "❌ Error sending message",
"admin_user_no_logs": "📜 User has no actions",
"admin_user_logs_error": "❌ Error loading user actions",
"admin_direct_message_signature": "\n\n---\n💬 Message from administrator",
"inline_user_stats_message": "👥 <b>User Statistics</b>\n\n📊 Total: <b>{total}</b>\n📈 Active today: <b>{active_today}</b>\n💳 With paid subscription: <b>{paid}</b>\n🆓 On trial period: <b>{trial}</b>\n😴 Inactive: <b>{inactive}</b>\n🚫 Banned: <b>{banned}</b>\n🎁 Via referral program: <b>{referral}</b>",
"inline_admin_user_stats_title": "👥 User Statistics",
"inline_admin_user_stats_desc": "Total: {total}, Active: {paid}",
"inline_financial_stats_message": "💰 <b>Financial Statistics</b>\n\n📅 Today: <b>{today:.2f} RUB</b>\n ({today_count} payments)\n📅 Week: <b>{week:.2f} RUB</b>\n📅 Month: <b>{month:.2f} RUB</b>\n🏆 All time: <b>{all_time:.2f} RUB</b>",
"inline_admin_financial_stats_title": "💰 Financial Statistics",
"inline_admin_financial_stats_desc": "Today: {today:.2f} RUB",
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
"inline_admin_system_stats_title": "🖥 System Statistics",
"inline_admin_system_stats_desc": "Online: {online}, Offline: {offline}",
"inline_admin_help_message": "🤖 <b>Bot Inline Mode</b>\n\n📱 <b>Available commands:</b>\n\n🎁 <b>ref</b> - share referral link\n👥 <b>stat</b> - user statistics\n💰 <b>financial</b> - financial statistics\n🖥 <b>system</b> - system statistics\n\n💡 Just type @{bot_username} and start typing a command in any chat!",
"inline_admin_help_title": "🤖 Inline Help (Admin)",
"inline_admin_help_desc": "Available commands: ref, stat, financial, system",
"inline_user_help_message": "🤖 <b>Bot Inline Mode</b>\n\n📱 <b>Available commands:</b>\n\n🎁 <b>ref</b> - share referral link\n\n💡 Just type @{bot_username} and start typing 'ref' in any chat!",
"inline_user_help_title": "🤖 Inline Help",
"inline_user_help_desc": "Available command: ref (referral link)",
"log_referral_suffix": " (referral from {referrer_id})",
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
"admin_general_cancel_operation": "Operation cancelled ❌",
"admin_promo_codes_menu_bulk_create": "📦 Bulk Creation",
"admin_bulk_promo_create_prompt": "📦 Bulk Promo Code Creation\n\nEnter data in format:\n<code>quantity|days|description</code>\n\nExample:\n<code>10|7|Weekly promo</code>",
"admin_bulk_promo_invalid_format": "❌ Invalid format. Use: quantity|days|description",
"admin_bulk_promo_created_success": "✅ Created {count} promo codes for {days} days!",
"admin_bulk_promo_codes_file": "📄 All created promo codes in file ({count} pcs.)",
"admin_promo_edit_step1_code": "✏️ <b>Editing Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nCurrent code: <b>{current_code}</b>\n\nEnter new promo code (3-30 characters, letters and numbers only) or send current one to keep:",
"admin_promo_edit_step2_bonus_days": "✏️ <b>Editing Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\nCurrent days: <b>{current_days}</b>\n\nEnter number of bonus days (1-365):",
"admin_promo_edit_step3_max_activations": "✏️ <b>Editing Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDays: <b>{days}</b>\nCurrent activations: <b>{current_max}</b>\n\nEnter maximum number of activations (1-10000):",
"admin_promo_edit_step4_validity": "✏️ <b>Editing Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDays: <b>{days}</b>\nActivations: <b>{max_act}</b>\nCurrent validity: <b>{current_validity}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_edit_unlimited_validity": "♾️ Unlimited",
"admin_promo_edit_set_validity": "⏰ Set Period",
"admin_promo_edit_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
"admin_promo_updated_success": "✅ Promo code updated!\n\n🎟 Code: <b>{code}</b>\n🎁 Bonus days: <b>{days}</b>\n🔢 Max activations: <b>{max_act}</b>\n⏰ Valid until: <b>{validity}</b>",
"admin_promo_update_failed": "❌ Error updating promo code",
"admin_logs_export_csv": "📄 Export to CSV",
"admin_logs_csv_export_started": "📄 Starting log export to CSV...",
"admin_logs_csv_export_success": "✅ Logs exported! File attached above.",
"admin_csv_header_log_id": "Log ID",
"admin_csv_header_timestamp": "Timestamp",
"admin_csv_header_user_id": "User ID",
"admin_csv_header_telegram_username": "Username",
"admin_csv_header_telegram_first_name": "First Name",
"admin_csv_header_event_type": "Event Type",
"admin_csv_header_content": "Content",
"admin_csv_header_is_admin_event": "Admin Event",
"admin_csv_header_target_user_id": "Target User ID",
"admin_csv_header_raw_update_preview": "Update Preview",
"admin_banned_users_empty": "📋 Banned Users\n\nList is empty",
"admin_banned_users_list": "📋 Banned Users ({count}):\n\n{users}",
"admin_panel_stats_header": "Panel Statistics",
"admin_bulk_promo_step1_quantity": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 1 of 4:</b> Quantity\n\nEnter the number of promo codes to create (1-1000):",
"admin_bulk_promo_step2_bonus_days": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nQuantity: <b>{quantity}</b>\n\nEnter the number of bonus days (1-365):",
"admin_bulk_promo_step3_max_activations": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nQuantity: <b>{quantity}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations for each promo code (1-10000):",
"admin_bulk_promo_step4_validity": "📦 <b>Bulk Promo Code Creation</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nQuantity: <b>{quantity}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for promo codes:",
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
"admin_bulk_promo_invalid_bonus_days": "❌ Bonus days must be between 1 and 365",
"admin_bulk_promo_invalid_max_activations": "❌ Max activations must be between 1 and 10000",
"admin_bulk_promo_unlimited_validity": "♾️ Unlimited",
"admin_bulk_promo_set_validity": "⏰ Set Period",
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_code_already_exists": "❌ A promo code with this code already exists",
"admin_promo_processing_error": "❌ Error processing promo code",
"admin_promo_unlimited_validity": "♾️ Unlimited",
"admin_promo_set_validity": "⏰ Set Period",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
"admin_promo_skip_description": "⏭️ Skip",
"admin_promo_description_too_long": "❌ Description must not exceed 200 characters",
"admin_promo_creating": "⏳ Creating promo code...",
"admin_panel_back_button": "⬅️ Back",
"admin_bulk_promo_creation_failed": "❌ Error creating promo codes: {error}",
"admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Name:</b>",
"admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_language_label": "🌍 <b>Language:</b>",
"admin_user_registration_label": "📅 <b>Registration:</b>",
"admin_user_status_label": "🛡 <b>Status:</b>",
"admin_user_referral_label": "🎁 <b>Referred by:</b>",
"admin_user_panel_uuid_label": "🔗 <b>Panel UUID:</b>",
"admin_user_panel_status_label": "📊 <b>Panel Status:</b>",
"admin_user_traffic_label": "📊 <b>Traffic:</b>",
"admin_user_subscription_label": "💼 <b>Subscription:</b>",
"admin_user_trial_label": "🏡 <b>Trial:</b>",
"admin_user_status_banned": "🚫 Banned",
"admin_user_status_active": "✅ Active",
"admin_user_trial_used": "Used",
"admin_user_trial_not_used": "Not used",
"admin_user_ban_action_banned": "banned",
"admin_user_ban_action_unbanned": "unbanned",
"admin_user_na_value": "N/A",
"admin_user_subscription_active": "Active until {end_date}",
"admin_user_subscription_expired": "Expired {end_date}",
"admin_user_subscription_none": "No active subscription",
"admin_user_logs_count": "Total entries: {count}",
"admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
"admin_user_subscription_error": "Loading error",
"admin_bulk_promo_unique_generation_failed": "Failed to create unique promo code",
"admin_promo_management_button": "🎟 Promo Management",
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
"admin_promo_card_created": "📅 Created: <b>{created}</b>",
"admin_promo_card_created_by": "👤 Created by: <b>{creator}</b>",
"admin_promo_status_active": "✅ Active",
"admin_promo_status_inactive": "🚫 Inactive",
"admin_promo_status_activated": "activated",
"admin_promo_status_deactivated": "deactivated",
"admin_promo_status_expired": "⏰ Expired",
"admin_promo_status_used_up": "🔄 Used up",
"admin_promo_toggle_status_button": "🔄 On/Off",
"admin_promo_view_activations_button": "📋 Activations",
"admin_promo_back_to_list_button": "⬅️ Back to list",
"admin_promo_toggle_success": "✅ Promo code {code} {status}",
"admin_promo_toggle_failed": "❌ Error changing promo code status",
"admin_promo_no_activations": "📋 <b>Activations of promo code: {code}</b>\n\n❌ No activations found",
"admin_promo_activations_header": "📋 <b>Activations of promo code: {code}</b>\n\n",
"admin_promo_activation_item": "👤 User ID: <b>{user_id}</b>\n📅 Date: <b>{date}</b>\n",
"admin_promo_back_to_detail_button": "⬅️ Back to promo",
"admin_panel_online_label": "Online",
"admin_panel_active_label": "Active",
"admin_panel_disabled_label": "Disabled",
"admin_panel_offline_label": "Offline",
"admin_panel_expired_label": "Expired",
"admin_panel_limited_label": "Limited",
"admin_panel_total_users_label": "Total users",
"admin_panel_cpu_usage_label": "CPU Usage",
"admin_panel_memory_usage_label": "RAM Usage",
"admin_panel_traffic_today_label": "Traffic today",
"admin_panel_traffic_week_label": "Traffic this week",
"admin_panel_traffic_month_label": "Traffic this month",
"admin_panel_nodes_label": "Active nodes",
"admin_panel_system_stats_error": "Error getting system statistics",
"admin_panel_bandwidth_stats_error": "Error getting bandwidth statistics",
"admin_panel_nodes_stats_error": "Error getting nodes statistics",
"admin_panel_stats_fetch_error": "Error fetching panel data",
"admin_panel_stats_error_details": "Details",
"inline_referral_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"inline_referral_title": "Referral link",
"inline_referral_description": "Share referral link to get bonuses",
"inline_stats_title": "Bot statistics",
"inline_stats_description": "Total: {total}, Active: {active}",
"inline_financial_title": "Financial statistics",
"inline_financial_description": "Today: {today} RUB",
"inline_system_title": "System statistics",
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
"inline_admin_help_title": "Commands help",
"inline_admin_help_description": "Available commands: ref, stat, financial, system",
"admin_user_stats_total_label": "Total",
"admin_user_stats_paid_subs_label": "With paid subscription",
"admin_user_stats_trial_label": "On trial period",
"admin_user_stats_inactive_label": "Inactive",
"admin_user_stats_banned_label": "Banned",
"admin_user_stats_referral_label": "Attracted via referral program",
"admin_financial_today_label": "Today",
"admin_financial_week_label": "This week",
"admin_financial_month_label": "This month",
"admin_financial_all_time_label": "All time",
"admin_financial_payments_label": "payments"
}
+311 -13
View File
@@ -16,6 +16,7 @@
"choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!",
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
"error_try_again": "Попробуйте еще раз.",
"error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.",
@@ -77,11 +78,13 @@
"no_button": "Нет",
"trial_cancelled_short": "Активация отменена.",
"referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n<code>{referral_link}</code>\n\n<b>Бонусы:</b>{bonus_details}",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"no_bonus_days": "0",
"referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.",
"referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.",
"referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"friend_placeholder": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
@@ -92,23 +95,56 @@
"admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка",
"admin_create_promo_button": "🎁 Создать промо",
"admin_manage_promos_button": "🛠 Управление промо",
"admin_view_promos_button": "👀 Список промо",
"admin_create_bulk_promo_button": "📦 Массовое создание",
"admin_manage_promos_button": "🛠 Управление",
"admin_view_promos_button": "👀 Список",
"admin_ban_user_button": "🚫 Забанить",
"admin_unban_user_button": "✅ Разбанить",
"admin_view_banned_users_button": "📜 Бан-лист",
"admin_view_logs_menu_button": "📄 Логи",
"admin_sync_panel_button": "🔄 Синхронизация",
"admin_unknown_action": "Неизвестное действие администратора.",
"admin_stats_and_monitoring_section": "📊 Статистика",
"admin_user_management_section": "👥 Пользователи",
"admin_promo_marketing_section": "🎁 Промокоды",
"admin_system_functions_section": "⚙️ Система",
"admin_ban_management_section": "🚫 Блокировки",
"admin_users_management_button": "👤 Управление",
"back_to_user_management_button": "⬅️ К пользователям",
"admin_action_cancelled_default": "Действие отменено. Возврат в меню.",
"admin_action_cancelled_default_alert": "Действие отменено",
"back_to_admin_panel_button": "⬅️ В админку",
"admin_stats_header": "📊 Статистика Бота",
"admin_enhanced_users_stats_header": "Пользователи",
"admin_financial_stats_header": "Финансовая статистика",
"admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}",
"admin_stats_recent_payments_header": "Последние платежи:",
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "Платежей пока нет.",
"admin_view_payments_button": "💰 Платежи",
"admin_payments_header": "💰 <b>Все платежи</b>",
"admin_no_payments_found": "Платежи не найдены.",
"admin_export_payments_csv": "📊 Экспорт CSV",
"admin_refresh_payments": "🔄 Обновить",
"admin_no_payments_to_export": "Нет платежей для экспорта.",
"admin_payments_export_success": "📊 Экспорт платежей завершен!\nВсего записей: {count}",
"admin_export_sent": "Файл отправлен!",
"admin_csv_payment_id": "ID",
"admin_csv_user_id": "User ID",
"admin_csv_username": "Логин",
"admin_csv_first_name": "Имя",
"admin_csv_amount": "Сумма",
"admin_csv_currency": "Валюта",
"admin_csv_provider": "Платежная система",
"admin_csv_status": "Статус",
"admin_csv_description": "Описание",
"admin_csv_months": "Месяцев",
"admin_csv_created_at": "Дата создания",
"admin_csv_provider_payment_id": "ID платежа в системе",
"admin_stats_last_sync_header": "Последняя синхронизация с панелью:",
"admin_stats_sync_time": "Время",
"admin_stats_sync_status": "Статус",
@@ -118,7 +154,7 @@
"admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?",
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?",
"confirm_broadcast_send_button": "✅ Отправить",
"cancel_broadcast_button": "❌ Отмена",
"admin_broadcast_sending_started": "Начинаю рассылку...",
@@ -132,11 +168,23 @@
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_invalid_values": "Неверные значения. {error}",
"admin_promo_invalid_format_general": "Ошибка парсинга деталей промокода. Проверьте формат.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
"admin_back_to_panel": "⬅️ В панель",
"admin_promo_unlimited": "♾️ Неограниченно",
"admin_bulk_promo_created_title": "📦 Массовое создание завершено",
"admin_bulk_promo_created_stats": "📊 Создано: <b>{created}</b> из <b>{total}</b>",
"admin_bulk_promo_settings": "🎁 Бонусные дни: <b>{bonus_days}</b>\n📊 Макс. активаций: <b>{max_activations}</b>\n⏰ Срок действия: <b>{validity}</b>",
"admin_promo_list_page_info": "Страница {current}/{total} ({count} промокодов)",
"admin_queue_status_button": "📊 Статус очередей",
"admin_queue_status_title": "📊 Статус очередей сообщений",
"admin_queue_status_info": "📤 <b>Очереди сообщений:</b>\n\n👥 <b>Пользователи (25 сообщ/сек):</b>\n 📋 В очереди: {user_queue_size}\n 🔄 Обрабатывается: {user_processing}\n 📈 Отправлено за минуту: {user_recent}\n\n📢 <b>Группы/каналы (15 сообщ/мин):</b>\n 📋 В очереди: {group_queue_size}\n 🔄 Обрабатывается: {group_processing}\n 📈 Отправлено за минуту: {group_recent}",
"admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод <code>{code}</code> уже существует.",
"admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
"admin_active_promos_list_header": "Активные промокоды:",
@@ -148,9 +196,20 @@
"admin_promo_edit_button": "✏️ Изменить",
"admin_promo_delete_button": "🗑 Удалить",
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.",
"admin_promo_updated_success": "Промокод {code} обновлен.",
"admin_promo_deleted_success": "Промокод {code} удален.",
"admin_promo_not_found": "Промокод не найден.",
"admin_promo_export_csv_button": "📄 Экспорт в CSV",
"admin_promo_export_caption": "📄 Активации промокода {code}",
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
"admin_promo_edit_success": "Промокод успешно обновлен.",
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
@@ -193,6 +252,7 @@
"admin_logs_menu_title": "Меню логов:",
"admin_view_all_logs_button": "📜 Все логи сообщений",
"admin_view_user_logs_prompt_button": "👤 Логи пользователя",
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
"admin_no_logs_found": "Логи не найдены.",
"admin_log_entry_format": "<code>{timestamp_str}</code> - <b>{user_display}</b> (ID: {user_id})\n <i>{event_type}</i>: {content_preview}",
@@ -201,11 +261,11 @@
"admin_log_user_not_found": "Пользователь по запросу \"{input}\" не найден в базе данных бота.",
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
"sync_started": "🔄 Начинаю синхронизацию данных с панелью...",
"sync_failed": "❌ Ошибка синхронизации с панелью. Детали: {details}",
"sync_completed": " Синхронизация с панелью завершена. Статус: {status}. Детали: {details}",
"sync_completed_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.",
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
"sync_started_simple": "🔄 Начинаю синхронизацию...",
"sync_success_simple": "Синхронизация успешно завершена",
"sync_failed_simple": " Синхронизация завершилась с ошибкой",
"sync_errors_simple": "⚠️ Синхронизация завершена с ошибками ({errors_count} ошибок)",
"sync_critical_error": "❌ Критическая ошибка синхронизации",
"no_errors_placeholder": "нет",
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
@@ -218,10 +278,248 @@
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.",
"admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)",
"error_unknown": "Произошла неизвестная ошибка."
"error_unknown": "Произошла неизвестная ошибка.",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
"admin_user_card_title": "Карточка пользователя",
"admin_user_subscription_info": "Информация о подписке:",
"admin_user_reset_trial_button": "🔄 Сбросить триал",
"admin_user_add_subscription_button": "➕ Добавить дни",
"admin_user_toggle_ban_button": "🚫 Бан/Разбан",
"admin_user_send_message_button": "✉️ Сообщение",
"admin_user_view_logs_button": "📜 Логи",
"admin_user_refresh_button": "🔄 Обновить",
"admin_user_search_new_button": "🔍 Новый поиск",
"admin_user_view_all_logs_button": "📋 Все логи",
"admin_user_back_to_card_button": "🔙 К карточке",
"admin_user_not_found": "❌ Пользователь не найден: {input}",
"admin_user_not_found_action": "Пользователь не найден",
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
"admin_user_trial_reset_success": "✅ Триал сброшен! Пользователь может активировать триал заново.",
"admin_user_trial_reset_error": "❌ Ошибка сброса триала",
"admin_user_add_subscription_prompt": "➕ Добавление дней подписки для пользователя {user_id}\n\nВведите количество дней для добавления:",
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
"admin_user_unban_success": "✅ Пользователь {input} разблокирован",
"admin_user_ban_error": "❌ Ошибка блокировки пользователя",
"admin_user_unban_error": "❌ Ошибка разблокировки пользователя",
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
"admin_user_send_message_prompt": "✉️ Отправка сообщения пользователю {user_id}\n\nВведите текст сообщения:",
"admin_user_message_too_long": "❌ Сообщение слишком длинное (максимум 4000 символов)",
"admin_user_message_sent_success": "✅ Сообщение отправлено пользователю {user_id}",
"admin_user_message_sent_error": "❌ Ошибка отправки сообщения",
"admin_user_no_logs": "📜 У пользователя нет действий",
"admin_user_logs_error": "❌ Ошибка загрузки действий пользователя",
"admin_direct_message_signature": "\n\n---\n💬 Сообщение от администратора",
"inline_user_stats_message": "📊 <b>Статистика Бота</b>\n👥 Пользователи\n\n📊 Всего: <b>{total}</b>\n💳 С платной подпиской: <b>{paid}</b>\n🆓 На пробном периоде: <b>{trial}</b>\n😴 Неактивных: <b>{inactive}</b>\n🚫 Заблокированных: <b>{banned}</b>\n🎁 Привлечено по реферальной программе: <b>{referral}</b>",
"inline_user_stats_description": "Всего: {total}, Платных: {active}",
"inline_admin_user_stats_title": "👥 Статистика пользователей",
"inline_admin_user_stats_desc": "Всего: {total}, Активных: {paid}",
"inline_financial_stats_message": "💰 <b>Финансовая статистика</b>\n\n📅 За сегодня: <b>{today:.2f} RUB</b>\n ({today_count} платежей)\n📅 За неделю: <b>{week:.2f} RUB</b>\n📅 За месяц: <b>{month:.2f} RUB</b>\n🏆 За все время: <b>{all_time:.2f} RUB</b>",
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
"inline_admin_financial_stats_desc": "Сегодня: {today:.2f} RUB",
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
"inline_admin_system_stats_title": "🖥 Системная статистика",
"inline_admin_system_stats_desc": "Онлайн: {online}, Офлайн: {offline}",
"inline_admin_help_message": "🤖 <b>Inline режим бота</b>\n\n📱 <b>Доступные команды:</b>\n\n🎁 <b>реф/ref</b> - поделиться реферальной ссылкой\n👥 <b>стат/stat</b> - статистика пользователей\n💰 <b>финансы</b> - финансовая статистика\n🖥 <b>система</b> - системная статистика\n\n💡 Просто напишите @{bot_username} и начните вводить команду в любом чате!",
"inline_user_help_message": "🤖 <b>Inline режим бота</b>\n\n📱 <b>Доступные команды:</b>\n\n🎁 <b>реф/ref</b> - поделиться реферальной ссылкой\n\n💡 Просто напишите @{bot_username} и начните вводить 'реф' в любом чате!",
"inline_user_help_title": "🤖 Inline помощь",
"inline_user_help_desc": "Доступна команда: реф (реферальная ссылка)",
"log_referral_suffix": " (реферал от {referrer_id})",
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
"admin_general_cancel_operation": "Операция отменена ❌",
"admin_promo_codes_menu_bulk_create": "📦 Массовое создание",
"admin_bulk_promo_create_prompt": "📦 Массовое создание промокодов\n\nВведите данные в формате:\n<code>количество|дни|описание</code>\n\nПример:\n<code>10|7|Промо на неделю</code>",
"admin_bulk_promo_invalid_format": "❌ Неверный формат. Используйте: количество|дни|описание",
"admin_bulk_promo_created_success": "✅ Создано {count} промокодов на {days} дней!",
"admin_bulk_promo_codes_file": "📄 Все созданные промокоды в файле ({count} шт.)",
"admin_promo_edit_step1_code": "✏️ <b>Редактирование промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nТекущий код: <b>{current_code}</b>\n\nВведите новый код промокода (3-30 символов, только буквы и цифры) или отправьте текущий для сохранения:",
"admin_promo_edit_step2_bonus_days": "✏️ <b>Редактирование промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\nТекущие дни: <b>{current_days}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_edit_step3_max_activations": "✏️ <b>Редактирование промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nДни: <b>{days}</b>\nТекущие активации: <b>{current_max}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_edit_step4_validity": "✏️ <b>Редактирование промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nДни: <b>{days}</b>\nАктивации: <b>{max_act}</b>\nТекущий срок: <b>{current_validity}</b>\n\nВыберите срок действия промокода:",
"admin_promo_edit_unlimited_validity": "♾️ Неограниченно",
"admin_promo_edit_set_validity": "⏰ Установить срок",
"admin_promo_edit_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
"admin_promo_updated_success": "✅ Промокод обновлен!\n\n🎟 Код: <b>{code}</b>\n🎁 Бонусные дни: <b>{days}</b>\n🔢 Максимальные активации: <b>{max_act}</b>\n⏰ Действует до: <b>{validity}</b>",
"admin_promo_update_failed": "❌ Ошибка обновления промокода",
"admin_logs_export_csv": "📄 Экспорт в CSV",
"admin_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...",
"admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.",
"admin_csv_header_log_id": "ID Лога",
"admin_csv_header_timestamp": "Время",
"admin_csv_header_user_id": "ID Пользователя",
"admin_csv_header_telegram_username": "Username",
"admin_csv_header_telegram_first_name": "Имя",
"admin_csv_header_event_type": "Тип события",
"admin_csv_header_content": "Содержание",
"admin_csv_header_is_admin_event": "Админ событие",
"admin_csv_header_target_user_id": "ID Целевого юзера",
"admin_csv_header_raw_update_preview": "Превью обновления",
"admin_banned_users_empty": "📋 Заблокированные пользователи\n\nСписок пуст",
"admin_banned_users_list": "📋 Заблокированные пользователи ({count}):\n\n{users}",
"admin_panel_stats_header": "Статистика панели",
"admin_bulk_promo_step1_quantity": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 1 из 4:</b> Количество промокодов\n\nВведите количество промокодов для создания (1-1000):",
"admin_bulk_promo_step2_bonus_days": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКоличество: <b>{quantity}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_bulk_promo_step3_max_activations": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций для каждого промокода (1-10000):",
"admin_bulk_promo_step4_validity": "📦 <b>Массовое создание промокодов</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКоличество: <b>{quantity}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокодов:",
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_invalid_bonus_days": "❌ Бонусные дни должны быть от 1 до 365",
"admin_bulk_promo_invalid_max_activations": "❌ Максимальные активации должны быть от 1 до 10000",
"admin_bulk_promo_unlimited_validity": "♾️ Неограниченно",
"admin_bulk_promo_set_validity": "⏰ Установить срок",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_processing_error": "❌ Ошибка обработки промокода",
"admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_set_validity": "⏰ Установить срок",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
"admin_promo_skip_description": "⏭️ Пропустить",
"admin_promo_description_too_long": "❌ Описание не должно превышать 200 символов",
"admin_promo_creating": "⏳ Создаю промокод...",
"admin_panel_back_button": "⬅️ Назад",
"admin_bulk_promo_creation_failed": "❌ Ошибка создания промокодов: {error}",
"admin_user_id_label": "🆔 <b>ID:</b>",
"admin_user_name_label": "👤 <b>Имя:</b>",
"admin_user_username_label": "📱 <b>Username:</b>",
"admin_user_language_label": "🌍 <b>Язык:</b>",
"admin_user_registration_label": "📅 <b>Регистрация:</b>",
"admin_user_status_label": "🛡 <b>Статус:</b>",
"admin_user_referral_label": "🎁 <b>Привлечен по реферальной программе от:</b>",
"admin_user_panel_uuid_label": "🔗 <b>Panel UUID:</b>",
"admin_user_panel_status_label": "📊 <b>Статус на панели:</b>",
"admin_user_traffic_label": "📊 <b>Трафик:</b>",
"admin_user_subscription_label": "💼 <b>Подписка:</b>",
"admin_user_trial_label": "🏡 <b>Триал:</b>",
"admin_user_status_banned": "🚫 Заблокирован",
"admin_user_status_active": "✅ Активен",
"admin_user_trial_used": "Использовал",
"admin_user_trial_not_used": "Не использовал",
"admin_user_ban_action_banned": "заблокирован",
"admin_user_ban_action_unbanned": "разблокирован",
"admin_user_na_value": "N/A",
"admin_user_subscription_active": "Активна до {end_date}",
"admin_user_subscription_expired": "Истекла {end_date}",
"admin_user_subscription_none": "Нет активной подписки",
"admin_user_logs_count": "Всего записей: {count}",
"admin_user_logs_entry": "📅 {timestamp}\n📝 {event_type}: {content}",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
"admin_user_subscription_error": "Ошибка загрузки",
"admin_bulk_promo_unique_generation_failed": "Не удалось создать уникальный промокод",
"admin_promo_management_button": "🎟 Управление промокодами",
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",
"admin_promo_card_created": "📅 Создан: <b>{created}</b>",
"admin_promo_card_created_by": "👤 Создал: <b>{creator}</b>",
"admin_promo_status_active": "✅ Активен",
"admin_promo_status_inactive": "🚫 Неактивен",
"admin_promo_status_activated": "активирован",
"admin_promo_status_deactivated": "деактивирован",
"admin_promo_status_expired": "⏰ Истек",
"admin_promo_status_used_up": "🔄 Исчерпан",
"admin_promo_toggle_status_button": "🔄 Вкл/Выкл",
"admin_promo_view_activations_button": "📋 Активации",
"admin_promo_back_to_list_button": "⬅️ К списку",
"admin_promo_toggle_success": "✅ Промокод {code} {status}",
"admin_promo_toggle_failed": "❌ Ошибка изменения статуса промокода",
"admin_promo_no_activations": "📋 <b>Активации промокода: {code}</b>\n\n❌ Активаций не найдено",
"admin_promo_activations_header": "📋 <b>Активации промокода: {code}</b>\n\n",
"admin_promo_activation_item": "👤 User ID: <b>{user_id}</b>\n📅 Дата: <b>{date}</b>\n",
"admin_promo_back_to_detail_button": "⬅️ К промокоду",
"admin_panel_online_label": "Онлайн",
"admin_panel_active_label": "Активных",
"admin_panel_disabled_label": "Отключенных",
"admin_panel_offline_label": "Офлайн",
"admin_panel_expired_label": "Истекшие",
"admin_panel_limited_label": "Ограниченные",
"admin_panel_total_users_label": "Всего пользователей",
"admin_panel_cpu_usage_label": "Загрузка CPU",
"admin_panel_memory_usage_label": "Использование RAM",
"admin_panel_traffic_today_label": "Трафик сегодня",
"admin_panel_traffic_week_label": "Трафик за неделю",
"admin_panel_traffic_month_label": "Трафик за месяц",
"admin_panel_nodes_label": "Активных нод",
"admin_panel_system_stats_error": "Ошибка получения системной статистики",
"admin_panel_bandwidth_stats_error": "Ошибка получения статистики трафика",
"admin_panel_nodes_stats_error": "Ошибка получения статистики нод",
"admin_panel_stats_fetch_error": "Ошибка получения данных с панели",
"admin_panel_stats_error_details": "Детали",
"inline_referral_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"inline_referral_title": "Реферальная ссылка",
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
"inline_stats_title": "Статистика бота",
"inline_stats_description": "Всего: {total}, Активных: {active}",
"inline_financial_title": "Финансовая статистика",
"inline_financial_description": "Сегодня: {today} RUB",
"inline_system_title": "Системная статистика",
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
"inline_admin_help_title": "Помощь по командам",
"inline_admin_help_description": "Доступны команды: реф, стат, финансы, система",
"admin_user_stats_total_label": "Всего",
"admin_user_stats_paid_subs_label": "С платной подпиской",
"admin_user_stats_trial_label": "На пробном периоде",
"admin_user_stats_inactive_label": "Неактивных",
"admin_user_stats_banned_label": "Заблокированных",
"admin_user_stats_referral_label": "Привлечено по реферальной программе",
"admin_financial_today_label": "За сегодня",
"admin_financial_week_label": "За неделю",
"admin_financial_month_label": "За месяц",
"admin_financial_all_time_label": "За все время",
"admin_financial_payments_label": "платежей"
}