Compare commits

...
281 Commits
Author SHA1 Message Date
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
machka paslaandGitHub d672032201 Merge pull request #104 from machka-pasla/dev
Added freekassa, HWID Devices managment, mandatory sub to channel
2025-10-18 22:45:02 +03:00
machka pasla e826c4309d locales 2025-10-18 22:43:38 +03:00
machka pasla 9f525dc7da hwid devices integration 2025-10-18 22:39:56 +03:00
machka paslaandGitHub b880032b9b Merge pull request #103 from gldkru/feature/sub-devices-managment
Add 'My Devices' feature to user subscription management
2025-10-18 21:32:48 +03:00
Kirill Gladkikh 8f9484b6ec Add 'My Devices' feature to user subscription management
- Implemented a new command handler for displaying user devices.
- Added functionality to disconnect devices from the user's account.
- Updated subscription service to retrieve and manage device information.
- Enhanced inline keyboard to include device management options.
- Added new translations for device-related messages in English and Russian locales.
2025-10-18 02:27:19 +03:00
machka pasla c3622d9c2b channel require and db update 2025-10-17 09:36:25 +03:00
machka pasla 51ffbbfa1d pay from saved card 2025-10-16 22:04:04 +03:00
machka paslaandGitHub 394e8dcc6c Merge pull request #100 from zerodata731/freekassa-codex
Freekassa codex
2025-10-14 09:38:47 +03:00
machka paslaandGitHub 5f3fc13c2b Merge branch 'dev' into freekassa-codex 2025-10-14 09:38:39 +03:00
raufakchurin 4d43c9cf0f ORDER ID added to SUccess info 2025-10-14 11:28:41 +05:00
raufakchurin c891122064 before review 2025-10-14 10:31:55 +05:00
raufakchurin 59d07314e2 Удалили все личшние кроме СБП 2025-10-14 09:44:04 +05:00
machka pasla c438672ced fix 2025-10-13 10:16:30 +03:00
machka pasla 49c532e4db bruh 2025-10-13 10:06:58 +03:00
machka paslaandGitHub d50324d098 Merge pull request #99 from zerodata731/freekassa-codex
v_0.0
2025-10-13 10:05:45 +03:00
raufakchurin 52893a0629 v0.1 2025-10-12 15:27:48 +05:00
machka pasla 664cea447c errors when broadcast fix 2025-10-11 16:59:44 +03:00
machka pasla 5f50cfeaee broadcast real time logs 2025-10-11 16:53:26 +03:00
raufakchurin bdade47758 v_0.0 2025-10-11 18:06:49 +05:00
machka paslaandGitHub a3406451cb Merge pull request #98 from machka-pasla/dev
auto renew message with trial fix
2025-10-10 20:05:26 +03:00
machka pasla 9938671af9 auto renew message fix 2025-10-10 20:04:50 +03:00
machka paslaandGitHub 1d57c9be21 Merge pull request #96 from machka-pasla/dev
name filtration
2025-10-09 21:11:17 +03:00
machka pasla fde0f1b135 username filtration 2025-10-09 21:08:01 +03:00
Machka PaslaandGitHub 67df164498 Merge pull request #94 from machka-pasla/dev
tribute grace fix
2025-09-28 19:24:45 +03:00
machka-pasla 48e666f605 grace fix 2025-09-28 23:24:16 +07:00
machka-pasla 3cb15fa254 Add recent actions title to user management translations in English and Russian locales. 2025-09-16 15:19:36 +03:00
machka-pasla 2100949470 Add referral statistics to user card formatting. Updated user management to include referral service and added new translations for invited friends and purchased subscriptions in English and Russian locales. 2025-09-16 15:14:22 +03:00
Machka PaslaandGitHub c2a475b062 Merge pull request #90 from machka-pasla/dev
Bugfixes
2025-09-12 13:15:04 +03:00
machka-pasla 585cbe8fb6 Refactor subscription retrieval logic to limit results to the most recent active subscription. Updated query execution to return the first scalar result for improved performance. 2025-09-12 12:57:35 +03:00
machka-pasla a243ddcac8 Refactor ads deletion callback data parsing to improve clarity and maintainability. Updated the way campaign ID and back page are extracted from the callback data. 2025-09-11 23:05:57 +03:00
machka-pasla 357d7caf26 Refactor i18n handling in ads deletion prompt to use direct gettext calls. This improves language support and error messaging consistency. 2025-09-11 23:03:41 +03:00
machka-pasla 2713b3bae5 Add ad campaign deletion functionality with confirmation prompts and error handling. Updated inline keyboards and translations for delete actions. Enhanced subscription command handler to include fallback for config link URL. 2025-09-11 22:58:12 +03:00
Machka PaslaandGitHub 2215ee8ad2 Merge pull request #89 from machka-pasla/dev
Автопродление подписки при платеже через ЮКассу, микрофиксы и прочее-прочее
2025-09-11 09:58:05 +03:00
Machka PaslaandGitHub 1b6acff6d4 Merge pull request #88 from ProtoPes/add-dockerignore
Добавление файла .dockerignore
2025-09-08 22:36:03 +05:00
Machka PaslaandGitHub 25d9cc7446 Update .dockerignore
оставил локали, потому что не всегда идет прокидывание локалей через докер вольюм
2025-09-08 22:35:43 +05:00
ProtoPes e1e73ec736 Added ".dockerignore" to reduce the size of a docker image 2025-09-08 19:07:46 +03:00
Machka PaslaandGitHub cc21089f18 Merge pull request #84 from freize/patch-1
Correction of translation into Russian.
2025-09-07 23:10:20 +05:00
Machka PaslaandGitHub 46120210f1 Merge pull request #85 from freize/patch-2
Correction of the English translation.
2025-09-07 23:09:40 +05:00
Machka PaslaandGitHub df43283971 Update .env.example 2025-09-07 23:08:10 +05:00
Machka PaslaandGitHub a51b20ee54 Merge pull request #86 from freize/patch-3
Update .env.example
2025-09-07 23:06:26 +05:00
Machka PaslaandGitHub 0915022b52 Merge branch 'dev' into patch-3 2025-09-07 23:05:49 +05:00
FreizeandGitHub 11f5ec4a5b Update .env.example
Comments for bot configuration. Just in case.
2025-09-07 20:58:50 +03:00
FreizeandGitHub d187a79be6 Correction of the English translation. 2025-09-07 20:09:22 +03:00
FreizeandGitHub 7b05ad4cb1 Correction of translation into Russian. 2025-09-07 20:07:45 +03:00
machka-pasla 79ac5cb797 Enhance error handling and logging in CryptoPayService payment processing
- Added error handling for payment record creation and updates, ensuring database transactions are rolled back on failure.
- Implemented logging for errors during payment record creation and updates, improving traceability and debugging capabilities.
- Committed changes to ensure that payment records are properly persisted or rolled back in case of exceptions.
2025-09-07 12:55:54 +05:00
machka-pasla c208541525 Add autorenew cancellation handler and logging for subscription service
- Implemented a new callback handler to allow users to cancel auto-renewal of their subscriptions via a webhook button.
- Added logging to track subscription status and auto-renew settings during webhook checks, improving monitoring and debugging capabilities.
- Enhanced user feedback with appropriate alerts for unsupported actions and confirmation of updates to subscription settings.
2025-09-05 22:10:56 +03:00
machka-pasla 2b592e8abe Implement auto-renew confirmation and cancellation features in subscription module
- Added confirmation popups for enabling and disabling auto-renewal, enhancing user interaction and clarity.
- Introduced inline keyboard options for auto-renewal management, allowing users to confirm their choices easily.
- Updated notification messages to inform users about upcoming automatic charges and provide cancellation options.
- Enhanced localization files to include new strings for auto-renewal features in both English and Russian.
2025-09-05 22:02:52 +03:00
machka-pasla 43de4cc08b Add cryptocurrency and stars payment handlers to subscription module
- Implemented new callback handlers for cryptocurrency and stars payments, enhancing payment options for users.
- Integrated error handling and user feedback mechanisms for payment processes, improving overall user experience.
- Updated the pre-checkout and successful payment handling to accommodate new payment methods, ensuring seamless transaction processing.
- Organized imports for better modularity and clarity in the payments module.
2025-09-05 15:46:50 +03:00
machka-pasla 9a6ff6f636 Refactor pagination logic in admin ads list keyboard
- Updated the pagination row to only display when multiple pages are available, improving the user interface for ad navigation.
- Enhanced the inline keyboard structure for better clarity and usability in the admin panel's ads management section.
2025-09-05 15:17:01 +03:00
machka-pasla 8113908a98 Implement pagination and detailed views for ad campaigns in admin panel
- Added pagination support for the ads list, allowing admins to navigate through multiple pages of campaigns.
- Introduced detailed views for individual ad campaigns, displaying comprehensive statistics and information.
- Enhanced the database access layer with new methods for counting and listing campaigns with pagination.
- Updated localization files to include new strings for the ads overview and campaign details.
- Improved the inline keyboard structure for better navigation within the ads management interface.
2025-09-05 15:08:52 +03:00
machka-pasla 81155c9151 Add ads creation action to admin panel
- Introduced a new action for starting the ad creation process in the admin panel, enhancing ad management capabilities.
- Organized imports related to ad handling for better modularity and clarity in the codebase.
2025-09-05 15:00:47 +03:00
machka-pasla 7fa670c1e8 Add ads menu handler to admin panel actions
- Introduced a new action for displaying the ads menu in the admin panel, enhancing the admin interface for ad management.
- Improved the organization of imports related to ad handling, ensuring better modularity in the codebase.
2025-09-05 14:58:20 +03:00
machka-pasla d94430c602 Enhance ad creation flow with state-based filtering
- Introduced StateFilter to the ads creation flow, ensuring that messages are processed only when the bot is in specific admin states.
- Improved the organization of imports by removing redundant imports and clarifying state management for ad-related interactions.
2025-09-05 14:55:54 +03:00
machka-pasla c6292a1c8e Add ad campaign management features and enhance user attribution
- Introduced ad campaign management functionality, including the ability to create campaigns and attribute users based on ad parameters.
- Updated user command handlers to process new ad-related parameters and log user interactions with ad campaigns.
- Enhanced admin interfaces with new keyboard options for managing ads and displaying campaign information.
- Added database models for ad campaigns and attributions, improving data management for advertising features.
- Updated localization files to support new ad-related strings in both English and Russian.
2025-09-05 14:53:32 +03:00
machka-pasla d7d8409919 Refactor subscription command handler to improve user experience
- Updated the subscription command handler to dynamically adjust inline keyboard options based on user subscription status.
- Enhanced the mini-app connect button functionality, allowing access to additional features when the URL is configured.
- Improved the layout of inline keyboard buttons to prioritize new options, streamlining user interaction in subscription management.
2025-09-04 20:46:20 +03:00
machka-pasla 8f603ad51c Enhance subscription command handler with dynamic inline keyboard options
- Added support for a mini-app connect button in the subscription command handler, allowing users to access additional features if the URL is configured.
- Implemented an auto-renew toggle button and payment methods management button, improving user interaction based on subscription status and settings.
- Refactored inline keyboard construction to prepend new buttons above the existing markup, enhancing the user experience in subscription management.
2025-09-04 20:20:27 +03:00
machka-pasla 6f3b727cc7 Update .env.example to include YooKassa autopayments configuration
- Added new environment variables for enabling/disabling YooKassa autopayments, enhancing payment method management.
- Updated comments to clarify the relationship between autopayments and receipt fields, improving documentation for developers.
2025-09-04 20:08:37 +03:00
machka-pasla b7a723a088 Implement YooKassa autopayments feature toggle across payment and subscription handlers
- Introduced a global setting for enabling or disabling YooKassa autopayments, allowing for better control over payment method management and subscription renewals.
- Updated various handlers to check the autopayments setting before executing payment-related logic, ensuring that features are only available when enabled.
- Enhanced error handling to notify users when autopayments are disabled, improving user experience and clarity in payment operations.
- Refactored receipt generation logic to derive fields based on the autopayments setting, streamlining configuration management.
2025-09-04 20:03:47 +03:00
machka-pasla 807e3dadd3 Update database setup to include simple migrations and enhance .gitignore
- Added the `run_simple_migrations` function call in the database initialization process to ensure any missing columns are added during setup.
- Updated the .gitignore file to exclude the `models_old.py` file, improving project cleanliness.
2025-09-04 19:52:57 +03:00
machka-pasla f11ec6a676 Add re-exports for backward compatibility in subscription module
- Re-exported commonly used entrypoints from the core module to maintain backward compatibility.
- Ensured that existing functionality remains accessible after recent refactoring efforts in the subscription handling codebase.
2025-09-04 19:29:13 +03:00
machka-pasla d83f64c5fe Refactor subscription handling and payment method management
- Updated the user router to import the subscription router directly, preparing for future package separation.
- Removed the outdated subscription handler file, streamlining the codebase and improving maintainability.
- Introduced a new method for deleting user payment methods by provider ID, enhancing flexibility in payment management.
- Improved error handling and clarity in payment method operations, ensuring a smoother user experience.
2025-09-04 19:21:14 +03:00
machka-pasla 72eaed991a Improve payment method filtering logic in subscription history handler
- Added support for distinguishing between internal method IDs and direct provider payment method IDs, enhancing the accuracy of payment method filtering.
- Introduced a flag to track if a payment method filter was requested, allowing for clearer handling of cases where the method cannot be resolved.
- Updated error handling to ensure that empty payment histories are displayed appropriately when a filter is requested but cannot be matched.
2025-09-04 19:06:20 +03:00
machka-pasla 868c48ccb9 Remove outdated comments and fallback logic in payment method history handler
- Eliminated unnecessary comments regarding auto-renewal payment methods to enhance code clarity.
- Removed fallback logic for including auto-renew entries, streamlining the filtering process for user payments.
2025-09-04 19:00:03 +03:00
machka-pasla 2b855ed11b Refactor payment method ID handling in subscription history
- Updated the payment method history handler to improve the extraction of payment method IDs from callback data, enhancing reliability in filtering and navigation.
- Changed variable names for clarity, ensuring better readability and maintainability of the code.
- Improved error handling during ID extraction to prevent potential issues when no payment history is available.
2025-09-04 18:53:58 +03:00
machka-pasla 2bc7bea29d Enhance payment method history filtering and YooKassa integration
- Updated the payment method history handler to support filtering by specific saved payment methods, improving user experience when viewing payment logs.
- Increased the limit of recent payment logs retrieved from 10 to 30 for better visibility.
- Enhanced the YooKassa service to include detailed payment method information, including card details and last four digits, improving clarity in payment history.
- Refactored error handling in the YooKassa service to ensure robust fetching of payment information.
2025-09-04 18:49:31 +03:00
machka-pasla b6a2bf2329 Implement YooMoney payment method enhancements and localization updates
- Added functionality to save multiple YooMoney payment methods, marking the first entry as default.
- Refactored payment method display logic to standardize the presentation of YooMoney wallet details across various handlers.
- Introduced new localization strings for improved clarity in user-facing messages regarding payment methods.
- Enhanced error handling during payment method operations to ensure smoother user experience.
2025-09-04 18:42:48 +03:00
machka-pasla ba8be7c3f9 Standardize YooMoney wallet display across payment handlers and localization
- Updated the display logic for YooMoney wallet in payment and subscription handlers to ensure consistent naming and avoid leaking sensitive account information.
- Introduced a new localization string for wallet display, enhancing clarity in user-facing messages.
- Refactored relevant sections to improve overall code maintainability and user experience.
2025-09-04 18:26:15 +03:00
machka-pasla cb464059d9 Enhance payment method display for YooMoney integration
- Updated the logic for displaying payment method details, specifically for YooMoney, to include account number handling and last four digits extraction.
- Improved the consistency of payment method display across different handlers, ensuring a clearer user experience.
- Refactored relevant sections in both payment and subscription handlers to accommodate the new display logic.
2025-09-04 18:18:36 +03:00
machka-pasla a5aecb0f86 Enhance payment method display logic and localization updates
- Improved the logic for displaying payment method details, including card type and last four digits, to provide a clearer user experience.
- Updated localization strings to reflect changes in payment method terminology, ensuring consistency across user-facing messages.
- Refactored payment method handlers to utilize the new display logic, enhancing the overall management of payment methods.
2025-09-04 18:14:32 +03:00
machka-pasla cf7f0eae51 Refactor payment method ID extraction for improved reliability
- Updated the logic for extracting payment method IDs from callback data to handle cases with multiple segments, ensuring more robust parsing.
- Enhanced the consistency of ID retrieval across different payment method handlers, improving overall code reliability and user experience.
2025-09-04 18:00:04 +03:00
machka-pasla 850f135bf8 Implement multi-card deletion for payment methods with enhanced error handling
- Added functionality to delete specific payment methods based on their ID, supporting multi-card management.
- Improved error handling during deletion processes, including rollback mechanisms for database transactions.
- Updated user notifications to reflect the success or failure of deletion attempts, ensuring a consistent user experience.
- Refactored the response messages to include updated lists of remaining payment methods after deletion.
2025-09-04 17:52:21 +03:00
machka-pasla 793412c472 Enhance payment method history handling and navigation
- Added a new keyboard function to facilitate navigation back to specific payment method details.
- Updated the payment method history handler to utilize the new back navigation option, improving user experience when no payment history is available.
- Enhanced error handling for extracting payment method IDs from callback data to ensure smoother navigation.
2025-09-04 17:44:31 +03:00
machka-pasla a510ab1021 Refactor payment methods management to utilize a paginated list view
- Updated the payment methods management handler to directly build and display a paginated list of user payment methods, enhancing user experience.
- Removed legacy support for single card checks and deprecated keyboard functions in favor of the new list view.
- Improved localization handling for payment method titles and added fallback options for missing data.
2025-09-04 17:32:27 +03:00
machka-pasla 50cea15c92 Enhance localization for user-facing messages across payment and subscription services
- Updated various services to utilize the user's database language for all user-facing messages, improving the localization experience.
- Refactored message retrieval logic in payment processing, subscription management, and notification handling to ensure consistency in language usage.
- Added comments to clarify the purpose of language settings in relevant sections of the code.
2025-09-04 17:24:24 +03:00
machka-pasla 6b5828ea51 Implement multi-card payment method management and enhance user notifications
- Added support for saving multiple payment methods, allowing users to bind and manage their cards effectively.
- Introduced a new paginated list view for displaying saved payment methods, improving user experience.
- Enhanced user notifications for successful binding of payment methods, including localized messages.
- Updated the database models and data access layer to accommodate multi-card functionality.
- Refactored existing payment method handlers to integrate with the new multi-card system.
2025-09-04 17:11:18 +03:00
machka-pasla 257597ccb7 Enhance payment processing and user detail updates in YooKassa integration
- Added handling for 'waiting_for_capture' event in the YooKassa webhook to manage bind-only payment flows, including saving payment methods and canceling authorizations.
- Introduced a new method in the YooKassaService to cancel payments, improving error handling and logging for payment cancellations.
- Updated user detail synchronization to conditionally update descriptions only when they differ from the current panel state, enhancing efficiency.
2025-09-04 16:30:45 +03:00
Machka PaslaandGitHub dba82cfb97 Create LICENSE
MIT
2025-09-04 15:04:53 +03:00
machka-pasla e0e2cde9a7 Remove unused import for PaymentMethodType in yookassa_service.py to clean up code and improve readability. 2025-09-03 19:56:31 +03:00
machka-pasla 5b98e1a40c Implement payment methods management and binding functionality
- Added new handlers for managing payment methods, including viewing, binding, and deleting payment methods.
- Introduced new inline keyboard options for payment method management in user interactions.
- Enhanced the YooKassa service to support card binding with minimal payment requirements.
- Updated localization files to include new messages related to payment methods and their management.
2025-09-03 18:07:18 +03:00
machka-pasla 0f69823192 Refactor subscription fetching logic in toggle_autorenew_handler
- Updated the subscription retrieval process to directly fetch the Subscription model by ID, improving clarity and reducing potential import cycle issues.
- Enhanced code readability by adding a comment to clarify the purpose of the subscription fetch operation.
2025-09-03 16:32:18 +03:00
machka-pasla 38f3b1ad74 Enhance localization and fallback logic in i18n middleware
- Improved the effective language determination process by adding robust fallbacks to ensure a valid language is always used.
- Updated the gettext method to include explicit fallback to English if the requested language data is unavailable.
- Corrected a typo in the Russian localization for auto-renewal messaging to ensure clarity in user notifications.
2025-09-03 16:28:02 +03:00
machka-pasla 9b4dab84ad Enhance auto-renewal messaging and payment processing logic
- Introduced a new flag for auto-renew subscriptions to streamline messaging and avoid redundant configuration links.
- Updated localization files to include a new message for auto-renewal notifications in both English and Russian.
- Improved error handling and logging in the payment processing flow to ensure clarity and reliability during subscription renewals.
2025-09-03 10:44:20 +03:00
machka-pasla c607b6ef85 Enhance payment processing for auto-renew subscriptions
- Updated the payment processing logic to handle cases where payment_db_id may be absent for auto-renewal scenarios, ensuring idempotent creation of payment records using provider payment IDs.
- Implemented error handling for ensuring payment records and backfilling yookassa_payment_id, improving reliability in processing auto-renewal webhooks.
- Enhanced logging for better traceability of payment record creation and updates.
2025-09-03 10:32:39 +03:00
machka-pasla 09f0de78c6 Refactor subscription renewal process to utilize panel webhook
- Removed the recurring billing task from the bot, shifting the auto-renew functionality to the panel webhook service, which now triggers renewals 24 hours before expiry.
- Updated service dependencies to wire the subscription service with the panel webhook for seamless renewal handling.
- Adjusted the Subscription model to enable auto-renew by default, enhancing subscription management.
2025-09-03 10:22:02 +03:00
machka-pasla 6a3b4a6947 Safely extract and serialize payment method details in YooKassa webhook handling
- Enhanced the yookassa_webhook_route to safely extract payment method details, including card information, from the payment notification.
- Implemented error handling to log exceptions during serialization, improving reliability and debugging capabilities.
- Updated the payment processing dictionary to use the newly structured payment method data.
2025-09-03 10:12:15 +03:00
machka-pasla d6f707d386 Implement auto-renew subscription feature and enhance payment method handling
- Added a recurring billing task to automatically charge users one day before subscription expiry, improving subscription management.
- Introduced a new UserBilling model to store saved payment methods for off-session charges, enhancing user experience.
- Updated YooKassa service to support saving payment methods and capturing payments for auto-renewals.
- Enhanced subscription handling to toggle auto-renew settings and provide user feedback through localized messages.
- Improved error handling and logging for payment method persistence and subscription renewal processes.
2025-09-03 09:40:42 +03:00
Machka PaslaandGitHub dace4c2938 Merge pull request #82 from machka-pasla/dev
I HATE tribute
2025-09-01 21:26:30 +03:00
machka-pasla b69c2ab18d Enhance PanelWebhookService to support panel expiry updates and auto-renew payments
- Updated the PanelWebhookService to accept a new PanelApiService dependency for managing panel user details.
- Implemented functionality to update panel expiry upon subscription renewal, ensuring users maintain access to services.
- Added error handling and logging for both panel expiry updates and auto-renew payment record creation, improving reliability and user feedback.
2025-09-01 21:23:55 +03:00
machka-pasla 56fc88c10e Refactor trial confirmation and activation handlers to utilize new keyboard options
- Updated the trial confirmation and activation handlers to use a dynamic reply markup based on activation status, improving user interaction.
- Enhanced localization for trial activation details in both English and Russian, providing clearer messaging about trial status and connection instructions.
- Ensured consistency in the user experience across different scenarios by integrating the new keyboard options.
2025-08-30 13:29:42 +03:00
machka-pasla e74f801aec Add new error messages and subscription details in English and Russian localization
- Introduced additional error messages for improved user feedback, including service unavailability and payment gateway errors.
- Added subscription details localization to enhance user experience, providing information on subscription status and traffic usage.
- Ensured consistency in messaging across both English and Russian locales.
2025-08-29 13:09:01 +03:00
machka-pasla acf5f060a6 Enhance error handling in subscription response methods
- Implemented try-except blocks around callback answer methods to prevent exceptions from disrupting user interactions.
- Ensured consistent use of the `answer` method across various subscription-related handlers, improving reliability in user notifications.
2025-08-29 12:27:13 +03:00
machka-pasla fb2672732d Improve error handling in user interaction responses
- Refactored the handling of callback answers and message responses to include try-except blocks, ensuring that exceptions are caught and logged without disrupting the user experience.
- Updated message sending methods to utilize the `answer` method consistently, enhancing the reliability of user notifications in various scenarios.
2025-08-29 12:10:00 +03:00
machka-pasla f4730c3ceb Enhance user description synchronization with Telegram profile
- Added functionality to update user descriptions on the panel during synchronization, incorporating Telegram fields such as username, first name, and last name.
- Implemented error handling and logging for failed description updates in both admin sync and profile sync processes.
- Updated user creation and details update methods in the subscription service to include user descriptions, improving consistency across user data management.
2025-08-29 11:05:21 +03:00
machka-pasla e6b3a7c66f Add trial activation details in English and Russian localization
- Introduced a new message for trial activation details, providing users with information about the duration, validity, and configuration link.
- Enhanced user experience by ensuring clarity and consistency in both English and Russian locales.
2025-08-29 10:51:40 +03:00
machka-pasla 24cf193f3c Refactor broadcast and user logs messages for localization support
- Updated the broadcast confirmation message to utilize localization for dynamic content, enhancing multilingual support.
- Modified user logs display to incorporate localization for the title, improving consistency across languages.
- Added new localization entries in both English and Russian for better user experience and clarity.
2025-08-28 22:03:48 +03:00
machka-pasla 85bf0fb6fd Enhance promo export functionality with English localization
- Added support for exporting promo activations and all promo codes with captions and CSV headers in English.
- Updated the promo export handlers to ensure consistent English messaging for user notifications and CSV content.
- Introduced new localization entries for English in the locales files to support the changes.
2025-08-28 21:52:36 +03:00
machka-pasla b576d04501 Add welcome messages in English and Russian localization
- Introduced a new "welcome" message for users in both English and Russian, enhancing the user experience with personalized greetings.
2025-08-28 21:46:59 +03:00
machka-pasla 97268b9906 Add subscription expiration notifications in English and Russian
- Introduced new localized messages for notifying users about upcoming subscription expirations at 72, 48, and 24 hours.
- Added notifications for expired subscriptions and a message for cancelled Tribute subscriptions, enhancing user communication regarding their subscription status.
2025-08-28 21:41:40 +03:00
machka-pasla 1091b7b846 Update bot functionality and localization improvements
- Added new entries to .gitignore for backup JSON files.
- Enhanced user statistics in inline mode to include active users today.
- Improved message handling in broadcast functionality to differentiate between text and media messages, ensuring proper parameter usage.
- Updated payment handling to reflect pagination information in English and Russian.
- Refactored promo management to utilize localized button texts for better user experience.
2025-08-28 21:24:31 +03:00
Machka PaslaandGitHub f191c1813e Merge pull request #79 from ProtoPes/add-referral-check
Added check for referral user, small refactoring
2025-08-26 20:38:45 +03:00
ProtoPes e78f9bf291 Added check for referral user, small refactoring
With regular expressions we can be sure that the data is already valid
2025-08-26 20:28:50 +03:00
Machka PaslaandGitHub 5c74a08ed8 Merge pull request #78 from machka-pasla/dev
Implement user creation and synchronization enhancements in admin syn…
2025-08-26 18:14:40 +03:00
machka-pasla 14950fd559 Implement user creation and synchronization enhancements in admin sync handler
- Added functionality to create new users during synchronization if they are not found in the local database and have a valid Telegram ID.
- Introduced logging for newly created users to improve tracking and debugging.
- Enhanced synchronization statistics to include the count of newly created users, with localization support for both English and Russian.
- Updated the details of synchronization status to reflect additional statistics, improving clarity in admin reports.
2025-08-26 18:11:52 +03:00
Machka PaslaandGitHub 711b9a2487 Merge pull request #77 from machka-pasla/dev
Update broadcast and some other small bugs 🪲
2025-08-25 14:13:27 +03:00
machka-pasla 6e7eb6acfd Enhance message sending functionality to filter unsupported parameters
- Introduced a `SUPPORTED_PARAMS` dictionary to define valid parameters for each message type.
- Added a `filter_kwargs` utility function to filter out unsupported parameters based on the content type.
- Updated `send_message_by_type`, `send_message_via_queue`, and `send_direct_message` functions to utilize the new filtering logic, ensuring only valid parameters are passed during message sending.
- Improved handling for unknown content types by sending a default text message.
2025-08-25 13:49:31 +03:00
machka-pasla b42fae8772 Refactor message handling in broadcast and user management to utilize new utility functions
- Introduced `get_message_content` and `send_message_by_type` utility functions to streamline content type handling and message sending for various media types.
- Updated `process_broadcast_message_handler` and `process_direct_message_handler` to leverage these new functions, reducing code duplication and improving maintainability.
- Enhanced error handling for empty messages and improved message formatting with admin signatures.
2025-08-25 13:44:37 +03:00
machka-pasla f707662125 Refactor bot initialization and routing logic to enforce webhook mode requirement
- Updated the bot's startup logic to require a configured WEBHOOK_BASE_URL, exiting if not set, and logging appropriate error messages.
- Simplified the decision-making process for running the AIOHTTP server, ensuring it only runs in webhook mode.
- Enhanced the router configuration to filter updates for private chats, improving message handling security.
2025-08-25 13:24:30 +03:00
machka-pasla 60c6e0e961 Refactor subscription duration calculation in PanelWebhookService and SubscriptionService
- Introduced a utility function `add_months` to handle subscription duration calculations based on calendar months instead of a fixed 30-day period.
- Updated the auto-renewal logic in `PanelWebhookService` to use the new function for extending subscription end dates.
- Adjusted the duration calculation in `SubscriptionService` to derive the end date after a specified number of months, improving accuracy in subscription management.
2025-08-25 12:30:44 +03:00
machka-pasla b23b75b72e Refactor username update logic in SubscriptionService to prevent overwriting Telegram usernames
- Removed the conditional logic that updated the local user's username with the panel username, ensuring that the Telegram username remains unchanged.
- Added a comment to clarify the purpose of the update, focusing on maintaining the linkage to the panel UUID.
2025-08-25 12:24:30 +03:00
machka-pasla c69f02f7c0 Enhance direct message handling in user management to support multiple content types
- Updated the process_direct_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and send them accordingly to the target user.
- Added error handling for empty messages and invalid HTML content, improving user feedback.
- Included admin signature in messages, ensuring consistent formatting across different content types.
2025-08-20 17:12:40 +03:00
machka-pasla f22e359684 Refactor user creation logic in DAL to support race-safe inserts and return creation status
- Updated the create_user function to use PostgreSQL upsert for concurrent user creation, preventing IntegrityError.
- Modified the function to return a tuple containing the user object and a boolean indicating if the user was newly created.
- Adjusted the start_command_handler to log user registration only if a new user was created, improving logging clarity.
2025-08-20 15:49:12 +03:00
machka-pasla d2402fea77 Add preview message functionality for multiple content types in broadcast handler
- Implemented preview message sending for various content types (text, photo, video, animation, document, audio, voice, sticker, video_note) in the process_broadcast_message_handler.
- Added error handling for invalid HTML content in broadcast messages, improving user feedback and experience.
- Enhanced the confirmation prompt to provide a concise message preview without duplicating text.
2025-08-20 15:39:22 +03:00
machka-pasla 9b8ddb39da Enhance broadcast message handling to support multiple content types
- Updated the process_broadcast_message_handler to determine the content type of incoming messages (text, photo, video, etc.) and store relevant data in the state.
- Implemented new methods in MessageQueueManager for queuing various media types, improving the flexibility of the broadcast system.
- Adjusted confirmation prompts to provide a concise message preview, enhancing user experience.
- Added localization for the new confirmation prompt in both English and Russian.
2025-08-20 15:30:06 +03:00
Machka PaslaandGitHub 459b655ae3 Merge pull request #76 from machka-pasla/dev
add new referral param and broadcast for groups of users
2025-08-19 14:54:30 +03:00
machka-pasla d81ab4137d Update payment processing logic to disable skipping active subscriptions
- Modified the payment processing functions across multiple services to set skip_if_active_before_payment to False, ensuring that active subscriptions are not skipped during payment processing.
- This change enhances the handling of user subscriptions and improves overall payment logic consistency.
2025-08-19 12:26:56 +03:00
Machka PaslaandGitHub cb6ae1e052 Update README.md 2025-08-18 15:22:29 +03:00
machka-pasla f4e2ae5fbd Enhance expired subscription handling in PanelWebhookService
- Updated the _handle_expired_subscription method to return a boolean indicating whether an auto-renewal was performed.
- Adjusted the handle_event method to suppress expiration notifications if an auto-renewal occurs, improving user experience and notification management.
2025-08-17 18:18:31 +03:00
machka-pasla 157c3a7c61 Add referral bonus configuration and enhance payment processing logic
- Introduced a new environment variable REFERRAL_ONE_BONUS_PER_REFEREE to control referral bonus application.
- Updated referral bonus application logic to skip bonuses for users with active subscriptions at the time of payment.
- Enhanced payment processing functions across multiple services to include current payment ID and skip logic for active users.
- Added a new database method to count succeeded payments for users, improving referral bonus eligibility checks.
2025-08-17 17:27:33 +03:00
machka-pasla ef9ebc1918 Refactor startup and shutdown handlers in bot initialization for compatibility with aiogram event signature
- Wrapped the startup and shutdown handlers to ensure they conform to the aiogram event signature, allowing for proper argument handling.
- Updated the web server to access dispatcher workflow data directly, preventing sequence protocol issues and enhancing stability.
2025-08-17 12:13:08 +03:00
machka-pasla 87664a7735 Remove unused router inclusion from dispatcher setup to streamline bot initialization 2025-08-17 12:08:42 +03:00
machka-pasla 3d58f60a4d Refactor bot initialization and service registration for improved modularity
- Integrated new service building functions to streamline bot initialization, enhancing maintainability and clarity.
- Updated the dispatcher setup to include core services dynamically, reducing redundancy in service registration.
- Added support for multiple broadcast targets in the admin handler, allowing for more flexible message distribution.
- Enhanced localization for new broadcast target options, improving user experience in both English and Russian.
- Implemented new database access methods to retrieve user IDs based on subscription status, optimizing broadcast logic.
2025-08-17 12:05:06 +03:00
machka-pasla a75a1f483c Add ProfileSyncMiddleware to keep user profile data updated in the database
- Integrated ProfileSyncMiddleware to ensure that user profile information (username, first_name, last_name) remains current in the database.
- Updated get_enhanced_user_statistics to use timezone-aware datetime for accurate SQL queries, preventing naive/aware comparison issues.
- Clarified comments in the user statistics function for better understanding of active user metrics and subscription handling.
2025-08-15 23:40:23 +03:00
Machka PaslaandGitHub 70d472e71c Merge pull request #71 from machka-pasla/dev
i hate tribute
2025-08-09 22:32:58 +03:00
machka-pasla 13a9e58e27 Refactor response handling in TributeService for improved clarity and consistency
- Introduced helper functions for generating standardized JSON responses, enhancing readability and maintainability.
- Updated response handling for various error conditions to return structured JSON instead of plain text.
- Acknowledged missing user ID with an "ignored" status to prevent unnecessary retries, improving webhook processing reliability.
2025-08-09 22:18:06 +03:00
machka-pasla 7219a6ac30 Refactor provider payment ID generation in TributeService for improved uniqueness
- Updated the logic to generate a unique, idempotent provider payment ID based on explicit event/payment identifiers or a combination of subscription ID and a hash of the raw payload.
- This change ensures better handling of webhook events and prevents potential conflicts in payment identification.
2025-08-09 21:29:04 +03:00
Machka PaslaandGitHub dbb27ee9ca Merge pull request #70 from machka-pasla/dev
Broadcast fix
2025-08-09 10:00:30 +03:00
machka-pasla 4e58fda4a5 Update broadcast message handler to enforce HTML parsing and disable web page previews
- Changed the parse_mode to "HTML" for broadcast messages to ensure proper formatting.
- Added disable_web_page_preview option to enhance message presentation and control over content display.
2025-08-09 09:51:21 +03:00
machka-pasla 8eb5daada5 Add HTML validation for broadcast messages in admin handler
- Implemented a preliminary check for HTML validity in broadcast messages by attempting to send a test message before processing.
- Added error handling for invalid HTML, providing user feedback in both English and Russian.
- Updated localization files to include new error messages for invalid HTML input.
2025-08-09 09:46:33 +03:00
machka-pasla 4c28d3868c Implement validation for broadcast message input in admin handler
- Updated the broadcast message handler to trim whitespace from the input text and added a check for empty messages.
- If the message is empty, an error response is sent to prompt the user for valid input, enhancing user experience and preventing empty broadcasts.
2025-08-09 09:43:02 +03:00
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
Machka PaslaandGitHub 956ec5ea71 Update requirements.txt 2025-07-25 20:25:20 +03:00
Machka PaslaandGitHub 2ef420ff47 Merge pull request #49 from machka-pasla/dependabot/pip/aiohttp-3.12.14
Bump aiohttp from 3.10.11 to 3.12.14
2025-07-25 20:23:55 +03:00
Machka PaslaandGitHub 7de1cdf9d6 Merge pull request #54 from machka-pasla/codex/use-fiat-asset-instead-of-crypto-asset
Support fiat invoices in Crypto Pay
2025-07-25 13:16:00 +03:00
Machka Pasla 860c2298bd Support fiat invoices in Crypto Pay 2025-07-25 13:15:46 +03:00
Machka PaslaandGitHub 2eff9300d3 Merge pull request #53 from machka-pasla/por3bg-codex/integrate-crypto-bot-with-crypto-pay-api
Fix CryptoPay import path
2025-07-25 12:53:48 +03:00
Machka PaslaandGitHub 1bd09be005 Merge branch 'main' into por3bg-codex/integrate-crypto-bot-with-crypto-pay-api 2025-07-25 12:53:41 +03:00
Machka Pasla ce571f6566 Fix CryptoPay import 2025-07-25 12:52:51 +03:00
Machka PaslaandGitHub 856129fce2 Merge pull request #52 from machka-pasla/codex/integrate-crypto-bot-with-crypto-pay-api
Add Crypto Pay integration
2025-07-25 12:17:36 +03:00
Machka Pasla b67f659746 Refine CryptoBot labels and .env layout 2025-07-25 12:16:39 +03:00
Machka PaslaandGitHub a102903814 hotfix 2025-07-22 21:58:19 +03:00
dependabot[bot]andGitHub 7f6cca2f59 Bump aiohttp from 3.10.11 to 3.12.14
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.10.11 to 3.12.14.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.11...v3.12.14)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 07:25:39 +00:00
Machka PaslaandGitHub 3e6ef9de32 Merge pull request #48 from machka-pasla/l9vj54-codex/refactor-payment-success-messages
Refine payment success flow
2025-07-21 10:15:16 +03:00
Machka Pasla 9cf395c103 Use full payment messages and remove unused locales 2025-07-21 10:14:59 +03:00
Machka PaslaandGitHub 69429dce81 Merge pull request #47 from machka-pasla/codex/update-purchase-confirmation-messages
Integrate referral info into purchase message
2025-07-20 21:05:49 +03:00
Machka Pasla d3bfe774c7 Refine purchase and promo messages 2025-07-20 21:04:52 +03:00
Machka PaslaandGitHub e2df8a01c8 Merge pull request #46 from machka-pasla/codex/improve-ux-after-purchase-confirmation
Improve subscription purchase flow
2025-07-20 13:11:30 +03:00
Machka Pasla dbeee31081 Improve post-purchase message 2025-07-20 13:10:44 +03:00
Machka PaslaandGitHub aa5a68c32c Merge pull request #45 from machka-pasla/codex/refactor-subscription-notifications-style
Update subscription expiration notifications
2025-07-12 10:02:00 +07:00
Machka Pasla a7b4863889 Add subscribe reminder buttons and update notifications 2025-07-12 10:01:25 +07:00
Machka PaslaandGitHub 16a00efcc9 Merge pull request #44 from machka-pasla/codex/log-event-and-telegramid-if-available
Log panel webhook event and telegram ID
2025-07-12 00:36:33 +07:00
Machka Pasla 2779e62e43 Log panel webhook events 2025-07-12 00:35:41 +07:00
Machka PaslaandGitHub d7fdcc25cd Merge pull request #43 from machka-pasla/7ql8c7-codex/fix-webhook-without-telegramid-warning
Fix webhook user extraction and notification logic
2025-07-12 00:24:48 +07:00
Machka PaslaandGitHub 24c4bbc27e Merge branch 'main' into 7ql8c7-codex/fix-webhook-without-telegramid-warning 2025-07-12 00:24:40 +07:00
Machka Pasla 5b08ad5449 Fix panel webhook user data parsing 2025-07-12 00:22:35 +07:00
Machka PaslaandGitHub fa321426b8 Merge pull request #42 from machka-pasla/codex/fix-webhook-without-telegramid-warning
Handle panel webhook data variant
2025-07-12 00:06:28 +07:00
Machka Pasla a4839ffc63 Fix panel webhook user data parsing 2025-07-12 00:06:11 +07:00
Machka PaslaandGitHub 0aa38d3806 Merge pull request #41 from machka-pasla/qrbsms-codex/add-user-notifications-for-expiration-webhooks
Handle panel webhook variants
2025-07-11 23:41:38 +07:00
Machka Pasla c7b124ad4c Handle panel webhook variants and remove Tribute cancel 2025-07-11 23:40:33 +07:00
Machka PaslaandGitHub b5b740611c Merge pull request #39 from machka-pasla/codex/add-promo-code-activation-notifications
Add promo activation and tribute/star payment notifications
2025-07-11 19:29:29 +07:00
Machka Pasla 68459a7075 Add admin notifications for promos and non-YK payments 2025-07-11 19:28:45 +07:00
Machka PaslaandGitHub 0e84a480fc Merge pull request #38 from machka-pasla/codex/fix-unhandled-telegrambadrequest-exception
Fix HTML name escaping
2025-07-09 14:34:06 +07:00
Machka Pasla 1ac368354d Escape user names in HTML messages 2025-07-09 14:33:53 +07:00
Machka PaslaandGitHub 831e61cd99 Merge pull request #37 from machka-pasla/codex/refactor-payment-logic-for-subscription-periods
Update Tribute webhook period parsing
2025-07-08 19:09:57 +07:00
Machka Pasla 981d63c53e Update Tribute webhook period parsing 2025-07-08 19:09:43 +07:00
Machka PaslaandGitHub 4d8cf45bda Merge pull request #31 from machka-pasla/codex/remove-auto-renew-for-attribute
Disable tribute auto-renew and add admin alerts
2025-07-06 13:31:14 +07:00
Machka Pasla df987fc52a Remove tribute auto renew and add admin notifications 2025-07-06 13:31:04 +07:00
Machka PaslaandGitHub 07cb68457f Merge pull request #30 from machka-pasla/codex/уточнить-работу-автопродления-с-tribute
Implement auto-renewal for Tribute
2025-07-05 00:05:51 +07:00
Machka Pasla 1e666f90d3 Improve Tribute auto-renew 2025-07-05 00:05:33 +07:00
Machka PaslaandGitHub 6241f766a2 Merge pull request #29 from machka-pasla/codex/add-description-support-for-/start-command
Fix mini app registration
2025-07-04 22:45:19 +07:00
Machka Pasla 451581f792 Fix mini app registration 2025-07-04 22:43:04 +07:00
Machka PaslaandGitHub a015946b20 Merge pull request #28 from machka-pasla/codex/add-description-support-for-/start-command
Enable mini app via API without menu change
2025-07-04 22:35:48 +07:00
Machka Pasla d1d701bff3 feat: allow /start description and keep menu default 2025-07-04 22:35:13 +07:00
Machka PaslaandGitHub ea50567a42 Merge pull request #26 from machka-pasla/codex/добавить-поддержку-mini-app-для-подписки
Support subscription mini app
2025-07-04 20:45:59 +07:00
Machka Pasla a8b62ccc03 Add subscription mini app support 2025-07-04 20:45:27 +07:00
Machka PaslaandGitHub 0e03d72dd3 Merge pull request #25 from machka-pasla/codex/refactor-.env-variables-for-consistency
Fix panel user sync and clarify traffic settings
2025-06-29 00:53:44 +03:00
Machka Pasla aa5eb74d75 Rename user traffic env vars 2025-06-29 00:52:27 +03:00
Machka PaslaandGitHub fd2d0dd570 Merge pull request #24 from machka-pasla/codex/fix-typeerror-with-unsupported-operand-types-for
Fix admin state filter
2025-06-27 20:58:44 +03:00
Machka Pasla abaaf65da9 Fix state filter in promo codes handler 2025-06-27 20:58:00 +03:00
Machka PaslaandGitHub 699964adc0 Merge pull request #23 from machka-pasla/dev
Dev
2025-06-27 20:44:01 +03:00
Machka PaslaandGitHub 74b5587c77 Merge pull request #22 from machka-pasla/codex/refactor-code-for-readability-and-consistency
Add promo management and unify user lookup
2025-06-27 20:43:25 +03:00
Machka Pasla d196801e2e Add promo management and unified user lookup 2025-06-27 20:38:03 +03:00
Machka PaslaandGitHub c12958e9a2 Merge pull request #21 from machka-pasla/codex/fix-activeuserinbounds-filter-in-api-request
Fix default inbounds not applied on user creation
2025-06-27 14:29:46 +03:00
Machka Pasla f4bb61dd1b Use default inbound UUIDs when creating panel user 2025-06-27 14:29:16 +03:00
Machka PaslaandGitHub 24a7e6cc6a Merge pull request #20 from machka-pasla/codex/fix-payment-error-with-tribute
Fix Tribute duplicate payment error
2025-06-27 00:17:27 +03:00
Machka Pasla c6f5e06eba Handle duplicate Tribute payments 2025-06-27 00:17:09 +03:00
Machka PaslaandGitHub ca35567012 Fix typo 2025-06-26 01:36:43 +03:00
Machka PaslaandGitHub cdefe27f89 Merge pull request #18 from machka-pasla/codex/implement-user-creation-and-subscription-updates
Improve panel user handling and promo code activation
2025-06-26 01:28:36 +03:00
Machka Pasla 7200d6e342 Handle missing panel users and allow promo codes 2025-06-26 01:26:55 +03:00
Machka PaslaandGitHub fd4b9b828b Merge pull request #16 from machka-pasla/codex/refactor-panel-unreachable-handling
Fix panel unreachable handling
2025-06-26 00:59:03 +03:00
Machka Pasla b2a0ea5bd2 Remove panel unreachable placeholders and enhance sync 2025-06-26 00:57:48 +03:00
Machka PaslaandGitHub b97774c5b5 Update README.md 2025-06-25 22:15:23 +03:00
Machka PaslaandGitHub d5b4f18dbc Merge pull request #14 from machka-pasla/dev
Move tribute webhook route to service
2025-06-24 19:18:34 +03:00
Machka PaslaandGitHub a94ee8b554 Merge pull request #13 from machka-pasla/codex/locate-usage-of-handlers/webhooks-folder
Move tribute webhook route to service
2025-06-24 19:15:09 +03:00
Machka Pasla 7a90e24427 Move tribute webhook route to service 2025-06-24 19:12:17 +03:00
Machka PaslaandGitHub 891f9354ac Merge pull request #12 from machka-pasla/dev
Tribute and TG stars payment
2025-06-24 10:34:36 +03:00
Machka PaslaandGitHub 89709d6626 Merge pull request #10 from machka-pasla/3eerd1-codex/update-payment-methods-variables-and-readme.md
Add payment method toggles and new pricing variables
2025-06-24 01:36:31 +03:00
Machka Pasla eea6afbcc7 Refactor payment logic into dedicated services 2025-06-24 01:35:20 +03:00
Machka PaslaandGitHub 99539542ca Merge pull request #9 from machka-pasla/codex/update-payment-methods-variables-and-readme.md
Add payment method toggles and new pricing variables
2025-06-24 01:18:49 +03:00
Machka Pasla 6e8f810cbd feat: add payment method toggles and new pricing vars 2025-06-24 01:18:30 +03:00
Machka PaslaandGitHub c7294ddaba Merge pull request #8 from machka-pasla/codex/fix-unsuccessful-payment-notification-and-nonetype-error
Fix payment success notifications
2025-06-24 00:40:14 +03:00
Machka Pasla cb82637b8e Fix payment notifications 2025-06-23 23:59:17 +03:00
Machka Pasla aa20ac9a18 Notify users after Tribute payment 2025-06-23 23:42:24 +03:00
Machka PaslaandGitHub 53464ab25c Merge pull request #3 from machka-pasla/codex/locate-and-fix-apscheduler-duplicate-notifications
Fix duplicate scheduler instances
2025-06-23 20:13:03 +03:00
Machka Pasla 1e0e883c6e Prevent duplicate scheduler startup 2025-06-23 20:12:44 +03:00
machka d14afc5720 locales update 2025-05-23 10:28:08 +00:00
machka 24706388c3 user_keyboards.py update 2025-05-23 10:16:53 +00:00
machka df9e36928c Broadcast HTML fix and subscription keyboards improvements 2025-05-21 19:30:25 +00:00
machka c245ccb55b requirements.txt update 2025-05-21 15:45:41 +00:00
machka ba348eb639 Logger and docker-compose.yml update 2025-05-21 12:23:11 +00:00
machka f6ce39f7c1 Dockerfile update №2 2025-05-21 11:14:13 +00:00
machka 4732a97611 Dockerfile update 2025-05-21 11:04:05 +00:00
machka 0fe29c19b1 ru.json update 2025-05-21 07:59:38 +00:00
machka 7da42c9e25 README update 2 2025-05-21 07:55:38 +00:00
machka e490769044 README update 2025-05-21 07:53:23 +00:00
machka dc8ba3ce06 Exclude readme.md from docker-publish.yml 2025-05-20 19:23:26 +00:00
79 changed files with 14735 additions and 2936 deletions
+92
View File
@@ -0,0 +1,92 @@
# Git
.git
.gitignore
.github
.gitattributes
LICENSE
README.md
# CI
.codeclimate.yml
.travis.yml
.taskcluster.yml
# Docker
docker-compose.yml
Dockerfile
.docker
.dockerignore
# Byte-compiled / optimized / DLL files
**/__pycache__/
**/*.py[cod]
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.cache
nosetests.xml
coverage.xml
# Translations
*.mo
*.pot
# Django stuff:
*.log
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Virtual environment
.env*
.venv/
venv/
# PyCharm
.idea
# Python mode for VIM
.ropeproject
**/.ropeproject
# Vim swap files
**/*.swp
# VS Code
.vscode/
+112 -45
View File
@@ -1,78 +1,145 @@
# Telegram Bot Token and Admin IDs
BOT_TOKEN=your_bot_token_here
ADMIN_IDS=comma_separated_admin_ids
BOT_TOKEN=your_bot_token_here # Telegram bot token
ADMIN_IDS=comma_separated_admin_ids # Your telegram ID
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_HOST=remnawave-tg-shop-db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
# PostgreSQL Database Connection Settings
POSTGRES_USER=postgres # Database user name
POSTGRES_PASSWORD=postgres # Database password
POSTGRES_HOST=remnawave-tg-shop-db # Database container name
POSTGRES_PORT=5432 # Port
POSTGRES_DB=postgres # Database name
# Localization and Display
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
DEFAULT_LANGUAGE="ru" # or "en"
DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
# External Links
SUPPORT_LINK=https://t.me/your_support_link
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
TERMS_OF_SERVICE_URL=https://example.com/tos
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
# Required channel subscription
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
STARS_ENABLED=True # Turn on STARS
TRIBUTE_ENABLED=True # Turn on TRIBUTE
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_key
YOOKASSA_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
YOOKASSA_RETURN_URL=https://t.me/your_bot
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
YOOKASSA_VAT_CODE=1
YOOKASSA_PAYMENT_MODE=full_prepayment
YOOKASSA_PAYMENT_SUBJECT=payment
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
YOOKASSA_RETURN_URL=https://t.me/your_bot # URL to which the user will be returned after payment
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
YOOKASSA_VAT_CODE=1 # VAT code
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
# Telegram Webhook Configuration (Optional)
# If unset, bot will use polling while YooKassa uses webhooks.
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# FreeKassa Payment Gateway Configuration
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
# Subscription Prices (integer values)
PRICE_1_MONTH=150
PRICE_3_MONTHS=300
PRICE_6_MONTHS=500
PRICE_12_MONTHS=900
# CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
CRYPTOPAY_CURRENCY_TYPE=fiat # Currency type (fiat or crypto)
CRYPTOPAY_ASSET=RUB # Asset, e.g., RUB, BTC, USDT
# Subscription Expiration Notifications
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0
# 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
# Subscription Options. Specify cost parameters or payment links here.
1_MONTH_ENABLED=True
RUB_PRICE_1_MONTH=150
STARS_PRICE_1_MONTH=0
TRIBUTE_LINK_1_MONTH=
3_MONTHS_ENABLED=True
RUB_PRICE_3_MONTHS=300
STARS_PRICE_3_MONTHS=0
TRIBUTE_LINK_3_MONTHS=
6_MONTHS_ENABLED=True
RUB_PRICE_6_MONTHS=500
STARS_PRICE_6_MONTHS=0
TRIBUTE_LINK_6_MONTHS=
12_MONTHS_ENABLED=True
RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS=
# Subscription Notifications
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True # Enable subscription
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True # Notify on subscription
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True # Notify after
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Days before expiration to notify
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
# Referral Bonus Days
# Bonus for the inviting user
REFERRAL_BONUS_DAYS_1_MONTH=3
REFERRAL_BONUS_DAYS_3_MONTHS=7
REFERRAL_BONUS_DAYS_6_MONTHS=15
REFERRAL_BONUS_DAYS_12_MONTHS=30
# Invited User Bonus
REFEREE_BONUS_DAYS_1_MONTH=1
REFEREE_BONUS_DAYS_3_MONTHS=3
REFEREE_BONUS_DAYS_6_MONTHS=7
REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api
PANEL_API_KEY=your_panel_api_key
PANEL_API_URL=http://your_panel_api_url/api # URL of the panel API
PANEL_API_KEY=your_panel_api_key # Panel API key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# Default settings for NEW panel users
PANEL_USER_DEFAULT_EXPIRE_DAYS=1
PANEL_USER_DEFAULT_TRAFFIC_BYTES=0
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY="NO_RESET"
# User traffic limits (applied for all users)
# 0 means unlimited
USER_TRAFFIC_LIMIT_GB=0 # Traffic limit for users (0 unlimited)
USER_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy (NO_RESET, WEEK, MONTH)
# Default Inbounds for Panel Users (Optional, comma-separated UUIDs)
PANEL_USER_DEFAULT_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
TRIAL_DURATION_DAYS=5
TRIAL_TRAFFIC_LIMIT_GB=0 # 0 for unlimited
TRIAL_TRAFFIC_STRATEGY="NO_RESET"
TRIAL_ENABLED=True # Enable the trial period
TRIAL_DURATION_DAYS=5 # Duration of the trial period in days
TRIAL_TRAFFIC_LIMIT_GB=0 # Traffic limit for the trial period (0 = unlimited)
TRIAL_TRAFFIC_STRATEGY="NO_RESET" # Traffic reset strategy for the trial period (NO_RESET, WEEK, MONTH)
# Web Server Settings (for handling webhooks)
WEB_SERVER_HOST="0.0.0.0"
WEB_SERVER_PORT=8080
# Admin Panel Log Pagination
LOGS_PAGE_SIZE=10
LOGS_PAGE_SIZE=10 # Number of events in the log
# 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 new user registrations
LOG_PAYMENTS=True # Log payments
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
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 }}
+2
View File
@@ -6,6 +6,8 @@ on:
- main
tags:
- 'v*.*.*'
paths-ignore:
- 'README.md'
jobs:
build-and-push:
+3
View File
@@ -14,3 +14,6 @@ __pycache__/
# Игнорировать Docker артефакты (если вдруг)
*.log
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
+11 -2
View File
@@ -1,11 +1,20 @@
FROM python:3.11-slim
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
RUN rm -rf /root/.cache
CMD ["python", "main.py"]
+7
View File
@@ -0,0 +1,7 @@
Copyright 2025 machka-pasla
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+155 -180
View File
@@ -1,223 +1,198 @@
# 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
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), 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.
* 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
* **aiosqlite:** Asynchronous SQLite 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, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
- **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).
* **YooKassa Settings:**
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
* `YOOKASSA_WEBHOOK_BASE_URL`: Your publicly accessible HTTPS base URL where the bot will listen for YooKassa webhooks (e.g., `https://your.domain.com`). The full path will be `{YOOKASSA_WEBHOOK_BASE_URL}/webhook/yookassa`.
* `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`.
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
* `PRICE_X_MONTH`: Prices for different subscription durations.
* **Panel API Settings:**
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
* `PANEL_USER_DEFAULT_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.
* `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. The `bot_database.sqlite3` and `locales` directory will be mounted as volumes in Docker. `locales` mounting is optional.
<details>
<summary><b>Основные настройки</b></summary>
4. **Build and 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` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</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. |
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `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 - безлимит). |
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
</gidetails>
<details>
<summary><b>Настройки пробного периода</b></summary>
| Переменная | Описание |
| --- | --- |
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
</details>
3. **Запустите контейнеры:**
```bash
docker compose up --build -d
docker compose up -d
```
This command will build the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `vpn-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://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://vpn-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `vpn-shop` is the service name in `docker-compose.yml`).
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://vpn-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_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, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
6. **Database:**
* A SQLite database file (`bot_database.sqlite3`) will be created in your project root (or wherever you map the volume). The schema is initialized automatically on the first run if the file doesn't exist.
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
7. **Viewing Logs:**
**Пути для перенаправления:**
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
- `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 vpn-shop
docker compose logs -f remnawave-tg-shop
```
## 🐳 Docker Setup
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
### `Dockerfile`
## 🐳 Docker
```dockerfile
FROM python:3.11-slim
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
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`
YAML
```
services:
vpn-shop:
build: .
container_name: vpn-shop
hostname: vpn-shop
networks:
- remnawave-network # Ensure this external network exists or define it
volumes:
- ./bot_database.sqlite3:/app/bot_database.sqlite3
# - ./locales:/app/locales
restart: unless-stopped
# Optionally, expose ports if you are not using a shared Docker network
# and want to access the bot's webserver directly (not recommended for production without a reverse proxy)
# ports:
# - "8080:8080"
networks:
remnawave-network:
external: true # Assumes 'remnawave-network' is an existing external Docker network
# If not, you might want to define it here or use a default bridge.
```
**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 model
│ └── settings.py # Настройки Pydantic
├── db/
── database.py # Database schema, connection, and CRUD functions
├── locales/ # Localization files (en.json, ru.json)
├── .env.example # Example environment variables
├── .env # Your local environment variables (ignored by git)
├── Dockerfile # Instructions to build the Docker image
├── docker-compose.yml # Docker Compose configuration
├── requirements.txt # Python dependencies
── main.py # Main entry point to run 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`
+2
View File
@@ -0,0 +1,2 @@
@@ -0,0 +1,39 @@
import logging
from typing import Dict
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.db_session import DBSessionMiddleware
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
from bot.middlewares.profile_sync import ProfileSyncMiddleware
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
storage = MemoryStorage()
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings.BOT_TOKEN, default=default_props)
dp = Dispatcher(storage=storage, settings=settings, bot_instance=bot)
i18n_instance = get_i18n_instance(path="locales", default=settings.DEFAULT_LANGUAGE)
dp["i18n_instance"] = i18n_instance
dp["async_session_factory"] = async_session_factory
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
dp.update.outer_middleware(ProfileSyncMiddleware())
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
return dp, bot, {"i18n_instance": i18n_instance}
+2
View File
@@ -0,0 +1,2 @@
+87
View File
@@ -0,0 +1,87 @@
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.freekassa_service import FreeKassaService
def build_core_services(
settings: Settings,
bot: Bot,
async_session_factory: sessionmaker,
i18n: JsonI18n,
bot_username_for_default_return: str,
):
panel_service = PanelApiService(settings)
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
cryptopay_service = CryptoPayService(
settings.CRYPTOPAY_TOKEN,
settings.CRYPTOPAY_NETWORK,
bot,
settings,
i18n,
async_session_factory,
subscription_service,
referral_service,
)
freekassa_service = FreeKassaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
)
tribute_service = TributeService(
bot,
settings,
i18n,
async_session_factory,
panel_service,
subscription_service,
referral_service,
)
panel_webhook_service = PanelWebhookService(bot, settings, i18n, async_session_factory, panel_service)
yookassa_service = YooKassaService(
shop_id=settings.YOOKASSA_SHOP_ID,
secret_key=settings.YOOKASSA_SECRET_KEY,
configured_return_url=settings.YOOKASSA_RETURN_URL,
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
)
# Wire services that depend on each other
try:
# Attach YooKassa to subscription service for auto-renew charges
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
setattr(panel_webhook_service, "subscription_service", subscription_service)
except Exception:
pass
return {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"tribute_service": tribute_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
}
+2
View File
@@ -0,0 +1,2 @@
+97
View File
@@ -0,0 +1,97 @@
import asyncio
import logging
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
async def build_and_start_web_app(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
):
app = web.Application()
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
# Inject shared instances used by webhook handlers
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
"subscription_service",
"referral_service",
"panel_service",
"stars_service",
"freekassa_service",
"cryptopay_service",
"tribute_service",
"panel_webhook_service",
):
# Access dispatcher workflow_data directly to avoid sequence protocol issues
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
setup_application(app, dp, bot=bot)
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
if telegram_uses_webhook_mode:
telegram_webhook_path = f"/{settings.BOT_TOKEN}"
app.router.add_post(telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot))
logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.tribute_service import tribute_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
tribute_path = settings.tribute_webhook_path
if tribute_path.startswith("/"):
app.router.add_post(tribute_path, tribute_webhook_route)
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
fk_path = settings.freekassa_webhook_path
if fk_path.startswith("/"):
app.router.add_post(fk_path, freekassa_webhook_route)
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
# YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
app.router.add_post(yk_path, yookassa_webhook_route)
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
panel_path = settings.panel_webhook_path
if panel_path.startswith("/"):
app.router.add_post(panel_path, panel_webhook_route)
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
site = web.TCPSite(
web_app_runner,
host=settings.WEB_SERVER_HOST,
port=settings.WEB_SERVER_PORT,
)
await site.start()
logging.info(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
# Run until cancelled
await asyncio.Event().wait()
+6 -2
View File
@@ -2,20 +2,24 @@ 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
from . import ads
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)
admin_router_aggregate.include_router(ads.router)
__all__ = ("admin_router_aggregate", )
+359
View File
@@ -0,0 +1,359 @@
import logging
from aiogram import Router, F, types
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from db.dal import ad_dal
from bot.states.admin_states import AdminStates
router = Router(name="admin_ads_router")
PAGE_SIZE = 5
@router.callback_query(F.data == "admin_action:ads")
async def show_ads_menu(callback: types.CallbackQuery, 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")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
totals = await ad_dal.get_totals(session)
total_cost = totals.get("cost", 0.0)
total_revenue = totals.get("revenue", 0.0)
overview = _("admin_ads_overview", revenue=f"{total_revenue:.2f}", cost=f"{total_cost:.2f}")
total_count = await ad_dal.count_campaigns(session)
if total_count == 0:
text = overview + "\n\n" + _("admin_ads_empty")
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
reply_markup = get_ads_menu_keyboard(i18n, current_lang)
else:
current_page = 0
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
campaigns = await ad_dal.list_campaigns_paged(session, page=current_page, page_size=PAGE_SIZE)
text = overview + "\n\n" + _("admin_ads_header")
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, current_page, total_pages)
await callback.message.edit_text(text, reply_markup=reply_markup)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("admin_ads:page:"))
async def ads_list_pagination(callback: types.CallbackQuery, 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")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
try:
page = int(callback.data.split(":")[2])
except Exception:
page = 0
totals = await ad_dal.get_totals(session)
overview = _("admin_ads_overview", revenue=f"{totals.get('revenue', 0.0):.2f}", cost=f"{totals.get('cost', 0.0):.2f}")
total_count = await ad_dal.count_campaigns(session)
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(0, min(page, total_pages - 1))
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
text = overview + "\n\n" + _("admin_ads_header")
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
try:
await callback.message.edit_text(text, reply_markup=reply_markup)
await callback.answer()
except Exception as e:
logging.error(f"Failed to paginate ads list: {e}")
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:card:"))
async def show_ad_card(callback: types.CallbackQuery, 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")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
parts = callback.data.split(":")
camp_id = int(parts[2])
back_page = int(parts[3]) if len(parts) > 3 else 0
camp = await ad_dal.get_campaign_by_id(session, camp_id)
if not camp:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
try:
stats = await ad_dal.get_campaign_stats(session, camp_id)
except Exception:
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
text = _(
"admin_ads_card",
id=camp.ad_campaign_id,
source=camp.source,
start_param=camp.start_param,
cost=f"{camp.cost:.2f}",
active=_("csv_yes") if camp.is_active else _("csv_no"),
starts=stats["starts"],
trials=stats["trials"],
payers=stats["payers"],
revenue=f"{stats['revenue']:.2f}",
)
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
try:
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
except Exception as e:
logging.error(f"Failed to show ad card: {e}")
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:delete:"))
async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
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("Language error.", show_alert=True)
return
try:
_, _, camp_id_str, back_page_str = callback.data.split(":", 3)
camp_id = int(camp_id_str)
back_page = int(back_page_str)
except Exception:
await callback.answer(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
return
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
kb = get_confirmation_keyboard(
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}",
i18n_instance=i18n,
lang=current_lang,
)
try:
await callback.message.edit_text(confirm_text, reply_markup=kb)
await callback.answer()
except Exception:
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
# Return to the ad card view
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
try:
parts = callback.data.split(":", 3)
camp_id = int(parts[2])
back_page = int(parts[3])
except Exception:
await callback.answer(_("error_try_again"), show_alert=True)
return
camp = await ad_dal.get_campaign_by_id(session, camp_id)
if not camp:
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
return
try:
stats = await ad_dal.get_campaign_stats(session, camp_id)
except Exception:
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
text = _(
"admin_ads_card",
id=camp.ad_campaign_id,
source=camp.source,
start_param=camp.start_param,
cost=f"{camp.cost:.2f}",
active=_("csv_yes") if camp.is_active else _("csv_no"),
starts=stats["starts"],
trials=stats["trials"],
payers=stats["payers"],
revenue=f"{stats['revenue']:.2f}",
)
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
try:
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer()
except Exception:
await callback.answer()
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
async def ads_delete_confirm(callback: types.CallbackQuery, 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")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
try:
parts = callback.data.split(":", 3)
camp_id = int(parts[2])
back_page = int(parts[3])
except Exception:
await callback.answer(_("error_try_again"), show_alert=True)
return
existed = await ad_dal.delete_campaign(session, camp_id)
if not existed:
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
return
await session.commit()
# After delete, show list page (may shift due to fewer items)
totals = await ad_dal.get_totals(session)
overview = _(
"admin_ads_overview",
revenue=f"{totals.get('revenue', 0.0):.2f}",
cost=f"{totals.get('cost', 0.0):.2f}",
)
total_count = await ad_dal.count_campaigns(session)
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = max(0, min(back_page, total_pages - 1))
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
text = overview + "\n\n" + _("admin_ads_header")
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
try:
await callback.message.edit_text(text, reply_markup=reply_markup)
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
except Exception:
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
@router.callback_query(F.data == "admin_action:ads_create")
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
from bot.states.admin_states import AdminStates
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer("Language error.", show_alert=True)
return
await state.set_state(AdminStates.waiting_for_ad_source)
await callback.message.edit_text(_("admin_ads_create_source_prompt"))
try:
await callback.answer()
except Exception:
pass
@router.message(
StateFilter(
AdminStates.waiting_for_ad_source,
AdminStates.waiting_for_ad_start_param,
AdminStates.waiting_for_ad_cost,
),
F.text,
)
async def ads_create_flow(message: types.Message, state: FSMContext, settings: Settings, i18n_data: dict, session: AsyncSession):
current_state = await state.get_state()
if current_state not in (
AdminStates.waiting_for_ad_source.state,
AdminStates.waiting_for_ad_start_param.state,
AdminStates.waiting_for_ad_cost.state,
):
return
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if current_state == AdminStates.waiting_for_ad_source.state:
source = message.text.strip()
if not source or len(source) > 64:
await message.answer(_("admin_ads_invalid_source"))
return
await state.update_data(ad_source=source)
await state.set_state(AdminStates.waiting_for_ad_start_param)
await message.answer(_("admin_ads_create_start_param_prompt"))
return
if current_state == AdminStates.waiting_for_ad_start_param.state:
start_param = message.text.strip()
# Allow alnum underscore dash only
import re as _re
if not _re.match(r"^[A-Za-z0-9_\-]{2,64}$", start_param):
await message.answer(_("admin_ads_invalid_start_param"))
return
await state.update_data(ad_start_param=start_param)
await state.set_state(AdminStates.waiting_for_ad_cost)
await message.answer(_("admin_ads_create_cost_prompt"))
return
if current_state == AdminStates.waiting_for_ad_cost.state:
text = message.text.replace(",", ".").strip()
try:
cost = float(text)
if cost < 0 or cost > 1e8:
raise ValueError()
except Exception:
await message.answer(_("admin_ads_invalid_cost"))
return
data = await state.get_data()
try:
campaign = await ad_dal.create_campaign(
session,
source=data.get("ad_source", "unknown"),
start_param=data.get("ad_start_param", "NA"),
cost=cost,
)
await session.commit()
except ValueError as ve:
await session.rollback()
if str(ve) == "ad_campaign_start_param_exists":
await message.answer(_("admin_ads_start_param_exists"))
else:
await message.answer(_("error_occurred_try_again"))
return
except Exception as e:
await session.rollback()
logging.error(f"Failed to create ad campaign: {e}", exc_info=True)
await message.answer(_("error_occurred_try_again"))
return
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await message.answer(
_(
"admin_ads_created_success",
id=campaign.ad_campaign_id,
source=campaign.source,
start_param=campaign.start_param,
cost=f"{campaign.cost:.2f}",
)
)
# Offer back to ads menu
from bot.keyboards.inline.admin_keyboards import get_ads_menu_keyboard
await message.answer(_("admin_ads_back_to_menu_hint"), reply_markup=get_ads_menu_keyboard(i18n, current_lang))
+309 -92
View File
@@ -1,6 +1,7 @@
import logging
import asyncio
from aiogram import Router, F, types, Bot
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
from aiogram.fsm.context import FSMContext
from typing import Optional
@@ -11,16 +12,25 @@ from config.settings import Settings
from db.dal import user_dal, message_log_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from bot.keyboards.inline.admin_keyboards import (
get_broadcast_confirmation_keyboard,
get_back_to_admin_panel_keyboard,
get_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.utils.message_queue import get_queue_manager
from bot.utils import get_message_content, send_message_by_type, send_message_via_queue, MessageContent
router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def broadcast_message_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:
@@ -35,25 +45,29 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
except Exception as e:
logging.warning(
f"Could not edit message for broadcast prompt: {e}. Sending new."
)
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
async def process_broadcast_message_handler(message: types.Message,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
@router.message(AdminStates.waiting_for_broadcast_message)
async def process_broadcast_message_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -63,28 +77,116 @@ async def process_broadcast_message_handler(message: types.Message,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
broadcast_message_text = message.html_text
await state.update_data(broadcast_message=broadcast_message_text)
# Определяем тип содержимого и сохраняем данные в state
entities = message.entities or message.caption_entities or []
content = get_message_content(message)
preview_snippet = (
broadcast_message_text[:200] +
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
confirmation_prompt = _("admin_broadcast_confirm_prompt",
message_preview=preview_snippet)
# Если нет ни текста, ни медиа — ошибка
if not content.text and not content.file_id:
await message.answer(_("admin_broadcast_error_no_message"))
return
await message.answer(confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(
current_lang, i18n),
parse_mode="HTML")
# Сохраняем данные для рассылки
await state.update_data(
broadcast_text=content.text,
broadcast_entities=entities,
broadcast_content_type=content.content_type,
broadcast_file_id=content.file_id,
broadcast_target="all",
)
# Отправляем превью-копию того, что будет разослано
try:
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
else:
await send_message_by_type(
bot,
chat_id=message.chat.id,
content=content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
disable_notification=True,
)
except TelegramBadRequest as e:
await message.answer(
_(
"admin_broadcast_invalid_html",
default="❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.\nОшибка: {error}",
error=str(e),
)
)
return
# Показываем короткое подтверждение без дублирования текста — сообщение выше служит превью
confirmation_prompt = _("admin_broadcast_confirm_prompt_short")
await message.answer(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n, target="all"),
)
await state.set_state(AdminStates.confirming_broadcast)
@router.callback_query(F.data == "admin_action:main",
AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings, i18n_data: dict,
session: AsyncSession):
@router.callback_query(
F.data.startswith("broadcast_target:"),
AdminStates.confirming_broadcast,
)
async def change_broadcast_target_handler(
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 updating selection.", show_alert=True)
return
new_target = callback.data.split(":")[1]
if new_target not in {"all", "active", "inactive"}:
await callback.answer("Unknown target.", show_alert=True)
return
await state.update_data(broadcast_target=new_target)
user_fsm_data = await state.get_data()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
confirmation_prompt = _(
"admin_broadcast_confirm_prompt_short"
)
try:
await callback.message.edit_text(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(
current_lang, i18n, target=new_target
),
)
except Exception:
pass
await callback.answer()
@router.callback_query(
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
)
async def cancel_broadcast_at_prompt_stage(
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:
@@ -94,97 +196,137 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear()
await callback.message.answer(_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang, settings))
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast)
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict, bot: Bot,
settings: Settings,
session: AsyncSession):
@router.callback_query(
F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast,
)
async def confirm_broadcast_callback_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
bot: Bot,
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 broadcast confirmation.",
show_alert=True)
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1]
user_fsm_data = await state.get_data()
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
if action == "send":
if not broadcast_message_to_send:
await callback.message.edit_text(
_("admin_broadcast_error_no_message"))
# Создаем объект контента из сохраненных данных
content = MessageContent(
content_type=user_fsm_data.get("broadcast_content_type", "text"),
file_id=user_fsm_data.get("broadcast_file_id"),
text=user_fsm_data.get("broadcast_text")
)
entities = user_fsm_data.get("broadcast_entities", [])
if not content.text and content.content_type == "text":
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear()
await callback.answer(_("admin_broadcast_error_no_message_alert"),
show_alert=True)
await callback.answer(
_("admin_broadcast_error_no_message_alert"), show_alert=True
)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"),
reply_markup=None)
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
await callback.answer()
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
session)
target = user_fsm_data.get("broadcast_target", "all")
if target == "active":
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
elif target == "inactive":
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
else:
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent_count = 0
failed_count = 0
logging.info(
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
)
admin_user = callback.from_user
logging.info(
f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users."
)
for user_id_to_send in user_ids_for_broadcast:
# 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(user_id_to_send,
broadcast_message_to_send,
parse_mode="HTML")
# Для медиа-сообщений используем caption_entities, для текста - entities
if content.content_type == "text":
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
entities=entities,
disable_web_page_preview=True,
)
else:
await send_message_via_queue(
queue_manager,
uid,
content,
parse_mode="HTML",
caption_entities=entities,
disable_web_page_preview=True,
)
sent_count += 1
log_payload_sent = {
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_sent",
"content":
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
"is_admin_event": True,
"target_user_id": user_id_to_send
}
# Log successful queuing
await message_log_dal.create_message_log(
session, log_payload_sent)
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_queued",
"content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
except Exception as e:
failed_count += 1
logging.warning(
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
f"Failed to queue broadcast to {uid}: {type(e).__name__} {e}"
)
log_payload_failed = {
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content":
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": user_id_to_send
}
await message_log_dal.create_message_log(
session, log_payload_failed)
await asyncio.sleep(0.05)
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content": f"For user {uid}: {type(e).__name__} {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
try:
await session.commit()
@@ -192,17 +334,92 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
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)
await callback.message.answer(
# Prepare queue stats presentation
queue_stats = queue_manager.get_queue_stats()
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
initial_user_failed = queue_stats.get("user_failed_messages", 0)
initial_group_failed = queue_stats.get("group_failed_messages", 0)
def build_queue_status(stats: dict) -> str:
dynamic_failed = max(
0, stats.get("user_failed_messages", 0) - initial_user_failed
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
total_failed = failed_count + dynamic_failed
return _(
"broadcast_queue_result",
default=(
"🚀 Рассылка поставлена в очередь!\n"
"📤 В очередь добавлено: {sent_count}\n"
"❌ Ошибок: {failed_count}\n\n"
"📊 Статус очередей:\n"
"👥 Очередь пользователей: {user_queue_size} сообщений\n"
"📢 Очередь групп: {group_queue_size} сообщений\n\n"
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
),
sent_count=sent_count,
failed_count=total_failed,
user_queue_size=stats["user_queue_size"],
group_queue_size=stats["group_queue_size"],
)
result_message = build_queue_status(queue_stats)
status_message = await callback.message.answer(
result_message,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
reply_markup=back_keyboard,
)
async def auto_update_queue_status() -> None:
"""Refresh queue stats message twice per second via message edit."""
last_text = result_message
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
max_iterations = 240
for _ in range(max_iterations):
await asyncio.sleep(0.5)
stats = queue_manager.get_queue_stats()
new_text = build_queue_status(stats)
queues_drained = (
stats["user_queue_size"] == 0
and stats["group_queue_size"] == 0
and not stats.get("user_queue_processing")
and not stats.get("group_queue_processing")
)
if new_text != last_text:
try:
await status_message.edit_text(
new_text,
reply_markup=back_keyboard,
)
last_text = new_text
except TelegramBadRequest as e:
if "message is not modified" in str(e):
last_text = new_text
else:
logging.debug(
"Broadcast queue auto-update stopped: %s", e
)
break
except Exception as e:
logging.debug(
"Broadcast queue auto-update unexpected error: %s", e
)
break
if queues_drained:
# Final refresh already attempted; exit loop.
break
else:
logging.debug("Broadcast queue auto-update reached time limit.")
asyncio.create_task(auto_update_queue_status())
elif action == "cancel":
await callback.message.edit_text(
_("admin_broadcast_cancelled"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.clear()
+142 -4
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
@@ -24,6 +31,7 @@ router = Router(name="admin_common_router")
@router.message(Command("admin"))
async def admin_panel_command_handler(
message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
):
@@ -34,6 +42,7 @@ async def admin_panel_command_handler(
await message.answer("Language service error.")
return
await state.clear()
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await message.answer(_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(
@@ -71,10 +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_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(
@@ -82,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(
@@ -89,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(
@@ -99,6 +121,18 @@ 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 == "ads":
from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
elif action == "ads_create":
from . import ads as admin_ads_handlers
await admin_ads_handlers.ads_create_start(callback, state, settings, i18n_data)
elif action == "main":
try:
await callback.message.edit_text(
@@ -116,3 +150,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)
))
+250
View File
@@ -0,0 +1,250 @@
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', 'pending_freekassa'] 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',
'freekassa': 'FreeKassa',
}.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 # Show 5 payments per page
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(_("admin_payments_pagination_info",
shown=len(payments),
total=total_count,
current_page=page + 1,
total_pages=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="📊 Payments export completed!\nTotal records: {count}",
count=len(all_payments))
)
await callback.answer(
_("admin_export_sent", default="File sent!"),
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()
+452
View File
@@ -0,0 +1,452 @@
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=_("admin_promo_export_csv_button"), 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)
export_lang = "en"
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")
# Force English caption for exports
await callback.message.answer_document(
file,
caption=i18n.gettext(export_lang, "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)
export_lang = "en"
try:
await callback.answer(i18n.gettext(export_lang, "admin_promo_export_all_generating"), 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 headers (forced to English)
writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"),
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
i18n.gettext(export_lang, "admin_promo_csv_is_active"),
i18n.gettext(export_lang, "admin_promo_csv_valid_until"),
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
])
for promo in all_promos:
# Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Формируем данные для CSV
row = [
promo.code,
promo.bonus_days,
promo.max_activations,
promo.current_activations,
status_text,
i18n.gettext(export_lang, "csv_yes") if promo.is_active else i18n.gettext(export_lang, "csv_no"),
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S") if promo.valid_until else i18n.gettext(export_lang, "admin_promo_valid_indefinitely"),
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 = i18n.gettext(export_lang, "admin_promo_export_all_caption", count=len(all_promos))
await callback.message.answer_document(file, caption=caption)
except Exception as e:
await callback.answer(f"❌ Export error: {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)
-221
View File
@@ -1,221 +0,0 @@
import logging
from aiogram import Router, F, types, Bot
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 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:main",
AdminStates.waiting_for_promo_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()
+126 -11
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,
@@ -45,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
status_emoji = "" if payment.status == 'succeeded' else (
"" if payment.status == 'pending'
or payment.status == 'pending_yookassa' else "")
"" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "")
user_info = f"User {payment.user_id}"
if payment.user and payment.user.username:
@@ -79,9 +196,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}")
+371 -245
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,12 +16,324 @@ 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_created = 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:
# Create new user if they have telegram_id
try:
user_data = {
"user_id": telegram_id_from_panel,
"username": None, # Username will be updated when user interacts with bot
"first_name": None, # Panel doesn't provide this info
"last_name": None, # Panel doesn't provide this info
"language_code": "ru", # Default language
"panel_user_uuid": panel_uuid,
"is_banned": False,
"referred_by_id": None
}
new_user, was_created = await user_dal.create_user(session, user_data)
if was_created:
users_created += 1
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
existing_user = new_user
except Exception as e_create:
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
continue
else:
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
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}")
# Ensure panel description contains Telegram fields
try:
if panel_uuid and existing_user:
description_text = "\n".join([
existing_user.username or "",
existing_user.first_name or "",
existing_user.last_name or "",
])
# Update description only when it differs from the current one on panel
current_panel_description = (panel_user_dict.get("description") or "").strip()
desired_description = description_text.strip()
if desired_description and desired_description != current_panel_description:
await panel_service.update_user_details_on_panel(
panel_uuid, {"description": description_text}
)
except Exception as e_desc:
logging.warning(
f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}"
)
# 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"
# Build additional stats
default_lang = settings.DEFAULT_LANGUAGE
additional_stats = ""
if users_without_telegram_id > 0:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
if users_not_found_in_db > 0:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
if sync_errors:
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
# Build full details using localization
details = i18n_instance.gettext(default_lang, "admin_sync_details",
panel_records_checked=panel_records_checked,
users_found_in_db=users_found_in_db,
users_created=users_created,
users_updated=users_updated,
subscriptions_synced_count=subscriptions_synced_count,
subscriptions_created=subscriptions_created,
subscriptions_updated=subscriptions_updated,
additional_stats=additional_stats
)
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 created: {users_created}")
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,
"users_created": users_created,
"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],
bot: Bot, settings: Settings, i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession):
async def sync_command_handler(
message_event: Union[types.Message, types.CallbackQuery],
bot: Bot,
settings: Settings,
i18n_data: dict,
panel_service: PanelApiService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -33,254 +346,67 @@ async def sync_command_handler(message_event: Union[types.Message,
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
target_chat_id = message_event.chat.id if isinstance(
message_event, types.Message) else (
message_event.message.chat.id if message_event.message else None)
target_chat_id = (
message_event.chat.id
if isinstance(message_event, types.Message)
else (message_event.message.chat.id if message_event.message else None)
)
if not target_chat_id:
logging.error("Sync handler: could not determine target_chat_id.")
if isinstance(message_event, types.CallbackQuery):
await message_event.answer("Error initiating sync.",
show_alert=True)
await message_event.answer("Error initiating sync.", show_alert=True)
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."
)
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"))
async def sync_status_command_handler(message: types.Message, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def sync_status_command_handler(
message: types.Message, 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:
@@ -292,13 +418,12 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
response_text = ""
if status_record_model:
last_time_val = status_record_model.last_sync_time
last_time_str = last_time_val.strftime(
'%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A"
last_time_str = (
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
)
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"
@@ -306,8 +431,9 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n"
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
f" {_('admin_stats_sync_details_label')}: {details_str}")
f" {_('admin_stats_sync_details_label')}: {details_str}"
)
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
+361
View File
@@ -0,0 +1,361 @@
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'],
active_today=user_stats['active_today'],
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
+3 -2
View File
@@ -1,7 +1,8 @@
from aiogram import Router
from . import start
from . import subscription
# TODO: after splitting subscription into a package, replace this import
from .subscription import router as subscription_router
from . import referral
from . import promo_user
from . import trial_handler
@@ -11,5 +12,5 @@ user_router_aggregate = Router(name="user_router_aggregate")
user_router_aggregate.include_router(promo_user.router)
user_router_aggregate.include_router(trial_handler.router)
user_router_aggregate.include_router(start.router)
user_router_aggregate.include_router(subscription.router)
user_router_aggregate.include_router(subscription_router)
user_router_aggregate.include_router(referral.router)
+288 -39
View File
@@ -12,19 +12,23 @@ from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from yookassa.domain.models.amount import Amount as YooKassaAmount
from db.dal import payment_dal, user_dal
from db.dal import payment_dal, user_dal, user_billing_dal
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.payment_service import YooKassaService
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
async def process_successful_payment(session: AsyncSession, bot: Bot,
@@ -38,8 +42,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months_str = metadata.get("subscription_months")
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get(
"auto_renew_for_subscription_id")
if not user_id_str or not subscription_months_str or not payment_db_id_str:
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (not user_id_str or not subscription_months_str
or (not payment_db_id_str and not auto_renew_subscription_id_str)):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
)
@@ -49,7 +58,9 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
user_id = int(user_id_str)
subscription_months = int(subscription_months_str)
payment_db_id = int(payment_db_id_str)
payment_db_id = int(
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id)
promo_code_id = int(
promo_code_id_str
) if promo_code_id_str and promo_code_id_str.isdigit() else None
@@ -57,6 +68,44 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
amount_data = payment_info_from_webhook.get("amount", {})
payment_value = float(amount_data.get("value", 0.0))
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
# Create/ensure provider payment by YooKassa payment id for idempotency
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
from db.dal import payment_dal as _payment_dal
ensured_payment = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=subscription_months,
description=payment_info_from_webhook.get(
"description") or f"Auto-renewal for {subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = ensured_payment.payment_id
# Also persist yookassa_payment_id field if not set yet
try:
await _payment_dal.update_payment_status_by_db_id(
session,
payment_db_id,
payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id_from_hook,
)
except Exception:
# Non-fatal; continue processing
logging.exception(
"Failed to backfill yookassa_payment_id for ensured auto-renew payment"
)
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
exc_info=True,
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
@@ -87,6 +136,42 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and isinstance(payment_method, dict) and payment_method.get("saved", False):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
display_network = None
display_last4 = None
# Build generic display for various instrument types
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
# Wallets, SBP, etc. — use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm_id,
card_last4=display_last4,
card_network=display_network,
)
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
@@ -105,7 +190,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id)
promo_code_id_from_payment=promo_code_id,
provider="yookassa")
if not activation_details or not activation_details.get('end_date'):
logging.error(
@@ -120,7 +206,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
"applied_promo_bonus_days", 0)
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, subscription_months)
session,
user_id,
subscription_months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get(
"referee_new_end_date"):
@@ -129,51 +220,96 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days")
# Use user's DB language for all user-facing messages
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
success_message = ""
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id)
if inviter and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
success_message = _(
"payment_successful_with_referral_bonus",
# For auto-renew charges, avoid re-sending config link; send concise message
if is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=subscription_months,
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
success_message = _(
"payment_successful_with_promo",
months=subscription_months,
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
elif final_end_date_for_user:
success_message = _(
"payment_successful",
months=subscription_months,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
)
details_markup = None
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
success_message = _("payment_successful_error_details")
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(
session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
details_message = _(
"payment_successful_with_referral_bonus_full",
months=subscription_months,
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=subscription_months,
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=subscription_months,
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang, i18n, settings, config_link, preserve_message=True
)
try:
await bot.send_message(user_id, success_message)
await bot.send_message(
user_id,
details_message,
reply_markup=details_markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(
f"Failed to send final payment success message to user {user_id}: {e_notify}"
f"Failed to send payment details message to user {user_id}: {e_notify}"
)
# 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(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
@@ -272,6 +408,40 @@ async def yookassa_webhook_route(request: web.Request):
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, 'payment_method', None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, 'card', None)
pm_dict = {
"id": getattr(pm_obj, 'id', None),
"type": getattr(pm_obj, 'type', None),
"saved": bool(getattr(pm_obj, 'saved', False)),
"title": getattr(pm_obj, 'title', None),
"account_number": (
getattr(pm_obj, 'account_number', None)
if hasattr(pm_obj, 'account_number') else (
getattr(pm_obj, 'account', None)
if hasattr(pm_obj, 'account') else None
)
),
"card": (
{
"first6": getattr(card_obj, 'first6', None),
"last4": getattr(card_obj, 'last4', None),
"expiry_month": getattr(card_obj, 'expiry_month', None),
"expiry_year": getattr(card_obj, 'expiry_year', None),
"card_type": getattr(card_obj, 'card_type', None),
}
if card_obj is not None
else None
),
}
except Exception:
logging.exception("Failed to serialize YooKassa payment_method from webhook")
pm_dict = None
payment_dict_for_processing = {
"id":
str(payment_data_from_notification.id),
@@ -288,6 +458,7 @@ async def yookassa_webhook_route(request: web.Request):
"description":
str(payment_data_from_notification.description)
if payment_data_from_notification.description else None,
"payment_method": pm_dict,
}
async with payment_processing_lock:
@@ -313,6 +484,84 @@ async def yookassa_webhook_route(request: web.Request):
session, bot, payment_dict_for_processing,
i18n_instance, settings)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1":
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get("payment_method")
if isinstance(payment_method, dict) and payment_method.get("id"):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(session, user_id)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get('yookassa_service')
if yk:
await yk.cancel_payment(payment_dict_for_processing.get("id"))
except Exception:
logging.exception("Failed to cancel bind-only payment auth")
except Exception:
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
except Exception as e_webhook_db_processing:
await session.rollback()
logging.error(
+45 -29
View File
@@ -10,7 +10,11 @@ from config.settings import Settings
from bot.states.user_states import UserPromoStates
from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from datetime import datetime
from bot.middlewares.i18n import JsonI18n
from .start import send_main_menu
@@ -65,6 +69,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
async def process_promo_code_input(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict,
promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot, session: AsyncSession):
logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
@@ -100,50 +105,61 @@ 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, response_text_from_service = await promo_code_service.apply_promo_code(
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
response_to_user_text = response_text_from_service
if success:
await session.commit()
logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}."
)
new_end_date = result if isinstance(result, datetime) else None
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")
response_to_user_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,
)
reply_markup = get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link
)
else:
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}"
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer(response_to_user_text,
reply_markup=get_back_to_main_menu_markup(
current_lang, i18n),
parse_mode="HTML")
await message.answer(
response_to_user_text,
reply_markup=reply_markup,
parse_mode="HTML",
)
await state.clear()
logging.info(
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
+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()
+392 -42
View File
@@ -1,20 +1,29 @@
import logging
import re
from aiogram import Router, F, types, Bot
from aiogram.utils.text_decorations import html_decoration as hd
from aiogram.filters import CommandStart, Command
from aiogram.fsm.context import FSMContext
from typing import Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timezone
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
from db.dal import user_dal
from db.models import User
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard,
get_language_selection_keyboard,
get_channel_subscription_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
router = Router(name="user_start_router")
@@ -30,7 +39,7 @@ async def send_main_menu(target_event: Union[types.Message,
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
user_id = target_event.from_user.id
user_full_name = target_event.from_user.full_name
user_full_name = hd.quote(target_event.from_user.full_name)
if not i18n:
logging.error(
@@ -41,14 +50,14 @@ async def send_main_menu(target_event: Union[types.Message,
await target_event.answer(err_msg_fallback, show_alert=True)
except Exception:
pass
elif isinstance(target_event, types.Message) and hasattr(
target_event, 'chat') and target_event.chat:
elif isinstance(target_event, types.Message):
try:
await target_event.chat.send_message(err_msg_fallback)
await target_event.answer(err_msg_fallback)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
show_trial_button_in_menu = False
@@ -91,33 +100,220 @@ async def send_main_menu(target_event: Union[types.Message,
await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery):
await target_event.answer()
try:
await target_event.answer()
except Exception:
pass
except Exception as e_send_edit:
logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
)
if is_edit and target_message_obj and hasattr(
target_message_obj, 'chat') and target_message_obj.chat:
if is_edit and target_message_obj:
try:
await target_message_obj.chat.send_message(
text, reply_markup=reply_markup)
await target_message_obj.answer(text, reply_markup=reply_markup)
except Exception as e_send_new:
logging.error(
f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
)
if isinstance(target_event, types.CallbackQuery):
await target_event.answer(
_("error_occurred_try_again") if is_edit else None)
try:
await target_event.answer(
_("error_occurred_try_again") if is_edit else None)
except Exception:
pass
async def ensure_required_channel_subscription(
event: Union[types.Message, types.CallbackQuery],
settings: Settings,
i18n: Optional[JsonI18n],
current_lang: str,
session: AsyncSession,
db_user: Optional[User] = None) -> bool:
"""
Verify that the user is a member of the required channel (if configured).
Returns True when access can proceed, False when user must subscribe first.
"""
required_channel_id = settings.REQUIRED_CHANNEL_ID
if not required_channel_id:
return True
if isinstance(event, types.CallbackQuery):
user_id = event.from_user.id
bot_instance: Optional[Bot] = getattr(event, "bot", None)
if bot_instance is None and event.message:
bot_instance = event.message.bot
message_obj: Optional[types.Message] = event.message
else:
user_id = event.from_user.id
bot_instance = event.bot if hasattr(event, "bot") else None
message_obj = event
if bot_instance is None:
logging.error(
"Channel subscription check: bot instance missing for user %s.", user_id
)
return False
if user_id in settings.ADMIN_IDS:
return True
if db_user is None:
try:
db_user = await user_dal.get_user_by_id(session, user_id)
except Exception as fetch_error:
logging.error(
"Channel subscription check: failed to fetch user %s: %s",
user_id,
fetch_error,
exc_info=True,
)
return False
if not db_user:
logging.warning(
"Required channel check skipped because user %s is not persisted yet.",
user_id,
)
return True
if (db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for
== required_channel_id):
return True
def translate(key: str, **kwargs) -> str:
if i18n:
return i18n.gettext(current_lang, key, **kwargs)
return key
now = datetime.now(timezone.utc)
is_member = False
status_value = None
try:
member = await bot_instance.get_chat_member(required_channel_id, user_id)
status = getattr(member, "status", None)
status_value = getattr(status, "value", status)
allowed_statuses = {"creator", "administrator", "member", "restricted"}
if status_value in allowed_statuses:
is_member = True
except TelegramBadRequest as bad_request:
logging.info(
"Required channel check: user %s not subscribed (details: %s)",
user_id,
bad_request,
)
except TelegramForbiddenError as forbidden_error:
logging.error(
"Required channel check failed due to insufficient permissions: %s",
forbidden_error,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
except TelegramAPIError as api_error:
logging.error(
"Required channel check failed for user %s: %s",
user_id,
api_error,
exc_info=True,
)
error_text = translate("channel_subscription_check_failed")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(error_text, show_alert=True)
except Exception:
pass
if message_obj:
try:
await message_obj.answer(error_text)
except Exception:
pass
else:
await event.answer(error_text)
return False
update_payload = {
"channel_subscription_checked_at": now,
"channel_subscription_verified_for": required_channel_id,
"channel_subscription_verified": is_member,
}
try:
await user_dal.update_user(session, user_id, update_payload)
except Exception as update_error:
logging.error(
"Failed to persist channel verification result for user %s: %s",
user_id,
update_error,
exc_info=True,
)
if is_member:
logging.info(
"User %s confirmed as member of required channel %s (status=%s).",
user_id,
required_channel_id,
status_value,
)
return True
keyboard = (get_channel_subscription_keyboard(
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
)
if i18n else None)
prompt_text = translate("channel_subscription_required")
if isinstance(event, types.CallbackQuery):
if keyboard and event.message:
try:
await event.message.edit_text(prompt_text, reply_markup=keyboard)
except Exception as edit_error:
logging.debug(
"Failed to edit prompt message for user %s: %s",
user_id,
edit_error,
)
if keyboard is None and message_obj:
try:
await message_obj.answer(prompt_text)
except Exception:
pass
try:
await event.answer(prompt_text, show_alert=True)
except Exception:
pass
else:
await event.answer(prompt_text, reply_markup=keyboard)
return False
@router.message(CommandStart())
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
command: Optional[CommandStart] = None):
ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -128,37 +324,55 @@ async def start_command_handler(message: types.Message,
user_id = user.id
referred_by_user_id: Optional[int] = None
if command and command.args:
arg_payload = command.args
if arg_payload.startswith("ref_"):
try:
potential_referrer_id_str = arg_payload.split("_")[1]
if potential_referrer_id_str.isdigit():
potential_referrer_id = int(potential_referrer_id_str)
if potential_referrer_id != user_id:
referred_by_user_id = potential_referrer_id
except (IndexError, ValueError) as e:
logging.warning(
f"Could not parse referral from /start args '{arg_payload}': {e}"
)
promo_code_to_apply: Optional[str] = None
ad_start_param: Optional[str] = None
if ref_match:
potential_referrer_id = int(ref_match.group(1))
if await user_dal.get_user_by_id(session, potential_referrer_id):
referred_by_user_id = potential_referrer_id
elif promo_match:
promo_code_to_apply = promo_match.group(1)
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
elif ad_param_match:
ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
sanitized_username = sanitize_username(user.username)
sanitized_first_name = sanitize_display_name(user.first_name)
sanitized_last_name = sanitize_display_name(user.last_name)
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
user_data_to_create = {
"user_id": user_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"username": sanitized_username,
"first_name": sanitized_first_name,
"last_name": sanitized_last_name,
"language_code": current_lang,
"referred_by_id": referred_by_user_id,
"registration_date": datetime.now(timezone.utc)
}
try:
db_user = await user_dal.create_user(session, user_data_to_create)
db_user, created = await user_dal.create_user(session, user_data_to_create)
logging.info(
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
)
if created:
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=sanitized_username,
first_name=sanitized_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(
@@ -170,14 +384,21 @@ async def start_command_handler(message: types.Message,
update_payload = {}
if db_user.language_code != current_lang:
update_payload["language_code"] = current_lang
# Set referral only if not already set AND user is not currently active.
# This allows previously subscribed but currently inactive users to be attributed.
if referred_by_user_id and db_user.referred_by_id is None:
update_payload["referred_by_id"] = referred_by_user_id
if user.username != db_user.username:
update_payload["username"] = user.username
if user.first_name != db_user.first_name:
update_payload["first_name"] = user.first_name
if user.last_name != db_user.last_name:
update_payload["last_name"] = user.last_name
try:
is_active_now = await subscription_service.has_active_subscription(session, user_id)
except Exception:
is_active_now = False
if not is_active_now:
update_payload["referred_by_id"] = referred_by_user_id
if sanitized_username != db_user.username:
update_payload["username"] = sanitized_username
if sanitized_first_name != db_user.first_name:
update_payload["first_name"] = sanitized_first_name
if sanitized_last_name != db_user.last_name:
update_payload["last_name"] = sanitized_last_name
if update_payload:
try:
@@ -192,7 +413,75 @@ 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=user.full_name))
# Attribute user to ad campaign if start param provided
if ad_start_param:
try:
from db.dal import ad_dal as _ad_dal
campaign = await _ad_dal.get_campaign_by_start_param(session, ad_start_param)
if campaign and campaign.is_active:
await _ad_dal.ensure_attribution(session, user_id=user_id, campaign_id=campaign.ad_campaign_id)
await session.commit()
except Exception as e_attr:
logging.error(f"Failed to attribute user {user_id} to ad '{ad_start_param}': {e_attr}")
try:
await session.rollback()
except Exception:
pass
if not await ensure_required_channel_subscription(message, settings, i18n,
current_lang, session,
db_user):
return
# 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,
@@ -201,6 +490,57 @@ async def start_command_handler(message: types.Message,
is_edit=False)
@router.callback_query(F.data == "channel_subscription:verify")
async def verify_channel_subscription_callback(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
verified = await ensure_required_channel_subscription(
callback, settings, i18n, current_lang, session, db_user)
if not verified:
return
if db_user and db_user.language_code:
current_lang = db_user.language_code
i18n_data["current_language"] = current_lang
if i18n:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
else:
_ = lambda key, **kwargs: key
if not settings.DISABLE_WELCOME_MESSAGE:
welcome_text = _(key="welcome",
user_name=hd.quote(callback.from_user.full_name))
if callback.message:
await callback.message.answer(welcome_text)
else:
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
if fallback_bot:
await fallback_bot.send_message(callback.from_user.id,
welcome_text)
try:
await callback.answer(_(key="channel_subscription_verified_success"),
show_alert=True)
except Exception:
pass
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=bool(callback.message))
@router.message(Command("language"))
@router.callback_query(F.data == "main_action:language")
async def language_command_handler(
@@ -305,10 +645,13 @@ async def main_action_callback_handler(
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
elif action == "my_devices":
await user_subscription_handlers.my_devices_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
session, bot)
elif action == "referral":
await user_referral_handlers.referral_command_handler(
callback, settings, i18n_data, referral_service, bot, session)
@@ -328,6 +671,13 @@ async def main_action_callback_handler(
subscription_service,
session,
is_edit=True)
elif action == "back_to_main_keep":
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=False)
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
-362
View File
@@ -1,362 +0,0 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.payment_service import YooKassaService
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_subscription_router")
async def display_subscription_options(event: Union[types.Message,
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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n:
err_msg = "Language service error."
if isinstance(event, types.CallbackQuery):
await event.answer(err_msg, show_alert=True)
elif isinstance(event, types.Message):
await event.answer(err_msg)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("select_subscription_period"
) if settings.subscription_options else get_text(
"no_subscription_options_available")
reply_markup = get_subscription_options_keyboard(
settings.subscription_options, currency_symbol_val, current_lang, i18n
) if settings.subscription_options else get_back_to_main_menu_markup(
current_lang, i18n)
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
await event.answer(get_text("error_occurred_try_again"),
show_alert=True)
return
if isinstance(event, types.CallbackQuery):
try:
await target_message_obj.edit_text(text_content,
reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_content,
reply_markup=reply_markup)
await event.answer()
else:
await target_message_obj.answer(text_content,
reply_markup=reply_markup)
@router.callback_query(F.data.startswith("subscribe_period:"))
async def select_subscription_period_callback_handler(
callback: types.CallbackQuery, 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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not callback.message:
await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
return
try:
months = int(callback.data.split(":")[-1])
except (ValueError, IndexError):
logging.error(
f"Invalid subscription period in callback_data: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
price_rub = settings.subscription_options.get(months)
if price_rub is None:
logging.error(
f"Price not found for {months} months subscription period in settings.subscription_options."
)
await callback.answer(get_text("error_try_again"), show_alert=True)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
confirmation_text_content = get_text("confirm_subscription_prompt",
months=months,
price=f"{price_rub:.2f}",
currency_symbol=currency_symbol_val)
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
currency_symbol_val,
current_lang, i18n)
try:
await callback.message.edit_text(confirmation_text_content,
reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
)
await callback.message.answer(confirmation_text_content,
reply_markup=reply_markup)
await callback.answer()
@router.callback_query(F.data.startswith("confirm_sub:"))
async def confirm_subscription_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not callback.message:
await callback.answer(get_text("error_occurred_try_again"),
show_alert=True)
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable")
)
await callback.answer(get_text("payment_service_unavailable_alert"),
show_alert=True)
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(
f"Invalid confirmation data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription",
months=months)
currency_code_for_yk = "RUB"
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": months,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True)
await callback.message.edit_text(
get_text("error_creating_payment_record"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if not db_payment_record:
await callback.message.edit_text(
get_text("error_creating_payment_record"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
}
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"))
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
exc_info=True)
await callback.message.edit_text(
get_text("error_payment_gateway_link_failed"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
await callback.message.edit_text(
get_text(key="payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"], current_lang, i18n),
disable_web_page_preview=False)
else:
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation")
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
exc_info=True)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
)
await callback.message.edit_text(get_text("error_payment_gateway"))
await callback.answer()
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
settings: Settings, panel_service: PanelApiService,
subscription_service: SubscriptionService, session: AsyncSession,
bot: Bot):
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
user = event.from_user
if isinstance(event, types.CallbackQuery):
await event.answer()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not target_message_obj:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not panel_service or not subscription_service:
logging.error(
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
)
await target_message_obj.answer(get_text("error_service_unavailable"))
return
active_sub_details = await subscription_service.get_active_subscription_details(
session, user.id)
sub_info_text_content = ""
if active_sub_details:
end_date_obj = active_sub_details.get('end_date')
days_left = 0
if end_date_obj:
if end_date_obj.tzinfo is None:
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
days_left = (end_date_obj.date() - datetime.now().date()).days
actual_config_link = active_sub_details.get('config_link') or get_text(
"config_link_not_available")
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
traffic_limit_gb_str = get_text("traffic_unlimited")
if traffic_limit_bytes and traffic_limit_bytes > 0:
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
traffic_used_gb_str = get_text("traffic_na")
if traffic_used_bytes is not None:
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
sub_info_text_content = get_text(
"my_subscription_details",
end_date=end_date_obj.strftime("%Y-%m-%d")
if end_date_obj else "N/A",
days_left=max(0, days_left),
status=active_sub_details.get(
'status_from_panel', get_text('status_active')).capitalize(),
config_link=actual_config_link,
traffic_limit=traffic_limit_gb_str,
traffic_used=traffic_used_gb_str)
else:
sub_info_text_content = get_text("subscription_not_active")
logging.info(
f"User {user.id} no active sub details for 'my_subscription'.")
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
if isinstance(event, types.CallbackQuery) and event.message:
try:
await event.message.edit_text(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
except Exception as e_edit:
logging.warning(
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
)
await bot.send_message(chat_id=target_message_obj.chat.id,
text=sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
else:
await target_message_obj.answer(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
@router.message(Command("connect"))
async def connect_command_handler(message: types.Message, i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession, bot: Bot):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings,
panel_service, subscription_service,
session, bot)
@@ -0,0 +1,17 @@
from aiogram import Router
from . import core
from . import payments
from . import payment_methods
router = Router(name="user_subscription_router")
# Include sub-routers
router.include_router(core.router)
router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401
+567
View File
@@ -0,0 +1,567 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from typing import Optional, Union
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard,
get_back_to_main_menu_markup,
get_autorenew_confirm_keyboard,
)
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from db.dal import subscription_dal
from db.models import Subscription
router = Router(name="user_subscription_core_router")
async def display_subscription_options(event: Union[types.Message, 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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n:
err_msg = "Language service error."
if isinstance(event, types.CallbackQuery):
try:
await event.answer(err_msg, show_alert=True)
except Exception:
pass
elif isinstance(event, types.Message):
await event.answer(err_msg)
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
text_content = get_text("select_subscription_period") if settings.subscription_options else get_text("no_subscription_options_available")
reply_markup = (
get_subscription_options_keyboard(settings.subscription_options, currency_symbol_val, current_lang, i18n)
if settings.subscription_options
else get_back_to_main_menu_markup(current_lang, i18n)
)
target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event
if not target_message_obj:
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if isinstance(event, types.CallbackQuery):
try:
await target_message_obj.edit_text(text_content, reply_markup=reply_markup)
except Exception:
await target_message_obj.answer(text_content, reply_markup=reply_markup)
try:
await event.answer()
except Exception:
pass
else:
await target_message_obj.answer(text_content, reply_markup=reply_markup)
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
if not i18n or not target:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not panel_service or not subscription_service:
await target.answer(get_text("error_service_unavailable"))
return
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
if not active:
text = get_text("subscription_not_active")
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline", default="Купить"), callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = InlineKeyboardMarkup(inline_keyboard=[[buy_button], *back_markup.inline_keyboard])
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=kb)
except Exception:
await event.message.answer(text, reply_markup=kb)
else:
await event.answer(text, reply_markup=kb)
return
end_date = active.get("end_date")
days_left = (end_date.date() - datetime.now().date()).days if end_date else 0
tribute_hint = ""
if active.get("status_from_panel", "").lower() == "active":
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
if local_sub:
if local_sub.provider == "tribute":
link = None
link = settings.tribute_payment_links.get(local_sub.duration_months or 1) if hasattr(settings, "tribute_payment_links") else None
tribute_hint = "\n\n" + (
get_text("subscription_tribute_notice_with_link", link=link) if link else get_text("subscription_tribute_notice")
)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=active.get("config_link") or get_text("config_link_not_available"),
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na")
),
)
base_markup = get_back_to_main_menu_markup(current_lang, i18n)
kb = base_markup.inline_keyboard
try:
local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id)
# Build rows to prepend above the base "back" markup
prepend_rows = []
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
if settings.SUBSCRIPTION_MINI_APP_URL:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
])
else:
cfg_link_val = (active or {}).get("config_link")
if cfg_link_val:
prepend_rows.append([
InlineKeyboardButton(
text=get_text("connect_button"),
url=cfg_link_val,
)
])
if settings.MY_DEVICES_SECTION_ENABLED:
max_devices_value = active.get("max_devices")
max_devices_display = get_text("devices_unlimited_label")
if max_devices_value not in (None, 0):
try:
max_devices_int = int(max_devices_value)
if max_devices_int >= 0:
max_devices_display = str(max_devices_int)
except (TypeError, ValueError):
max_devices_display = str(max_devices_value)
current_devices_display = "?"
user_uuid = active.get("user_id")
devices_response = None
if user_uuid:
try:
devices_response = await panel_service.get_user_devices(user_uuid)
except Exception:
logging.exception("Failed to load devices for user %s", user_uuid)
if devices_response:
devices_count: Optional[int] = None
if isinstance(devices_response, dict):
devices_list = devices_response.get("devices")
if isinstance(devices_list, list):
devices_count = len(devices_list)
elif isinstance(devices_list, int):
devices_count = devices_list
else:
try:
devices_count = len(devices_list) # type: ignore[arg-type]
except Exception:
devices_count = None
if devices_count is None:
total_value = devices_response.get("total")
if isinstance(total_value, int):
devices_count = total_value
elif isinstance(devices_response, list):
devices_count = len(devices_response)
if devices_count is not None:
current_devices_display = str(devices_count)
devices_button_text = get_text(
"devices_button",
current_devices=current_devices_display,
max_devices=max_devices_display,
)
prepend_rows.append([
InlineKeyboardButton(
text=devices_button_text,
callback_data="main_action:my_devices",
)
])
# 2) Auto-renew toggle (if supported and not tribute)
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
toggle_text = (
get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button")
)
prepend_rows.append([
InlineKeyboardButton(
text=toggle_text,
callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}",
)
])
# 3) Payment methods management (when autopayments enabled)
if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
prepend_rows.append([
InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")
])
if prepend_rows:
kb = prepend_rows + kb
except Exception:
pass
markup = InlineKeyboardMarkup(inline_keyboard=kb)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except Exception:
await bot.send_message(
chat_id=target.chat.id,
text=text + tribute_hint,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
else:
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.callback_query(F.data == "main_action:my_devices")
async def my_devices_command_handler(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
if not i18n or not target:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not settings.MY_DEVICES_SECTION_ENABLED:
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception:
pass
else:
await target.answer(get_text("my_devices_feature_disabled"))
return
# TODO: context?
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
if not active or not active.get("user_id"):
message = get_text("subscription_not_active")
if isinstance(event, types.CallbackQuery):
try:
await event.answer(message, show_alert=True)
except Exception:
pass
else:
await target.answer(message)
return
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
if not devices:
if isinstance(event, types.CallbackQuery):
try:
await event.answer(get_text("no_devices_found"), show_alert=True)
except Exception:
pass
else:
await target.answer(get_text("no_devices_found"))
return
max_devices_value = active.get("max_devices")
max_devices_display = get_text("devices_unlimited_label")
if max_devices_value not in (None, 0):
try:
max_devices_int = int(max_devices_value)
if max_devices_int >= 0:
max_devices_display = str(max_devices_int)
except (TypeError, ValueError):
max_devices_display = str(max_devices_value)
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
else:
devices_list = []
current_devices = len(devices.get('devices') or [])
for index, device in enumerate(devices.get('devices') or [], start=1):
device_model = device.get('deviceModel') or None
platform = device.get('platform') or None
user_agent = device.get('userAgent') or None
os_version = device.get('osVersion') or None
created_at = device.get('createdAt')
hwid = device.get('hwid')
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
devices_list.append(device_details)
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
kb = base_markup.inline_keyboard
devices_kb = []
for index, device in enumerate(devices.get('devices') or [], start=1):
hwid = device.get('hwid')
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
kb = devices_kb + kb
markup = InlineKeyboardMarkup(inline_keyboard=kb)
if isinstance(event, types.CallbackQuery):
try:
await event.answer()
except Exception:
pass
try:
await event.message.edit_text(text, reply_markup=markup)
except Exception:
await event.message.answer(text, reply_markup=markup)
else:
await target.answer(text, reply_markup=markup)
@router.callback_query(F.data.startswith("disconnect_device:"))
async def disconnect_device_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not settings.MY_DEVICES_SECTION_ENABLED:
try:
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
except Exception:
pass
return
try:
_, hwid = callback.data.split(":", 1)
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
if not active:
await callback.answer(get_text("subscription_not_active"), show_alert=True)
return
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
if not success:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
await session.commit()
try:
await callback.answer(get_text("device_disconnected"))
except Exception:
pass
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@router.callback_query(F.data.startswith("toggle_autorenew:"))
async def toggle_autorenew_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
try:
_, payload = callback.data.split(":", 1)
sub_id_str, enable_str = payload.split(":")
sub_id = int(sub_id_str)
enable = bool(int(enable_str))
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
sub = await session.get(Subscription, sub_id)
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
return
# Show confirmation popup and inline buttons
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
kb = get_autorenew_confirm_keyboard(enable, sub.subscription_id, current_lang, i18n)
try:
await callback.message.edit_text(confirm_text, reply_markup=kb)
except Exception:
try:
await callback.message.answer(confirm_text, reply_markup=kb)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
@router.callback_query(F.data.startswith("autorenew:confirm:"))
async def confirm_autorenew_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
try:
_, _, sub_id_str, enable_str = callback.data.split(":", 3)
sub_id = int(sub_id_str)
enable = bool(int(enable_str))
except Exception:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
sub = await session.get(Subscription, sub_id)
if not sub or sub.user_id != callback.from_user.id:
await callback.answer(get_text("error_try_again"), show_alert=True)
return
if sub.provider == "tribute":
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@router.callback_query(F.data == "autorenew:cancel")
async def autorenew_cancel_from_webhook_button(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
bot: Bot,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
# Disable auto-renew on the active subscription (non-tribute)
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, callback.from_user.id)
if not sub:
try:
await callback.answer(get_text("subscription_not_active"), show_alert=True)
except Exception:
pass
return
if sub.provider == "tribute":
try:
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
except Exception:
pass
return
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": False})
await session.commit()
try:
await callback.answer(get_text("subscription_autorenew_updated"))
except Exception:
pass
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
@router.message(Command("connect"))
async def connect_command_handler(
message: types.Message,
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot,
):
logging.info(f"User {message.from_user.id} used /connect command.")
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
@@ -0,0 +1,458 @@
from aiogram import Router, F, types
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from bot.keyboards.inline.user_keyboards import (
get_payment_methods_list_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_bind_url_keyboard,
)
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from db.dal import user_billing_dal
from db.models import Payment
from sqlalchemy.future import select
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(callback: types.CallbackQuery, 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
description="Bind card",
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=True,
capture=False,
bind_only=True,
)
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(_("payment_methods_title"), reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(_("payment_method_delete_confirm"), reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(callback: types.CallbackQuery, 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id_raw = parts[2] if len(parts) >= 3 else ""
deleted = False
try:
from db.dal.user_billing_dal import (
delete_user_payment_method,
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(session, callback.from_user.id, int(pm_id_raw))
else:
deleted = await delete_user_payment_method_by_provider_id(session, callback.from_user.id, pm_id_raw)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(session, callback.from_user.id)
deleted = deleted or legacy_deleted
except Exception:
pass
await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id)
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(f"{msg}\n\n{text}", reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(callback: types.CallbackQuery, 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next((m for m in methods if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id), methods[0])
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime('%Y-%m-%d') if getattr(sel, 'created_at', None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime('%Y-%m-%d')
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(str(sel.method_id), current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
return
added_at = billing.created_at.strftime('%Y-%m-%d') if getattr(billing, 'created_at', None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == 'succeeded',
Payment.provider == 'yookassa',
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime('%Y-%m-%d')
except Exception:
pass
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card", default="Card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic", default="Payment method")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}"
await callback.message.edit_text(details, reply_markup=get_payment_method_details_keyboard(billing.yookassa_payment_method_id, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, yookassa_service: YooKassaService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
selected_pm_provider_id: Optional[str] = None
pm_filter_requested: bool = False
try:
split_a, split_b, split_pm_id = callback.data.split(":", 2)
if split_pm_id:
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
selected_pm_provider_id = sel.provider_payment_method_id
else:
selected_pm_provider_id = split_pm_id
except Exception:
selected_pm_provider_id = None
pm_filter_requested = False
if pm_filter_requested and not selected_pm_provider_id:
user_payments = []
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != 'yookassa':
continue
if p.yookassa_payment_id and yookassa_service:
try:
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
pm = (info or {}).get("payment_method") or {}
if pm.get("id") == selected_pm_provider_id:
filtered.append(p)
continue
except Exception:
pass
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
except Exception:
back_pm_id = ""
back_markup = (
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
if back_pm_id
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
def _format_item(p: Payment) -> str:
title = p.description or _("subscription_purchase_title", months=p.subscription_duration_months or 1)
date_str = p.created_at.strftime('%Y-%m-%d') if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
try:
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import get_back_to_payment_method_details_keyboard, get_payment_methods_manage_keyboard
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(text, reply_markup=back_markup)
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(callback: types.CallbackQuery, 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")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card", default="Card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic", default="Payment method")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
try:
_, _, page_str = callback.data.split(":", 2)
page = int(page_str)
except Exception:
page = 0
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n))
try:
await callback.answer()
except Exception:
pass
File diff suppressed because it is too large Load Diff
+260 -108
View File
@@ -7,7 +7,12 @@ 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.keyboards.inline.user_keyboards import get_trial_confirmation_keyboard, get_main_menu_inline_keyboard
from bot.services.notification_service import NotificationService
from bot.keyboards.inline.user_keyboards import (
get_trial_confirmation_keyboard,
get_main_menu_inline_keyboard,
get_connect_and_main_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from .start import send_main_menu
@@ -15,162 +20,309 @@ router = Router(name="user_trial_router")
async def request_trial_confirmation_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
subscription_service: SubscriptionService, session: AsyncSession):
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
user_id = callback.from_user.id
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
show_trial_btn_in_menu_if_fail = False
if settings.TRIAL_ENABLED:
if not await subscription_service.has_had_any_subscription(
session, user_id):
if not await subscription_service.has_had_any_subscription(session, user_id):
show_trial_btn_in_menu_if_fail = True
if not settings.TRIAL_ENABLED:
await callback.message.edit_text(
_("trial_feature_disabled"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
settings, False))
await callback.answer()
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
)
try:
await callback.answer()
except Exception:
pass
return
if await subscription_service.has_had_any_subscription(session, user_id):
await callback.message.edit_text(
_("trial_already_had_subscription_or_trial"),
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
settings, False))
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")
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()
@router.callback_query(F.data == "trial_action:confirm_activate")
async def confirm_activate_trial_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
subscription_service: SubscriptionService,
panel_service: PanelApiService, session: AsyncSession):
user_id = callback.from_user.id
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
if not i18n or not callback.message:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
return
if not settings.TRIAL_ENABLED:
await callback.answer(_("trial_feature_disabled"), show_alert=True)
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
return
if await subscription_service.has_had_any_subscription(session, user_id):
await callback.answer(_("trial_already_had_subscription_or_trial"),
show_alert=True)
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
reply_markup=get_main_menu_inline_keyboard(
current_lang, i18n, settings, False
),
)
try:
await callback.answer()
except Exception:
pass
return
# Directly activate trial without confirmation
activation_result = await subscription_service.activate_trial_subscription(
session, user_id)
session, user_id
)
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_for_trial = None
if activation_result and activation_result.get("activated"):
await callback.answer(_("trial_activated_alert"), show_alert=True)
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
end_date_obj = activation_result.get("end_date")
config_link_for_trial = activation_result.get("subscription_url") or _(
"config_link_not_available")
"config_link_not_available"
)
if config_link_for_trial == _(
"config_link_not_available") and activation_result.get(
"panel_short_uuid"):
generated_link = await panel_service.get_subscription_link(
activation_result["panel_short_uuid"])
if generated_link: config_link_for_trial = generated_link
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")
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",
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)
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)
# Mark ad attribution trial if exists
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
else:
message_key_from_service = activation_result.get(
"message_key", "trial_activation_failed"
) if activation_result else "trial_activation_failed"
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):
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
):
show_trial_button_after_action = True
reply_markup = (
get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link_for_trial
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
)
)
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)
reply_markup=reply_markup,
disable_web_page_preview=True,
)
except Exception as e_edit:
logging.warning(
f"Could not edit trial result message: {e_edit}. Sending new one.")
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(
if callback.message:
await callback.message.answer(
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)
reply_markup=reply_markup,
disable_web_page_preview=True,
)
@router.callback_query(F.data == "trial_action:confirm_activate")
async def confirm_activate_trial_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
panel_service: PanelApiService,
session: AsyncSession,
):
user_id = callback.from_user.id
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(_("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.TRIAL_ENABLED:
try:
await callback.answer(_("trial_feature_disabled"), show_alert=True)
except Exception:
pass
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
if await subscription_service.has_had_any_subscription(session, user_id):
try:
await callback.answer(
_("trial_already_had_subscription_or_trial"), show_alert=True
)
except Exception:
pass
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
return
activation_result = await subscription_service.activate_trial_subscription(
session, user_id
)
final_message_text_in_chat = ""
show_trial_button_after_action = False
config_link_for_trial = None
if activation_result and activation_result.get("activated"):
try:
await callback.answer(_("trial_activated_alert"), show_alert=True)
except Exception:
pass
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,
)
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)
try:
await callback.answer(final_message_text_in_chat, show_alert=True)
except Exception:
pass
if (
settings.TRIAL_ENABLED
and not await subscription_service.has_had_any_subscription(
session, user_id
)
):
show_trial_button_after_action = True
reply_markup = (
get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link_for_trial
)
if activation_result and activation_result.get("activated")
else get_main_menu_inline_keyboard(
current_lang, i18n, settings, show_trial_button_after_action
)
)
try:
await callback.message.edit_text(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
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:
await callback.message.answer(
final_message_text_in_chat,
parse_mode="HTML",
reply_markup=reply_markup,
disable_web_page_preview=True,
)
if activation_result and activation_result.get("activated") and end_date_obj:
notification_service = NotificationService(callback.bot, settings, i18n)
await notification_service.notify_trial_activation(user_id, end_date_obj)
try:
from db.dal import ad_dal as _ad_dal
await _ad_dal.mark_trial_activated(session, user_id)
await session.commit()
except Exception as e_mark:
await session.rollback()
logging.error(f"Failed to mark trial for ad attribution for user {user_id}: {e_mark}")
@router.callback_query(F.data == "main_action:cancel_trial")
async def cancel_trial_activation(callback: types.CallbackQuery,
settings: Settings, i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession):
await send_main_menu(callback,
settings,
i18n_data,
subscription_service,
session,
is_edit=True)
async def cancel_trial_activation(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
subscription_service: SubscriptionService,
session: AsyncSession,
):
await send_main_menu(
callback, settings, i18n_data, subscription_service, session, is_edit=True
)
+212 -12
View File
@@ -12,25 +12,187 @@ 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_ads_section", default="📈 Реклама"),
callback_data="admin_action:ads")
# Системные функции
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_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()
def get_ads_menu_keyboard(i18n_instance, lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1, 1)
return builder.as_markup()
def get_ads_list_keyboard(
i18n_instance,
lang: str,
campaigns: list,
current_page: int,
total_pages: int,
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
for c in campaigns:
title = f"{c.source}"
builder.button(
text=title,
callback_data=f"admin_ads:card:{c.ad_campaign_id}:{current_page}",
)
# Pagination row (only when needed)
if total_pages > 1:
row = []
if current_page > 0:
row.append(
InlineKeyboardButton(
text="⬅️ " + _("prev_page_button", default="Prev"),
callback_data=f"admin_ads:page:{current_page - 1}",
)
)
row.append(
InlineKeyboardButton(
text=f"{current_page + 1}/{total_pages}",
callback_data="ads_page_display",
)
)
if current_page < total_pages - 1:
row.append(
InlineKeyboardButton(
text=_("next_page_button", default="Next") + " ➡️",
callback_data=f"admin_ads:page:{current_page + 1}",
)
)
if row:
builder.row(*row)
builder.button(text=_(key="admin_ads_create_button", default=" Создать кампанию"),
callback_data="admin_action:ads_create")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1)
return builder.as_markup()
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Dangerous action: Delete campaign
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
callback_data=f"admin_ads:page:{back_page}")
builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main")
builder.adjust(1)
return builder.as_markup()
@@ -41,9 +203,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()
@@ -175,12 +340,47 @@ def get_confirmation_keyboard(yes_callback_data: str, no_callback_data: str,
def get_broadcast_confirmation_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
i18n_instance,
target: str = "all") -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="confirm_broadcast_send_button"),
# Row: target selection (all / active / inactive)
target_all_label = _(
key="broadcast_target_all_button",
default="👥 Все"
)
target_active_label = _(
key="broadcast_target_active_button",
default="✅ Активные"
)
target_inactive_label = _(
key="broadcast_target_inactive_button",
default="⌛ Неактивные"
)
# Highlight current selection with a prefix
def mark_selected(label: str, is_selected: bool) -> str:
return ("" + label) if is_selected else label
builder.button(
text=mark_selected(target_all_label, target == "all"),
callback_data="broadcast_target:all",
)
builder.button(
text=mark_selected(target_active_label, target == "active"),
callback_data="broadcast_target:active",
)
builder.button(
text=mark_selected(target_inactive_label, target == "inactive"),
callback_data="broadcast_target:inactive",
)
builder.adjust(3)
# Row: confirmation
builder.button(text=_(key="confirm_broadcast_send_button", default="🚀 Отправить"),
callback_data="broadcast_final_action:send")
builder.button(text=_(key="cancel_broadcast_button"),
builder.button(text=_(key="cancel_broadcast_button", default="❌ Отмена"),
callback_data="broadcast_final_action:cancel")
builder.adjust(2)
return builder.as_markup()
+346 -20
View File
@@ -1,6 +1,6 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
from aiogram.types import InlineKeyboardMarkup
from typing import Dict, Optional, List
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
from typing import Dict, Optional, List, Tuple
from config.settings import Settings
@@ -22,8 +22,11 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe"))
builder.row(
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription"))
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
)
)
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
@@ -108,48 +111,173 @@ def get_subscription_options_keyboard(subscription_options: Dict[
return builder.as_markup()
def get_confirm_subscription_keyboard(months: int, price: float,
currency_symbol_val: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup:
def get_payment_method_keyboard(months: int, price: float,
tribute_url: Optional[str],
stars_price: Optional[int],
currency_symbol_val: str, lang: str,
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
confirm_text = _(key="confirm_payment_button",
price=price,
currency_symbol=currency_symbol_val)
builder.button(text=confirm_text,
callback_data=f"confirm_sub:{months}:{price}")
if settings.FREEKASSA_ENABLED:
builder.button(text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{months}:{price}")
if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}")
if settings.TRIBUTE_ENABLED and tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
if settings.STARS_ENABLED and stars_price is not None:
builder.button(text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{months}:{stars_price}")
if settings.CRYPTOPAY_ENABLED:
builder.button(text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{months}:{price}")
builder.button(text=_(key="cancel_button"),
callback_data="main_action:subscribe")
builder.adjust(1)
return builder.as_markup()
def get_payment_url_keyboard(payment_url: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup:
def get_payment_url_keyboard(payment_url: str,
lang: str,
i18n_instance,
back_callback: Optional[str] = None,
back_text_key: str = "back_to_main_menu_button"
) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="pay_button"), url=payment_url)
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
if back_callback:
builder.button(text=_(key=back_text_key), callback_data=back_callback)
else:
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
builder.adjust(1)
return builder.as_markup()
def get_yk_autopay_choice_keyboard(
months: int,
price: float,
lang: str,
i18n_instance,
has_saved_cards: bool = True,
) -> InlineKeyboardMarkup:
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
price_str = str(price)
if has_saved_cards:
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_saved_card_button"),
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_new_card_button"),
callback_data=f"pay_yk_new:{months}:{price_str}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_payment_methods_button"),
callback_data=f"subscribe_period:{months}",
)
)
return builder.as_markup()
def get_yk_saved_cards_keyboard(
cards: List[Tuple[str, str]],
months: int,
price: float,
lang: str,
i18n_instance,
page: int = 0,
) -> InlineKeyboardMarkup:
"""Paginated keyboard for selecting a saved YooKassa card."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
per_page = 5
total = len(cards)
start = page * per_page
end = min(total, start + per_page)
price_str = str(price)
for method_id, title in cards[start:end]:
builder.row(
InlineKeyboardButton(
text=title,
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
)
)
nav_buttons: List[InlineKeyboardButton] = []
if start > 0:
nav_buttons.append(
InlineKeyboardButton(
text="⬅️",
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
)
)
if end < total:
nav_buttons.append(
InlineKeyboardButton(
text="➡️",
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
)
)
if nav_buttons:
builder.row(*nav_buttons)
builder.row(
InlineKeyboardButton(
text=_(key="yookassa_autopay_pay_new_card_button"),
callback_data=f"pay_yk_new:{months}:{price_str}",
)
)
builder.row(
InlineKeyboardButton(
text=_(key="back_to_autopay_method_choice_button"),
callback_data=f"pay_yk:{months}:{price_str}",
)
)
return builder.as_markup()
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()
def get_back_to_main_menu_markup(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
i18n_instance,
callback_data: Optional[str] = None) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
if callback_data:
builder.button(text=_(key="back_to_main_menu_button"),
callback_data=callback_data)
else:
builder.button(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main")
return builder.as_markup()
def get_subscribe_only_markup(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe")
return builder.as_markup()
@@ -159,5 +287,203 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
return None
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="contact_support_button"), url=support_link)
builder.button(text=_(key="menu_support_button"), url=support_link)
return builder.as_markup()
def get_channel_subscription_keyboard(
lang: str,
i18n_instance,
channel_link: Optional[str],
include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]:
"""
Return keyboard with buttons to open the required channel and trigger a subscription re-check.
"""
if i18n_instance is None:
return None
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
has_buttons = False
if channel_link:
builder.button(
text=_(key="channel_subscription_join_button"),
url=channel_link,
)
has_buttons = True
if include_check_button:
builder.button(
text=_(key="channel_subscription_verify_button"),
callback_data="channel_subscription:verify",
)
has_buttons = True
if not has_buttons:
return None
builder.adjust(1)
return builder.as_markup()
def get_connect_and_main_keyboard(
lang: str,
i18n_instance,
settings: Settings,
config_link: Optional[str],
preserve_message: bool = False) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
elif config_link:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=config_link)
)
else:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
callback_data="main_action:my_subscription",
)
)
back_callback = "main_action:back_to_main_keep" if preserve_message else "main_action:back_to_main"
builder.row(
InlineKeyboardButton(
text=_("back_to_main_menu_button"),
callback_data=back_callback,
)
)
return builder.as_markup()
def get_payment_methods_manage_keyboard(lang: str, i18n_instance, has_card: bool) -> InlineKeyboardMarkup:
"""Deprecated in favor of get_payment_methods_list_keyboard. Kept for backward compatibility."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind")
)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main")
)
return builder.as_markup()
def get_payment_methods_list_keyboard(
cards: List[Tuple[str, str]],
page: int,
lang: str,
i18n_instance,
) -> InlineKeyboardMarkup:
"""
Build a paginated list of saved payment methods.
cards: list of tuples (payment_method_id, display_title)
page: 0-based page index
"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
per_page = 5
total = len(cards)
start = page * per_page
end = start + per_page
for pm_id, title in cards[start:end]:
builder.row(
InlineKeyboardButton(text=title, callback_data=f"pm:view:{pm_id}")
)
# Pagination controls if needed
nav_buttons: List[InlineKeyboardButton] = []
if start > 0:
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"pm:list:{page-1}"))
if end < total:
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"pm:list:{page+1}"))
if nav_buttons:
builder.row(*nav_buttons)
# Bind new card and back
builder.row(InlineKeyboardButton(text=_(key="payment_method_bind_button"), callback_data="pm:bind"))
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="main_action:back_to_main"))
return builder.as_markup()
def get_payment_method_delete_confirm_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"pm:delete:{pm_id}"),
InlineKeyboardButton(text=_(key="cancel_button"), callback_data=f"pm:view:{pm_id}"),
)
return builder.as_markup()
def get_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="payment_method_tx_history_title"), callback_data=f"pm:history:{pm_id}")
)
builder.row(
InlineKeyboardButton(text=_(key="payment_method_delete_button"), callback_data=f"pm:delete_confirm:{pm_id}")
)
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0")
)
return builder.as_markup()
def get_bind_url_keyboard(bind_url: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="payment_method_bind_button"), url=bind_url)
builder.button(text=_(key="back_to_main_menu_button"), callback_data="pm:manage")
builder.adjust(1)
return builder.as_markup()
def get_back_to_payment_methods_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data="pm:list:0"))
return builder.as_markup()
def get_back_to_payment_method_details_keyboard(pm_id: str, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Back one step: return to specific payment method details
builder.row(InlineKeyboardButton(text=_(key="back_to_main_menu_button"), callback_data=f"pm:view:{pm_id}"))
return builder.as_markup()
def get_autorenew_cancel_keyboard(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="autorenew_disable_button"), callback_data="autorenew:cancel")
)
builder.row(
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"), callback_data="main_action:my_subscription")
)
return builder.as_markup()
def get_autorenew_confirm_keyboard(enable: bool, sub_id: int, lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(text=_(key="yes_button"), callback_data=f"autorenew:confirm:{sub_id}:{1 if enable else 0}"),
InlineKeyboardButton(text=_(key="no_button"), callback_data="main_action:my_subscription"),
)
return builder.as_markup()
+158 -222
View File
@@ -1,16 +1,15 @@
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
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
from aiohttp import web
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from bot.services.panel_webhook_service import PanelWebhookService, panel_webhook_route
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
@@ -18,65 +17,32 @@ 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.middlewares.profile_sync import ProfileSyncMiddleware
from bot.app.controllers.dispatcher_controller import build_dispatcher
from bot.app.factories.build_services import build_core_services
from bot.app.web.web_server import build_and_start_web_app
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.notification_service import schedule_subscription_notifications
from bot.services.payment_service import YooKassaService
from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
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.")
@@ -89,30 +55,13 @@ async def on_startup_configured(dispatcher: Dispatcher):
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
logging.info("STARTUP: on_startup_configured executing...")
scheduler = AsyncIOScheduler(timezone="UTC")
try:
await schedule_subscription_notifications(bot, settings, i18n_instance,
scheduler, panel_service,
async_session_factory)
scheduler.start()
dispatcher["scheduler"] = scheduler
logging.info("STARTUP: APScheduler started.")
except Exception as e:
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
exc_info=True)
telegram_webhook_url_to_set = getattr(settings,
'TELEGRAM_WEBHOOK_BASE_URL', None)
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
if telegram_webhook_url_to_set:
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
logging.error(
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
"This is a major security vulnerability. Webhook will NOT be set."
)
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
else:
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
full_telegram_webhook_url = (
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
)
logging.info(
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
@@ -128,7 +77,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
set_success = await bot.set_webhook(
url=full_telegram_webhook_url,
drop_pending_updates=True,
allowed_updates=dispatcher.resolve_used_update_types())
allowed_updates=dispatcher.resolve_used_update_types(),
)
if set_success:
logging.info(
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
@@ -150,16 +100,75 @@ async def on_startup_configured(dispatcher: Dispatcher):
except Exception as e_setwebhook:
logging.error(
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
exc_info=True)
exc_info=True,
)
else:
logging.error(
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
)
else:
logging.info(
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)."
logging.error(
"STARTUP: WEBHOOK_BASE_URL not set in environment. Webhook mode is required. Exiting."
)
await bot.delete_webhook(drop_pending_updates=True)
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
if settings.SUBSCRIPTION_MINI_APP_URL:
try:
menu_text = i18n_instance.gettext(
settings.DEFAULT_LANGUAGE,
"menu_my_subscription_inline",
)
await bot.set_chat_menu_button(
menu_button=MenuButtonWebApp(
text=menu_text,
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
logging.info(
"STARTUP: Mini app domain registered and default menu button restored."
)
except Exception as e:
logging.error(
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
)
if settings.START_COMMAND_DESCRIPTION:
try:
await bot.set_my_commands([
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
])
logging.info("STARTUP: /start command description set.")
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.")
@@ -167,19 +176,39 @@ async def on_startup_configured(dispatcher: Dispatcher):
async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
if scheduler and scheduler.running:
try:
scheduler.shutdown(wait=False)
logging.info("SHUTDOWN: APScheduler shut down.")
except Exception as e:
logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
exc_info=True)
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",
"freekassa_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:
@@ -190,6 +219,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
from db.database_setup import async_engine as global_async_engine
if global_async_engine:
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
await global_async_engine.dispose()
@@ -199,19 +229,16 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
async def run_bot(settings_param: Settings):
storage = MemoryStorage()
default_props = DefaultBotProperties(parse_mode=ParseMode.HTML)
bot = Bot(token=settings_param.BOT_TOKEN, default=default_props)
local_async_session_factory = init_db_connection(settings_param)
if local_async_session_factory is None:
logging.critical(
"Failed to initialize database connection and session factory. Exiting."
)
return
dp, bot, extra = build_dispatcher(settings_param, local_async_session_factory)
i18n_instance = extra["i18n_instance"]
dp = Dispatcher(storage=storage, settings=settings_param, bot_instance=bot)
# Get bot username for YooKassa default return URL if needed
actual_bot_username = "your_bot_username"
try:
bot_info = await bot.get_me()
@@ -222,152 +249,60 @@ async def run_bot(settings_param: Settings):
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
)
i18n_instance = get_i18n_instance(path="locales",
default=settings_param.DEFAULT_LANGUAGE)
yookassa_service = YooKassaService(
shop_id=settings_param.YOOKASSA_SHOP_ID,
secret_key=settings_param.YOOKASSA_SECRET_KEY,
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
bot_username_for_default_return=actual_bot_username,
settings_obj=settings_param)
panel_service = PanelApiService(settings_param)
subscription_service = SubscriptionService(settings_param, panel_service)
referral_service = ReferralService(settings_param, subscription_service,
bot, i18n_instance)
promo_code_service = PromoCodeService(settings_param, subscription_service,
bot, i18n_instance)
dp["i18n_instance"] = i18n_instance
dp["yookassa_service"] = yookassa_service
dp["panel_service"] = panel_service
dp["subscription_service"] = subscription_service
dp["referral_service"] = referral_service
dp["promo_code_service"] = promo_code_service
services = build_core_services(
settings_param,
bot,
local_async_session_factory,
i18n_instance,
actual_bot_username,
)
for key, service in services.items():
dp[key] = service
dp["panel_service"] = services["panel_service"]
dp["async_session_factory"] = local_async_session_factory
dp.update.outer_middleware(
DBSessionMiddleware(local_async_session_factory))
dp.update.outer_middleware(
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
dp.update.outer_middleware(
BanCheckMiddleware(settings=settings_param,
i18n_instance=i18n_instance))
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
dp.startup.register(on_startup_configured)
dp.shutdown.register(lambda: on_shutdown_configured(dp))
# Wrap startup/shutdown handlers to satisfy aiogram event signature (no args passed)
async def _on_startup_wrapper():
await on_startup_configured(dp)
async def _on_shutdown_wrapper():
await on_shutdown_configured(dp)
dp.startup.register(_on_startup_wrapper)
dp.shutdown.register(_on_shutdown_wrapper)
await register_all_routers(dp, settings_param)
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
None)
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
None)
tg_webhook_base = settings_param.WEBHOOK_BASE_URL
should_run_aiohttp_server = bool(tg_webhook_base) or (
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path))
telegram_uses_webhook_mode = bool(tg_webhook_base)
run_telegram_polling = not telegram_uses_webhook_mode
# Webhook mode is now required - exit if not configured
if not tg_webhook_base:
logging.error("WEBHOOK_BASE_URL is required. Polling mode is disabled. Exiting.")
await dp.emit_shutdown()
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
logging.info(f"--- Bot Run Mode Decision ---")
logging.info(
f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
)
logging.info(
f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'"
)
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
logging.info(f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Webhook Mode: ENABLED")
logging.info(f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'")
logging.info(f"Decision: Run AIOHTTP server: ENABLED (required for webhooks)")
logging.info(f"--- End Bot Run Mode Decision ---")
web_app_runner = None
main_tasks = []
if should_run_aiohttp_server:
app = web.Application()
app['bot'] = bot
app['dp'] = dp
app['settings'] = settings_param
app['i18n'] = i18n_instance
app['async_session_factory'] = local_async_session_factory
# Only run AIOHTTP server for webhook mode
async def web_server_task():
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
app['yookassa_service'] = yookassa_service
app['subscription_service'] = subscription_service
app['referral_service'] = referral_service
app['panel_service'] = panel_service
main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
setup_application(app, dp, bot=bot)
# Recurring billing moved to panel webhook (24h before expiry). No periodic task needed here.
if telegram_uses_webhook_mode:
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
if not telegram_webhook_path.startswith('/'):
telegram_webhook_path = '/' + telegram_webhook_path
app.router.add_post(telegram_webhook_path,
SimpleRequestHandler(dispatcher=dp, bot=bot))
logging.info(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
)
if yk_webhook_base and settings_param.yookassa_webhook_path:
yk_path = settings_param.yookassa_webhook_path
if not yk_path or not isinstance(yk_path, str):
logging.error(
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
)
elif not yk_path.startswith('/'):
logging.error(
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
)
else:
app.router.add_post(
yk_path,
user_payment_webhook_module.yookassa_webhook_route)
logging.info(
f"YooKassa webhook route configured at: [POST] {yk_path}")
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
site = web.TCPSite(web_app_runner,
host=settings_param.WEB_SERVER_HOST,
port=settings_param.WEB_SERVER_PORT)
async def web_server_task():
await site.start()
logging.info(
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
)
await asyncio.Event().wait(
) if not run_telegram_polling else await asyncio.sleep(31536000)
main_tasks.append(
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
if run_telegram_polling:
logging.info("Starting bot in Telegram Polling mode...")
main_tasks.append(
asyncio.create_task(dp.start_polling(
bot, allowed_updates=dp.resolve_used_update_types()),
name="TelegramPollingTask"))
if not main_tasks:
logging.error(
"Bot is not configured for any run mode (neither Webhook nor Polling). Exiting."
)
await dp.emit_shutdown()
return
logging.info(
f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}"
)
logging.info("Starting bot in Webhook mode with AIOHTTP server...")
logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}")
try:
await asyncio.gather(*main_tasks)
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
logging.info(
f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
logging.info(f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
finally:
logging.info("Initiating final bot shutdown sequence...")
for task in main_tasks:
@@ -382,7 +317,8 @@ async def run_bot(settings_param: Settings):
except Exception as e_task_cancel:
logging.error(
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
exc_info=True)
exc_info=True,
)
if web_app_runner:
await web_app_runner.cleanup()
+138
View File
@@ -0,0 +1,138 @@
import logging
from typing import Any, Awaitable, Callable, Dict, Optional
from aiogram import BaseMiddleware
from aiogram.types import (
CallbackQuery,
Message,
Update,
)
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import user_dal
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
class ChannelSubscriptionMiddleware(BaseMiddleware):
"""
Blocks access to handlers for users who have not yet passed the required channel subscription check.
The /start command is allowed through so that the handler can re-run the verification.
"""
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
super().__init__()
self.settings = settings
self.i18n_main_instance = i18n_instance
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
if not required_channel_id:
return await handler(event, data)
event_user = data.get("event_from_user")
if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message
if (
message_object
and message_object.text
and message_object.text.startswith("/start")
):
return await handler(event, data)
session: AsyncSession = data["session"]
try:
db_user = await user_dal.get_user_by_id(session, event_user.id)
except Exception as db_error:
logging.error(
"ChannelSubscriptionMiddleware: failed to fetch user %s: %s",
event_user.id,
db_error,
exc_info=True,
)
return await handler(event, data)
if not db_user:
return await handler(event, data)
if (
db_user.channel_subscription_verified
and db_user.channel_subscription_verified_for == required_channel_id
):
return await handler(event, data)
i18n_payload: Dict[str, Any] = data.get("i18n_data", {})
current_lang: str = i18n_payload.get(
"current_language", self.settings.DEFAULT_LANGUAGE
)
i18n_instance: Optional[JsonI18n] = i18n_payload.get(
"i18n_instance", self.i18n_main_instance
)
def translate(key: str) -> str:
if i18n_instance:
return i18n_instance.gettext(current_lang, key)
return key
keyboard = (
get_channel_subscription_keyboard(
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
)
if i18n_instance
else None
)
prompt_text = translate("channel_subscription_required")
if event.callback_query:
await self._handle_callback(event.callback_query, prompt_text, keyboard, data)
return
if message_object:
await message_object.answer(prompt_text, reply_markup=keyboard)
else:
bot_instance = data["bot"]
await bot_instance.send_message(
chat_id=event_user.id,
text=prompt_text,
reply_markup=keyboard,
)
return
async def _handle_callback(
self,
callback: CallbackQuery,
prompt_text: str,
keyboard,
data: Dict[str, Any],
) -> None:
try:
await callback.answer(prompt_text, show_alert=True)
except Exception:
pass
if callback.message:
try:
await callback.message.answer(prompt_text, reply_markup=keyboard)
except Exception as send_error:
logging.error(
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s",
callback.message.chat.id,
send_error,
exc_info=True,
)
else:
bot_instance = data["bot"]
await bot_instance.send_message(
chat_id=callback.from_user.id,
text=prompt_text,
reply_markup=keyboard,
)
+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
+18 -1
View File
@@ -45,10 +45,27 @@ class JsonI18n:
exc_info=True)
def gettext(self, lang_code: Optional[str], key: str, **kwargs) -> str:
effective_lang_code = lang_code if lang_code and lang_code in self.locales_data else self.default_lang
# Determine effective language with robust fallback
if lang_code and lang_code in self.locales_data:
effective_lang_code = lang_code
elif self.default_lang in self.locales_data:
effective_lang_code = self.default_lang
elif 'en' in self.locales_data:
effective_lang_code = 'en'
else:
effective_lang_code = lang_code or self.default_lang
lang_data = self.locales_data.get(effective_lang_code)
if lang_data is None:
# Try explicit fallback to English if available
fallback_data = self.locales_data.get('en')
if fallback_data is not None:
text = fallback_data.get(key)
if text is not None:
try:
return text.format(**kwargs) if kwargs else text
except Exception:
return text
logging.warning(
f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is."
)
+69
View File
@@ -0,0 +1,69 @@
import logging
from typing import Callable, Dict, Any, Awaitable, Optional
from aiogram import BaseMiddleware
from aiogram.types import Update, User as TgUser
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import user_dal
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
class ProfileSyncMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
event: Update,
data: Dict[str, Any],
) -> Any:
session: AsyncSession = data.get("session")
tg_user: Optional[TgUser] = data.get("event_from_user")
if session and tg_user:
try:
db_user = await user_dal.get_user_by_id(session, tg_user.id)
if db_user:
update_payload: Dict[str, Any] = {}
sanitized_username = sanitize_username(tg_user.username)
sanitized_first_name = sanitize_display_name(tg_user.first_name)
sanitized_last_name = sanitize_display_name(tg_user.last_name)
if db_user.username != sanitized_username:
update_payload["username"] = sanitized_username
if db_user.first_name != sanitized_first_name:
update_payload["first_name"] = sanitized_first_name
if db_user.last_name != sanitized_last_name:
update_payload["last_name"] = sanitized_last_name
if update_payload:
await user_dal.update_user(session, tg_user.id, update_payload)
logging.info(
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}"
)
# Also update description on panel if linked
try:
panel_service = data.get("panel_service")
if panel_service and db_user.panel_user_uuid:
description_text = "\n".join([
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
sanitized_first_name or "",
sanitized_last_name or "",
]).strip()
await panel_service.update_user_details_on_panel(
db_user.panel_user_uuid,
{"description": description_text},
)
except Exception as e_upd_desc:
logging.warning(
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}"
)
except Exception as e:
logging.error(
f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}",
exc_info=True,
)
return await handler(event, data)
+30
View File
@@ -0,0 +1,30 @@
from aiogram import Router, F
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")
# Allow all updates only in private chats (messages, callback queries, etc.)
root.message.filter(F.chat.type == "private")
root.callback_query.filter(F.message.chat.type == "private")
# 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
+249
View File
@@ -0,0 +1,249 @@
import logging
import json
from typing import Optional
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from config.settings import Settings
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 NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
class CryptoPayService:
def __init__(
self,
token: Optional[str],
network: str,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net)
self.client.register_pay_handler(self._invoice_paid_handler)
self.configured = True
else:
logging.warning("CryptoPay token not provided. CryptoPay disabled")
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,
user_id: int,
months: int,
amount: float,
description: str,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
# Create pending payment in DB and commit to persist
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": months,
"provider": "cryptopay",
},
)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
return None
payload = json.dumps({
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
})
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None,
asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None,
description=description,
payload=payload,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
await session.commit()
except Exception as e_db_update:
await session.rollback()
logging.error(
f"Failed to update cryptopay payment record {payment_record.payment_id}: {e_db_update}",
exc_info=True,
)
return None
return invoice.bot_invoice_url
except Exception as e:
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
return None
async def _invoice_paid_handler(self, update: Update, app: web.Application):
invoice = update.payload
if not invoice.payload:
logging.warning("CryptoPay webhook without payload")
return
try:
meta = json.loads(invoice.payload)
user_id = int(meta["user_id"])
months = int(meta["subscription_months"])
payment_db_id = int(meta["payment_db_id"])
except Exception as e:
logging.error(f"Failed to parse CryptoPay payload: {e}")
return
async_session_factory: sessionmaker = app["async_session_factory"]
bot: Bot = app["bot"]
settings: Settings = app["settings"]
i18n: JsonI18n = app["i18n"]
subscription_service: SubscriptionService = app["subscription_service"]
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
str(invoice.invoice_id),
"succeeded",
)
activation = await subscription_service.activate_subscription(
session,
user_id,
months,
float(invoice.amount),
payment_db_id,
provider="cryptopay",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
await session.rollback()
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
return
db_user = await user_dal.get_user_by_id(session, user_id)
# Use DB language for user-facing messages
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)
config_link = activation.get("subscription_url") or _("config_link_not_available")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _("payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link)
else:
text = _("payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link)
markup = get_connect_and_main_keyboard(
lang, i18n, settings, config_link, preserve_message=True
)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"Failed to send CryptoPay success message: {e}")
# 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:
return web.Response(status=503, text="cryptopay_disabled")
return await self.client.get_updates(request)
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
service: CryptoPayService = request.app["cryptopay_service"]
return await service.webhook_route(request)
+406
View File
@@ -0,0 +1,406 @@
import asyncio
from datetime import datetime
import hashlib
import hmac
import json
import logging
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Dict, Any, Tuple
from aiohttp import ClientSession, ClientTimeout, web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
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 NotificationService
from db.dal import payment_dal, user_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
class FreeKassaService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15)
self._session: Optional[ClientSession] = None
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
if not self.configured:
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
@staticmethod
def _format_amount(amount: float) -> str:
"""Format amount for payloads and signature with two decimal places."""
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{quantized:.2f}"
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
method_code: int,
email: Optional[str] = None,
ip_address: Optional[str] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
amount_str = self._format_amount(amount)
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(method_code),
"amount": amount_str,
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
url = f"{self.api_base_url}/orders/create"
try:
async with session.post(url, json=payload) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
if response.status != 200 or response_data.get("type") != "success":
logging.error(
"FreeKassa create_order: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
return False, {"message": str(exc)}
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
if candidate <= self._last_nonce:
candidate = self._last_nonce + 1
self._last_nonce = candidate
return candidate
def _sign_payload(self, payload: Dict[str, Any]) -> str:
if not self.api_key:
raise RuntimeError("FreeKassa API key is not configured.")
items = [
(key, value)
for key, value in payload.items()
if key != "signature" and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _validate_signature(
self,
merchant_order_id: str,
amount: str,
provided_signature: str,
payload: Optional[Dict[str, Any]] = None,
) -> bool:
if not provided_signature:
return False
if self.shop_id and self.second_secret:
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
if expected_signature.lower() == provided_signature.lower():
return True
if self.api_key and payload:
items = [
(key, value)
for key, value in payload.items()
if key not in {"signature", "SIGN"} and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
if alt_signature.lower() == provided_signature.lower():
return True
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
data = await request.post()
except Exception as e:
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any]
if data:
payload_dict = {str(k): v for k, v in data.items()}
else:
try:
json_payload = await request.json()
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
data = json_payload
except Exception:
payload_dict = {}
data = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
if isinstance(data, dict):
return data.get(key) or data.get(key.lower()) or default
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
return web.Response(status=403, text="merchant_mismatch")
signature = _get("SIGN") or _get("signature")
if not signature:
logging.error("FreeKassa webhook: missing signature")
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
logging.error("FreeKassa webhook: missing order_id or amount")
return web.Response(status=400, text="missing_data")
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
logging.error("FreeKassa webhook: invalid signature")
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES")
# Optional amount verification
try:
amount_decimal = Decimal(amount_str)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
logging.warning(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
except Exception as e:
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
activation = None
referral_bonus = None
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
new_status="succeeded",
)
months = payment.subscription_duration_months or 1
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
months,
float(payment.amount),
payment.payment_id,
provider="freekassa",
)
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
months,
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception as e:
await session.rollback()
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
config_link = None
final_end = None
months = payment.subscription_duration_months or 1
if activation:
config_link = activation.get("subscription_url")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if not final_end and activation and activation.get("end_date"):
final_end = activation["end_date"]
if not config_link:
config_link = _("config_link_not_available")
if final_end:
end_date_str = final_end.strftime("%Y-%m-%d")
else:
end_date_str = _("config_link_not_available")
if applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
text = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
bonus_days=applied_days,
final_end_date=end_date_str,
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
text = _(
"payment_successful_full",
months=months,
end_date=end_date_str,
config_link=config_link,
)
if provider_payment_id:
order_info_text = _(
"free_kassa_order_full",
order_id=provider_payment_id,
date=datetime.now().strftime("%Y-%m-%d"),
)
text = f"{order_info_text}\n{text}"
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
months=months,
payment_provider="freekassa",
username=db_user.username if db_user else None,
)
except Exception as e:
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
+306 -120
View File
@@ -1,135 +1,321 @@
import logging
import asyncio
from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler
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.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.utils.message_queue import get_queue_manager
from bot.utils.text_sanitizer import (
display_name_or_fallback,
username_for_display,
)
async def send_expiration_warnings(bot: Bot, settings: Settings,
i18n: JsonI18n,
panel_service: PanelApiService,
async_session_factory: sessionmaker):
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
logging.info(
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
)
if async_session_factory is None:
logging.error(
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
)
return
async with async_session_factory() as session:
@staticmethod
def _format_user_display(
user_id: int,
username: Optional[str] = None,
first_name: Optional[str] = None,
) -> str:
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
if username:
base_display = f"{base_display} ({username_for_display(username)})"
return base_display
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:
sub_service = SubscriptionService(settings, panel_service)
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
if not expiring_subs_details_list:
logging.info(
"No subscriptions found ending soon for notification.")
return
logging.info(
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
)
for sub_details in expiring_subs_details_list:
user_id = sub_details['user_id']
user_lang = sub_details.get('language_code',
settings.DEFAULT_LANGUAGE)
first_name = sub_details.get('first_name', f"User {user_id}")
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
days_left_display = sub_details.get('days_left', "N/A")
subscription_actual_end_date_obj: Optional[
datetime] = sub_details.get(
'subscription_end_date_iso_for_update')
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
)
message_text = _("subscription_ending_soon_notification",
user_name=first_name,
end_date=end_date_str_for_msg,
days_left=days_left_display)
try:
await bot.send_message(user_id, message_text)
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
)
if subscription_actual_end_date_obj:
await sub_service.update_last_notification_sent(
session, user_id, subscription_actual_end_date_obj)
else:
logging.warning(
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
)
except Exception as e:
logging.error(
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
exc_info=True)
await asyncio.sleep(0.1)
await session.commit()
logging.info(
"Finished processing expiration warnings. Session committed.")
except Exception as e_session:
logging.error(
f"Error during send_expiration_warnings session: {e_session}",
exc_info=True)
await session.rollback()
logging.info(
"Session rolled back due to error in send_expiration_warnings."
)
async def schedule_subscription_notifications(
bot: Bot, settings: Settings, i18n: JsonI18n,
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
async_session_factory: sessionmaker):
async def job_wrapper():
try:
await send_expiration_warnings(bot, settings, i18n, panel_service,
async_session_factory)
# 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"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
exc_info=True)
try:
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
notification_minute = int(
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
except (ValueError, TypeError):
logging.warning(
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
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 = self._format_user_display(
user_id=user_id,
username=username,
first_name=first_name,
)
notification_hour = 9
notification_minute = 0
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 = self._format_user_display(
user_id=user_id,
username=username,
)
provider_emoji = {
"yookassa": "💳",
"freekassa": "💳",
"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 = self._format_user_display(
user_id=user_id,
username=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 = self._format_user_display(
user_id=user_id,
username=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)
scheduler.add_job(job_wrapper,
'cron',
hour=notification_hour,
minute=notification_minute,
name="daily_subscription_expiration_warnings_v2",
misfire_grace_time=60 * 15,
replace_existing=True)
logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
)
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_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
user_display = self._format_user_display(
user_id=user_id,
username=username,
first_name=first_name,
)
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"))
# 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
+96 -8
View File
@@ -22,6 +22,14 @@ class PanelApiService:
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:
timeout = aiohttp.ClientTimeout(total=30)
@@ -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 = {
@@ -226,6 +238,28 @@ class PanelApiService:
return None
async def get_user(
self,
*,
uuid: Optional[str] = None,
telegram_id: Optional[int] = None,
username: Optional[str] = None,
email: Optional[str] = None,
log_response: bool = True,
) -> Optional[Dict[str, Any]]:
if uuid:
return await self.get_user_by_uuid(uuid, log_response=log_response)
users = await self.get_users_by_filter(
telegram_id=telegram_id,
username=username,
email=email,
log_response=log_response,
)
if users:
return users[0]
return None
async def get_users_by_filter(
self,
telegram_id: Optional[int] = None,
@@ -303,8 +337,8 @@ 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,
hwid_device_limit: Optional[int] = None,
specific_squad_uuids: Optional[List[str]] = None,
description: Optional[str] = None,
tag: Optional[str] = None,
status: str = "ACTIVE",
@@ -335,11 +369,20 @@ 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
hwid_limit_value = hwid_device_limit
if hwid_limit_value is None:
hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT
if hwid_limit_value is not None:
try:
hwid_limit_int = int(hwid_limit_value)
if hwid_limit_int >= 0:
payload["hwidDeviceLimit"] = hwid_limit_int
except (TypeError, ValueError):
logging.warning(
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
)
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
@@ -425,6 +468,30 @@ class PanelApiService:
return f"{base_sub_url}/{client_type.lower()}"
return base_sub_url
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
endpoint = f"/hwid/devices/{user_uuid}"
response_data = await self._request("GET", endpoint, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return response_data.get("response")
logging.error(
f"Failed to get user devices for user {user_uuid}. Response: {response_data}"
)
return None
async def disconnect_device(self, user_uuid: str, hwid: str) -> bool:
endpoint = f"/hwid/devices/delete"
payload = {
"userUuid": user_uuid,
"hwid": hwid
}
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
if response_data and not response_data.get("error") and "response" in response_data:
return True
logging.error(
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}"
)
return False
async def update_bot_db_sync_status(self,
session: AsyncSession,
status: str,
@@ -438,3 +505,24 @@ 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
+305
View File
@@ -0,0 +1,305 @@
import json
import logging
import hmac
import hashlib
from aiohttp import web
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.orm import sessionmaker
from typing import Optional
from config.settings import Settings
from .panel_api_service import PanelApiService
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup, get_autorenew_cancel_keyboard
from db.dal import user_dal
from bot.utils.date_utils import add_months
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
}
class PanelWebhookService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, async_session_factory: sessionmaker, panel_service: PanelApiService):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
async def _send_message(
self,
user_id: int,
lang: str,
message_key: str,
reply_markup: InlineKeyboardMarkup | None = None,
**kwargs,
):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
try:
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
)
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) -> bool:
"""Handle expired subscription - auto-renew tribute users if no cancellation was received.
Returns True if an auto-renewal was performed (and renewal message sent), False otherwise.
"""
from db.dal import subscription_dal, payment_dal
from datetime import datetime, timezone
try:
auto_renewed = False
# 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 (calendar months)
new_end_date = add_months(datetime.now(timezone.utc), last_tribute_duration)
# Update local DB subscription
await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
'end_date': new_end_date,
'status_from_panel': 'ACTIVE',
'is_active': True
}
)
# Update panel expiry to ensure actual service access is extended
try:
panel_payload = {
"uuid": sub.panel_user_uuid,
"expireAt": new_end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
"status": "ACTIVE",
}
panel_update_resp = await self.panel_service.update_user_details_on_panel(
sub.panel_user_uuid,
panel_payload,
log_response=True,
)
if panel_update_resp:
logging.info(
f"Panel expiry updated for user {user_id} (panel_uuid {sub.panel_user_uuid}) to {new_end_date}"
)
except Exception as e_panel:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {sub.panel_user_uuid}): {e_panel}")
# Create a succeeded payment record in DB with the same amount/currency as last tribute payment
try:
last_payment = await payment_dal.get_last_tribute_payment(session, user_id)
if last_payment and last_payment.amount and last_payment.currency:
provider_payment_id = (
f"tribute_auto_{user_id}_{sub.subscription_id}_"
f"{new_end_date.strftime('%Y%m%d')}"
)
created_payment = await payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=float(last_payment.amount),
currency=last_payment.currency,
months=last_tribute_duration,
description="Auto-renewal (panel webhook)",
provider="tribute",
provider_payment_id=provider_payment_id,
)
if created_payment:
logging.info(
f"Auto-renew payment recorded (id={created_payment.payment_id}) for user {user_id} amount={created_payment.amount} {created_payment.currency} months={last_tribute_duration}"
)
else:
logging.warning(
f"Could not create auto-renew payment for user {user_id}: previous tribute payment not found or missing amount/currency")
except Exception as e_pay:
logging.error(
f"Failed to create auto-renew payment record for user {user_id}: {e_pay}",
exc_info=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"
)
auto_renewed = True
except Exception as e:
logging.error(f"Failed to send auto-renewal notification to user {user_id}: {e}")
await session.commit()
return auto_renewed
except Exception as e:
logging.error(f"Error handling expired subscription for user {user_id}: {e}")
await session.rollback()
return False
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
logging.warning("Panel webhook without telegramId received")
return
user_id = int(telegram_id)
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
return
async with self.async_session_factory() as session:
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}"
markup = get_subscribe_only_markup(lang, self.i18n)
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
if days_left == 1:
# Trigger auto-renew via SubscriptionService (wired in at factory)
try:
subscription_service = getattr(self, "subscription_service", None)
if subscription_service:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
try:
ok = await subscription_service.charge_subscription_renewal(session, sub)
# If initiation succeeded, suppress the 24h reminder by returning early
if ok:
await session.commit()
return
else:
await session.rollback()
except Exception:
await session.rollback()
logging.exception("Auto-renew attempt (24h) failed")
except Exception:
logging.exception("Auto-renew trigger (24h) failed pre-check")
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
# For 48h event, if auto-renew is enabled and not tribute, show special notice with cancel button
if days_left == 2:
async with self.async_session_factory() as session:
from db.dal import subscription_dal
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
logging.info(
"48h webhook check: user_id=%s sub_found=%s auto_renew=%s provider=%s",
user_id,
bool(sub),
getattr(sub, 'auto_renew_enabled', None) if sub else None,
getattr(sub, 'provider', None) if sub else None,
)
if sub and sub.auto_renew_enabled and sub.provider != 'tribute':
cancel_kb = get_autorenew_cancel_keyboard(lang, self.i18n)
await self._send_message(
user_id,
lang,
"autorenew_48h_charge_tomorrow_notice",
reply_markup=cancel_kb,
user_name=first_name,
)
return
await self._send_message(
user_id,
lang,
msg_key,
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)
auto_renewed = await self._handle_expired_subscription(session, user_id, user_payload, lang, markup, first_name)
# If auto-renewed via Tribute, suppress expiration notification. Otherwise, send it if enabled.
if not auto_renewed 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_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_yesterday_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if self.settings.PANEL_WEBHOOK_SECRET:
if not signature_header:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode())
except Exception:
return web.Response(status=400, text="bad_request")
event_name = payload.get("name") or payload.get("event")
user_data = payload.get("payload") or payload.get("data", {})
if isinstance(user_data, dict) and "user" in user_data:
user_data = user_data.get("user") or user_data
telegram_id = user_data.get("telegramId") if isinstance(user_data, dict) else None
if not event_name:
return web.Response(status=200, text="ok_no_event")
logging.info(
"Panel webhook event received: %s; telegramId=%s",
event_name,
telegram_id if telegram_id is not None else "N/A",
)
await self.handle_event(event_name, user_data)
return web.Response(status=200, text="ok")
async def panel_webhook_route(request: web.Request):
service: PanelWebhookService = request.app["panel_webhook_service"]
raw = await request.read()
signature_header = request.headers.get("X-Remnawave-Signature")
return await service.handle_webhook(raw, signature_header)
+24 -16
View File
@@ -1,15 +1,17 @@
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
from config.settings import Settings
from db.dal import promo_code_dal, user_dal, subscription_dal
from db.dal import promo_code_dal, user_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n
from .notification_service import NotificationService
class PromoCodeService:
@@ -22,9 +24,13 @@ class PromoCodeService:
self.bot = bot
self.i18n = i18n
async def apply_promo_code(self, session: AsyncSession, user_id: int,
code_input: str,
user_lang: str) -> Tuple[bool, str]:
async def apply_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
@@ -40,11 +46,6 @@ class PromoCodeService:
return False, _("promo_code_already_used_by_user",
code=code_input_upper)
active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id)
if not active_sub:
return False, _("promo_code_no_active_subscription")
bonus_days = promo_data.bonus_days
new_end_date = await self.subscription_service.extend_active_subscription_days(
@@ -54,18 +55,26 @@ 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:
return True, _("promo_code_applied_success",
code=code_input_upper,
bonus_days=bonus_days,
new_end_date=new_end_date.strftime('%Y-%m-%d'))
# 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:
logging.error(
@@ -73,5 +82,4 @@ class PromoCodeService:
)
return False, _("error_applying_promo_bonus")
else:
return False, _("error_applying_promo_bonus")
+78 -4
View File
@@ -1,11 +1,13 @@
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
from config.settings import Settings
from db.dal import user_dal
from db.dal import payment_dal
from db.models import User
from db.dal import subscription_dal
from bot.middlewares.i18n import JsonI18n
@@ -23,8 +25,12 @@ class ReferralService:
self.i18n = i18n
async def apply_referral_bonuses_for_payment(
self, session: AsyncSession, referee_user_id: int,
purchased_subscription_months: int) -> Dict[str, Any]:
self,
session: AsyncSession,
referee_user_id: int,
purchased_subscription_months: int,
current_payment_db_id: Optional[int] = None,
skip_if_active_before_payment: bool = True) -> Dict[str, Any]:
referee_final_end_date: Optional[datetime] = None
referee_bonus_applied_days: Optional[int] = None
@@ -42,6 +48,39 @@ class ReferralService:
"referee_new_end_date": None
}
# If configured to apply referral bonuses only once per invited user,
# check if the referee already has succeeded payments.
# Use getattr with a safe default (True) to avoid AttributeError if
# running with an older settings schema.
if getattr(self.settings, "REFERRAL_ONE_BONUS_PER_REFEREE", True):
try:
succeeded_count = await payment_dal.count_user_succeeded_payments(
session, referee_user_id, exclude_payment_id=current_payment_db_id
)
if succeeded_count and succeeded_count > 0:
logging.info(
f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments.")
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
except Exception as e_cnt:
logging.error(f"Failed counting succeeded payments for user {referee_user_id}: {e_cnt}")
# Additionally, do not award referral bonuses if the user was active at payment time
# (has an active subscription now). This avoids giving bonuses to already active users.
if skip_if_active_before_payment:
try:
if await self.subscription_service.has_active_subscription(session, referee_user_id):
logging.info(
f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription.")
return {
"referee_bonus_applied_days": None,
"referee_new_end_date": None
}
except Exception as e_sub:
logging.error(f"Failed to check active subscription for {referee_user_id}: {e_sub}")
inviter_user_id = referee_user_model.referred_by_id
inviter_user_model = await user_dal.get_user_by_id(
session, inviter_user_id)
@@ -137,8 +176,7 @@ class ReferralService:
"status_from_panel":
"ACTIVE_BONUS",
"traffic_limit_bytes":
self.settings.
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
self.settings.user_traffic_limit_bytes,
}
try:
await subscription_dal.deactivate_other_active_subscriptions(
@@ -220,3 +258,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
}
+178
View File
@@ -0,0 +1,178 @@
import logging
from typing import Optional
from aiogram import Bot, types
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
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 NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
class StarsService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str) -> Optional[int]:
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": months,
"provider": "telegram_stars",
}
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data)
await session.commit()
except Exception as e_db:
await session.rollback()
logging.error(f"Failed to create stars payment record: {e_db}",
exc_info=True)
return None
payload = f"{db_payment_record.payment_id}:{months}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return db_payment_record.payment_id
except Exception as e_inv:
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
exc_info=True)
return None
async def process_successful_payment(self, session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict) -> None:
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded")
await session.commit()
except Exception as e_upd:
await session.rollback()
logging.error(
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
exc_info=True)
return
activation_details = await self.subscription_service.activate_subscription(
session,
message.from_user.id,
months,
float(stars_amount),
payment_db_id,
provider="telegram_stars",
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
return
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
message.from_user.id,
months,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
if not final_end:
final_end = activation_details["end_date"]
# Always use user's language from DB for user-facing messages
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_days:
inviter_name_display = _("friend_placeholder")
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
current_lang, i18n, self.settings, config_link, preserve_message=True
)
try:
await self.bot.send_message(
message.from_user.id,
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_send:
logging.error(
f"Failed to send stars payment success message: {e_send}")
# 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}")
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
import logging
import hmac
import hashlib
import json
from typing import Optional
from aiohttp import web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
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 NotificationService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
def convert_period_to_months(period: Optional[str]) -> int:
"""Map Tribute subscription period strings to months."""
if not period:
return 1
mapping = {
"monthly": 1,
"quarterly": 3,
"3-month": 3,
"3months": 3,
"3-months": 3,
"q": 3,
"halfyearly": 6,
"yearly": 12,
"annual": 12,
"y": 12,
}
return mapping.get(period.lower(), 1)
class TributeService:
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
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.subscription_service = subscription_service
self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
settings = self.settings
bot = self.bot
i18n = self.i18n
async_session_factory = self.async_session_factory
subscription_service = self.subscription_service
referral_service = self.referral_service
def ok(data: Optional[dict] = None) -> web.Response:
payload = {"status": "ok"}
if data:
payload.update(data)
return web.json_response(payload, status=200)
def ignored(reason: str) -> web.Response:
return web.json_response({"status": "ignored", "reason": reason}, status=200)
def bad_request(reason: str) -> web.Response:
return web.json_response({"status": "error", "reason": reason}, status=400)
if settings.TRIBUTE_API_KEY:
if not signature_header:
return web.json_response({"status": "error", "reason": "no_signature"}, status=403)
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.json_response({"status": "error", "reason": "invalid_signature"}, status=403)
try:
payload = json.loads(raw_body.decode())
except Exception:
return bad_request("invalid_json")
logging.info(
"Tribute webhook data: %s",
json.dumps(payload, ensure_ascii=False),
)
# Tribute webhook spec: only two events are sent
# name: new_subscription | cancelled_subscription
event_name = payload.get("name")
data = payload.get("payload", {})
# Mandatory routing fields
user_id = data.get("telegram_user_id")
if not user_id:
# Permanent format issue — acknowledge to avoid retries
return ignored("missing_telegram_user_id")
period_val = data.get("period")
months = convert_period_to_months(period_val)
# 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":
# Use a unique, idempotent provider payment id per webhook event
# Prefer explicit event/payment identifiers if present; otherwise fall back to payload hash suffix
candidate_event_id = (
str(data.get("event_id") or data.get("payment_id") or data.get("purchase_id") or data.get("invoice_id") or "")
)
if candidate_event_id:
provider_payment_id = candidate_event_id
else:
# Combine subscription_id (if any) with a stable hash of the raw payload to ensure uniqueness per event
sub_id_part = str(data.get("subscription_id") or "sub")
payload_hash = hashlib.sha256(raw_body).hexdigest()[:16]
provider_payment_id = f"{sub_id_part}:{payload_hash}"
# 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,
int(user_id),
months,
float(amount_float),
payment_record.payment_id,
provider="tribute",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
int(user_id),
months,
current_payment_db_id=payment_record.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
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)
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
final_end = (referral_bonus.get('referee_new_end_date')
if referral_bonus else None)
if not final_end:
final_end = activation_details.get('end_date')
if final_end:
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_ref_days:
inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_ref_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
lang,
i18n,
settings,
config_link,
preserve_message=True,
)
try:
# Use user's DB language in success messages prepared above
await bot.send_message(
int(user_id),
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}")
# 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()
# Acknowledge to Tribute that webhook was received and processed/accepted
return ok({"event": event_name or "unknown"})
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:
grace_days = 1
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
panel_users_updated: set[str] = set()
for sub in active_subscriptions:
updated_sub = await subscription_dal.update_subscription(
session,
sub.subscription_id,
{
"end_date": grace_end,
"status_from_panel": "CANCELLED",
"skip_notifications": True,
},
)
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
if panel_uuid and panel_uuid not in panel_users_updated:
panel_users_updated.add(panel_uuid)
panel_payload = {
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
}
try:
await self.panel_service.update_user_details_on_panel(
panel_uuid,
panel_payload,
log_response=False,
)
except Exception as panel_err:
logging.error(
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
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."""
tribute_service: TributeService = request.app['tribute_service']
raw_body = await request.read()
signature_header = request.headers.get('trbt-signature')
return await tribute_service.handle_webhook(raw_body, signature_header)
@@ -61,7 +61,11 @@ class YooKassaService:
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
receipt_phone: Optional[str] = None,
save_payment_method: bool = False,
payment_method_id: Optional[str] = None,
capture: bool = True,
bind_only: bool = False) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
@@ -102,13 +106,23 @@ class YooKassaService:
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
# For binding cards only, do not capture and set minimal amount
if bind_only:
capture = False
amount = max(amount, 1.00)
builder.set_capture(capture)
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
if save_payment_method:
# Ask YooKassa to save method for off-session charges
builder.set_save_payment_method(True)
if payment_method_id:
# Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id)
receipt_items_list: List[Dict[str, Any]] = [{
"description":
@@ -122,9 +136,9 @@ class YooKassaService:
"vat_code":
str(self.settings.YOOKASSA_VAT_CODE),
"payment_mode":
self.settings.YOOKASSA_PAYMENT_MODE,
getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE),
"payment_subject":
self.settings.YOOKASSA_PAYMENT_SUBJECT
getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT)
}]
receipt_data_dict: Dict[str, Any] = {
@@ -142,7 +156,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))
@@ -178,7 +192,8 @@ class YooKassaService:
"description_from_yk":
response.description,
"test_mode":
response.test if hasattr(response, 'test') else None
response.test if hasattr(response, 'test') else None,
"payment_method": getattr(response, 'payment_method', None),
}
except Exception as e:
logging.error(f"YooKassa payment creation failed: {e}",
@@ -196,7 +211,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))
@@ -204,37 +219,40 @@ class YooKassaService:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}"
)
pm = getattr(payment_info_yk, 'payment_method', None)
pm_payload: Dict[str, Any] = {}
if pm:
# Collect common fields, including id and hints for last4
pm_id = getattr(pm, 'id', None)
pm_type = getattr(pm, 'type', None)
pm_title = getattr(pm, 'title', None)
account_number = getattr(pm, 'account_number', None) or getattr(pm, 'account', None)
card_obj = getattr(pm, 'card', None)
last4_val = None
if card_obj and hasattr(card_obj, 'last4'):
last4_val = getattr(card_obj, 'last4')
elif isinstance(account_number, str) and len(account_number) >= 4:
last4_val = account_number[-4:]
pm_payload = {
"id": pm_id,
"type": pm_type,
"title": pm_title,
"card_last4": last4_val,
}
return {
"id":
payment_info_yk.id,
"status":
payment_info_yk.status,
"paid":
payment_info_yk.paid,
"amount_value":
float(payment_info_yk.amount.value),
"amount_currency":
payment_info_yk.amount.currency,
"metadata":
payment_info_yk.metadata,
"description":
payment_info_yk.description,
"refundable":
payment_info_yk.refundable,
"created_at":
payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(
payment_info_yk.created_at),
"captured_at":
payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(
payment_info_yk.captured_at, 'isoformat') else None,
"payment_method_type":
payment_info_yk.payment_method.type
if payment_info_yk.payment_method else None,
"test_mode":
payment_info_yk.test
if hasattr(payment_info_yk, 'test') else None
"id": payment_info_yk.id,
"status": payment_info_yk.status,
"paid": payment_info_yk.paid,
"amount_value": float(payment_info_yk.amount.value),
"amount_currency": payment_info_yk.amount.currency,
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat() if getattr(payment_info_yk, 'captured_at', None) and hasattr(payment_info_yk.captured_at, 'isoformat') else None,
"payment_method": pm_payload,
"test_mode": getattr(payment_info_yk, 'test', None),
}
else:
logging.warning(
@@ -246,3 +264,16 @@ class YooKassaService:
f"YooKassa get payment info for {payment_id_in_yookassa} failed: {e}",
exc_info=True)
return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
if not self.configured:
logging.error("YooKassa is not configured. Cannot cancel payment.")
return False
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, lambda: YooKassaPayment.cancel(payment_id_in_yookassa))
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True
except Exception as e:
logging.error(f"Failed to cancel YooKassa payment {payment_id_in_yookassa}: {e}")
return False
+23
View File
@@ -6,7 +6,30 @@ 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()
# Ads campaigns
waiting_for_ad_source = State()
waiting_for_ad_start_param = State()
waiting_for_ad_cost = State()
+263
View File
@@ -0,0 +1,263 @@
# Bot utilities package
from dataclasses import dataclass
from typing import Optional, Dict, Any
from aiogram import types
@dataclass
class MessageContent:
"""Класс для хранения информации о контенте сообщения"""
content_type: str
file_id: Optional[str] = None
text: Optional[str] = None
# Словари поддерживаемых параметров для каждого типа сообщения
SUPPORTED_PARAMS = {
"text": {"parse_mode", "entities", "disable_web_page_preview", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
"photo": {"caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
"video": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "supports_streaming", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
"animation": {"duration", "width", "height", "thumbnail", "caption", "parse_mode", "caption_entities", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id", "has_spoiler"},
"document": {"thumbnail", "caption", "parse_mode", "caption_entities", "disable_content_type_detection", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
"audio": {"caption", "parse_mode", "caption_entities", "duration", "performer", "title", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
"voice": {"caption", "parse_mode", "caption_entities", "duration", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
"sticker": {"disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
"video_note": {"duration", "length", "thumbnail", "disable_notification", "protect_content", "reply_markup", "reply_to_message_id", "allow_sending_without_reply", "message_thread_id"},
}
def filter_kwargs(content_type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""Фильтрует kwargs, оставляя только поддерживаемые параметры для данного типа сообщения"""
supported = SUPPORTED_PARAMS.get(content_type, set())
return {k: v for k, v in kwargs.items() if k in supported}
def get_message_content(message: types.Message) -> MessageContent:
"""
Определяет тип контента сообщения и возвращает его данные.
Использует match/case вместо длинных if-elif цепочек.
"""
text = (message.text or message.caption or "").strip()
# Проверяем наличие медиа-контента
media_content = None
if message.photo:
media_content = ("photo", message.photo[-1].file_id)
elif message.video:
media_content = ("video", message.video.file_id)
elif message.animation:
media_content = ("animation", message.animation.file_id)
elif message.document:
media_content = ("document", message.document.file_id)
elif message.audio:
media_content = ("audio", message.audio.file_id)
elif message.voice:
media_content = ("voice", message.voice.file_id)
elif message.sticker:
media_content = ("sticker", message.sticker.file_id)
elif message.video_note:
media_content = ("video_note", message.video_note.file_id)
# Используем match/case для определения типа контента
match media_content:
case (content_type, file_id):
return MessageContent(content_type=content_type, file_id=file_id, text=text)
case None:
return MessageContent(content_type="text", text=text)
case _:
return MessageContent(content_type="text", text=text)
async def send_message_by_type(bot, chat_id: int, content: MessageContent, **kwargs) -> None:
"""
Отправляет сообщение указанного типа.
Использует match/case вместо длинных if-elif цепочек.
Автоматически фильтрует неподдерживаемые параметры.
"""
# Фильтруем kwargs для данного типа сообщения
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
match content.content_type:
case "text":
await bot.send_message(
chat_id=chat_id,
text=content.text,
**filtered_kwargs
)
case "photo":
await bot.send_photo(
chat_id=chat_id,
photo=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "video":
await bot.send_video(
chat_id=chat_id,
video=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "animation":
await bot.send_animation(
chat_id=chat_id,
animation=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "document":
await bot.send_document(
chat_id=chat_id,
document=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "audio":
await bot.send_audio(
chat_id=chat_id,
audio=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "voice":
await bot.send_voice(
chat_id=chat_id,
voice=content.file_id,
caption=content.text or None,
**filtered_kwargs
)
case "sticker":
await bot.send_sticker(
chat_id=chat_id,
sticker=content.file_id,
**filtered_kwargs
)
case "video_note":
await bot.send_video_note(
chat_id=chat_id,
video_note=content.file_id,
**filtered_kwargs
)
case _:
# Fallback для неизвестных типов - отправляем как текст
text_kwargs = filter_kwargs("text", kwargs)
await bot.send_message(
chat_id=chat_id,
text=content.text or "Unknown content type",
**text_kwargs
)
async def send_message_via_queue(queue_manager, uid: int, content: MessageContent, **kwargs) -> None:
"""
Отправляет сообщение через очередь в зависимости от типа контента.
Использует match/case вместо длинных if-elif цепочек.
Автоматически фильтрует неподдерживаемые параметры.
"""
# Фильтруем kwargs для данного типа сообщения
filtered_kwargs = filter_kwargs(content.content_type, kwargs)
match content.content_type:
case "text":
await queue_manager.send_message(
chat_id=uid, text=content.text, **filtered_kwargs
)
case "photo":
await queue_manager.send_photo(
chat_id=uid, photo=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "video":
await queue_manager.send_video(
chat_id=uid, video=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "animation":
await queue_manager.send_animation(
chat_id=uid, animation=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "document":
await queue_manager.send_document(
chat_id=uid, document=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "audio":
await queue_manager.send_audio(
chat_id=uid, audio=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "voice":
await queue_manager.send_voice(
chat_id=uid, voice=content.file_id, caption=content.text or None, **filtered_kwargs
)
case "sticker":
await queue_manager.send_sticker(
chat_id=uid, sticker=content.file_id, **filtered_kwargs
)
case "video_note":
await queue_manager.send_video_note(
chat_id=uid, video_note=content.file_id, **filtered_kwargs
)
case _:
# Fallback для неизвестных типов - отправляем как текст
text_kwargs = filter_kwargs("text", kwargs)
await queue_manager.send_message(
chat_id=uid, text=content.text or "Unknown content type", **text_kwargs
)
async def send_direct_message(bot, chat_id: int, content: MessageContent, extra_text: str = "", **kwargs) -> None:
"""
Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note.
Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption.
Автоматически фильтрует неподдерживаемые параметры.
"""
match content.content_type:
case "sticker":
# Отправляем стикер с отфильтрованными параметрами
sticker_kwargs = filter_kwargs("sticker", kwargs)
await bot.send_sticker(
chat_id=chat_id,
sticker=content.file_id,
**sticker_kwargs
)
# Если есть текст с подписью, отправляем отдельно
if content.text or extra_text:
text_to_send = (content.text + extra_text) if content.text else extra_text
text_kwargs = filter_kwargs("text", kwargs)
await bot.send_message(
chat_id,
text_to_send,
**text_kwargs
)
case "video_note":
# Отправляем видео-заметку с отфильтрованными параметрами
video_note_kwargs = filter_kwargs("video_note", kwargs)
await bot.send_video_note(
chat_id=chat_id,
video_note=content.file_id,
**video_note_kwargs
)
# Если есть текст с подписью, отправляем отдельно
if content.text or extra_text:
text_to_send = (content.text + extra_text) if content.text else extra_text
text_kwargs = filter_kwargs("text", kwargs)
await bot.send_message(
chat_id,
text_to_send,
**text_kwargs
)
case "text":
# Для текста объединяем с extra_text
final_text = (content.text + extra_text) if content.text else extra_text
text_kwargs = filter_kwargs("text", kwargs)
await bot.send_message(
chat_id=chat_id,
text=final_text,
**text_kwargs
)
case _:
# Для остальных типов медиа используем caption
final_caption = (content.text + extra_text) if content.text else None
await send_message_by_type(
bot, chat_id,
MessageContent(content.content_type, content.file_id, final_caption),
**kwargs
)
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timedelta
def add_months(base_dt: datetime, months_to_add: int) -> datetime:
"""Add calendar months to a datetime, clamping the day to the month's length.
Preserves tzinfo from base_dt.
"""
year = base_dt.year
month = base_dt.month + months_to_add
day = base_dt.day
# Normalize year and month
year += (month - 1) // 12
month = ((month - 1) % 12) + 1
# Determine last day of target month by rolling to next month's first day and subtracting 1 day
if month == 12:
next_month_first = datetime(year + 1, 1, 1, tzinfo=base_dt.tzinfo)
else:
next_month_first = datetime(year, month + 1, 1, tzinfo=base_dt.tzinfo)
last_day = (next_month_first - timedelta(days=1)).day
clamped_day = min(day, last_day)
return base_dt.replace(year=year, month=month, day=clamped_day)
+261
View File
@@ -0,0 +1,261 @@
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
self.total_sent = 0
self.total_failed = 0
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())
self.total_sent += 1
# 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:
self.total_failed += 1
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 send_photo(self, chat_id: int, **kwargs) -> None:
"""Queue a send_photo 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_photo',
kwargs=kwargs
)
await queue.add_message(message)
async def send_video(self, chat_id: int, **kwargs) -> None:
"""Queue a send_video 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_video',
kwargs=kwargs
)
await queue.add_message(message)
async def send_animation(self, chat_id: int, **kwargs) -> None:
"""Queue a send_animation (GIF) 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_animation',
kwargs=kwargs
)
await queue.add_message(message)
async def send_audio(self, chat_id: int, **kwargs) -> None:
"""Queue a send_audio 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_audio',
kwargs=kwargs
)
await queue.add_message(message)
async def send_voice(self, chat_id: int, **kwargs) -> None:
"""Queue a send_voice 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_voice',
kwargs=kwargs
)
await queue.add_message(message)
async def send_sticker(self, chat_id: int, **kwargs) -> None:
"""Queue a send_sticker 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_sticker',
kwargs=kwargs
)
await queue.add_message(message)
async def send_video_note(self, chat_id: int, **kwargs) -> None:
"""Queue a send_video_note 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_video_note',
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),
"group_failed_messages": self.group_queue.total_failed,
"user_failed_messages": self.user_queue.total_failed,
"group_sent_messages": self.group_queue.total_sent,
"user_sent_messages": self.user_queue.total_sent,
}
# 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
+226
View File
@@ -0,0 +1,226 @@
import re
import unicodedata
from typing import Optional
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
_URL_PATTERNS = [
re.compile(r"(?i)https?://\S+"),
re.compile(r"(?i)www\.\S+"),
re.compile(r"(?i)tg://\S+"),
re.compile(r"(?i)telegram\.me\S*"),
re.compile(r"(?i)t\.me/\+\S*"),
re.compile(r"(?i)joinchat\S*"),
]
_OBFUSCATED_DOMAIN_PATTERNS = [
re.compile(
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
)
),
re.compile(
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
r"[{}\s]*(?:[mм]|rn)".format(
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
re.escape(_OBFUSCATION_CHARS),
)
),
re.compile(r"(?i)t\.me\S*"),
]
_ENGLISH_SERVICE_PATTERNS = [
re.compile(r"(?i)telegram"),
re.compile(r"(?i)teleqram"),
re.compile(r"(?i)teiegram"),
re.compile(r"(?i)teieqram"),
re.compile(r"(?i)telegrarn"),
re.compile(r"(?i)service"),
re.compile(r"(?i)notif(?:ication)?"),
re.compile(r"(?i)system"),
re.compile(r"(?i)security"),
re.compile(r"(?i)safety"),
re.compile(r"(?i)support"),
re.compile(r"(?i)moderation"),
re.compile(r"(?i)review"),
re.compile(r"(?i)compliance"),
re.compile(r"(?i)abuse"),
re.compile(r"(?i)spam"),
re.compile(r"(?i)report"),
]
_RUSSIAN_SERVICE_PATTERNS = [
re.compile(r"(?i)телеграм\w*"),
re.compile(r"(?i)служебн\w*"),
re.compile(r"(?i)уведомлен\w*"),
re.compile(r"(?i)поддержк\w*"),
re.compile(r"(?i)безопасн\w*"),
re.compile(r"(?i)модерац\w*"),
re.compile(r"(?i)жалоб\w*"),
re.compile(r"(?i)абуз\w*"),
]
_PRE_LOWER_TRANSLATION = str.maketrans(
{
"I": "l",
"İ": "l",
"Q": "g",
"": " ",
}
)
_POST_LOWER_TRANSLATION = str.maketrans(
{
"а": "a",
"б": "b",
"в": "v",
"г": "g",
"д": "d",
"е": "e",
"ё": "e",
"ж": "zh",
"з": "z",
"и": "i",
"і": "i",
"й": "i",
"к": "k",
"л": "l",
"м": "m",
"н": "n",
"о": "o",
"п": "p",
"р": "r",
"с": "s",
"т": "t",
"у": "u",
"ф": "f",
"х": "h",
"ц": "c",
"ч": "ch",
"ш": "sh",
"щ": "sh",
"ъ": "",
"ы": "y",
"ь": "",
"э": "e",
"ю": "yu",
"я": "ya",
"_": "_",
}
)
_NORMALIZED_BANNED_TOKENS = {
"tme",
"telegram",
"teleqram",
"teiegram",
"teieqram",
"telegrarn",
"joinchat",
"http",
"https",
"www",
"tg",
"service",
"notification",
"system",
"security",
"safety",
"support",
"moderation",
"review",
"compliance",
"abuse",
"spam",
"report",
}
_USERNAME_PLACEHOLDER = "клиент"
def _normalize_for_detection(value: str) -> str:
if not value:
return ""
normalized = unicodedata.normalize("NFKD", value)
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
normalized = normalized.lower()
normalized = "".join(
ch for ch in normalized if unicodedata.category(ch) != "Mn"
)
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
normalized = normalized.replace("rn", "m")
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
normalized = re.sub(pattern, "", normalized)
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
return normalized
def _remove_patterns(value: str) -> str:
updated = value
for pattern in (
_URL_PATTERNS
+ _OBFUSCATED_DOMAIN_PATTERNS
+ _ENGLISH_SERVICE_PATTERNS
+ _RUSSIAN_SERVICE_PATTERNS
):
updated = pattern.sub(" ", updated)
return updated
def _finalize(value: str) -> Optional[str]:
compacted = re.sub(r"\s+", " ", value)
compacted = compacted.strip(" \t\r\n-_.,/\\")
compacted = compacted.strip()
if not compacted:
return None
normalized = _normalize_for_detection(compacted)
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
return None
return compacted
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
if value is None:
return None
clean = value.replace("@", " ")
clean = _remove_patterns(clean)
return _finalize(clean)
def sanitize_username(value: Optional[str]) -> Optional[str]:
if value is None:
return None
clean = value.strip()
clean = clean.lstrip("@")
clean = _remove_patterns(clean)
return _finalize(clean)
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
sanitized = sanitize_username(username)
if not sanitized:
return _USERNAME_PLACEHOLDER
return f"@{sanitized}" if with_at else sanitized
def display_name_or_fallback(
first_name: Optional[str],
fallback: Optional[str] = None,
) -> str:
sanitized = sanitize_display_name(first_name)
if sanitized:
return sanitized
if fallback is not None:
return fallback
return _USERNAME_PLACEHOLDER
+252 -29
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
@@ -23,27 +23,74 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None,
description="Telegram channel ID the user must join to access the bot")
REQUIRED_CHANNEL_LINK: Optional[str] = Field(
default=None,
description="Public username or invite link to the required channel for join button")
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
YOOKASSA_VAT_CODE: int = Field(default=1)
# Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
WEBHOOK_BASE_URL: Optional[str] = None
PRICE_1_MONTH: Optional[int] = Field(default=None)
PRICE_3_MONTHS: Optional[int] = Field(default=None)
PRICE_6_MONTHS: Optional[int] = Field(default=None)
PRICE_12_MONTHS: Optional[int] = Field(default=None)
CRYPTOPAY_TOKEN: Optional[str] = None
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
CRYPTOPAY_ASSET: str = Field(default="RUB")
CRYPTOPAY_ENABLED: bool = Field(default=True)
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0)
FREEKASSA_ENABLED: bool = Field(default=False)
FREEKASSA_MERCHANT_ID: Optional[str] = None
FREEKASSA_FIRST_SECRET: Optional[str] = None
FREEKASSA_SECOND_SECRET: Optional[str] = None
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
TRIBUTE_ENABLED: bool = Field(default=True)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
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)
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
@@ -63,15 +110,20 @@ class Settings(BaseSettings):
REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS: Optional[int] = Field(
default=15, alias="REFEREE_BONUS_DAYS_12_MONTHS")
# Referral program configuration
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
default=True,
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user on their first successful payment."
)
PANEL_API_URL: Optional[str] = None
PANEL_API_KEY: Optional[str] = None
PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1)
PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0)
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
USER_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
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)
@@ -81,6 +133,26 @@ class Settings(BaseSettings):
WEB_SERVER_PORT: int = Field(default=8080)
LOGS_PAGE_SIZE: int = Field(default=10)
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")
MY_DEVICES_SECTION_ENABLED: bool = Field(
default=False,
description="Enable the My Devices section in the subscription menu"
)
USER_HWID_DEVICE_LIMIT: Optional[int] = Field(
default=None,
description="Default hardware device limit for panel users (0 = unlimited)"
)
# 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
def DATABASE_URL(self) -> str:
@@ -118,11 +190,18 @@ class Settings(BaseSettings):
@computed_field
@property
def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]:
if self.PANEL_USER_DEFAULT_INBOUND_UUIDS:
def user_traffic_limit_bytes(self) -> int:
if self.USER_TRAFFIC_LIMIT_GB is None or self.USER_TRAFFIC_LIMIT_GB <= 0:
return 0
return int(self.USER_TRAFFIC_LIMIT_GB * (1024**3))
@computed_field
@property
def parsed_user_squad_uuids(self) -> Optional[List[str]]:
if self.USER_SQUAD_UUIDS:
return [
uuid.strip()
for uuid in self.PANEL_USER_DEFAULT_INBOUND_UUIDS.split(',')
for uuid in self.USER_SQUAD_UUIDS.split(',')
if uuid.strip()
]
return None
@@ -136,26 +215,119 @@ class Settings(BaseSettings):
@computed_field
@property
def yookassa_full_webhook_url(self) -> Optional[str]:
if self.YOOKASSA_WEBHOOK_BASE_URL:
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
return None
@computed_field
@property
def tribute_webhook_path(self) -> str:
return "/webhook/tribute"
@computed_field
@property
def tribute_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
return None
@computed_field
@property
def panel_webhook_path(self) -> str:
return "/webhook/panel"
@computed_field
@property
def panel_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.panel_webhook_path}"
return None
@computed_field
@property
def cryptopay_webhook_path(self) -> str:
return "/webhook/cryptopay"
@computed_field
@property
def cryptopay_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
return None
@computed_field
@property
def freekassa_webhook_path(self) -> str:
return "/webhook/freekassa"
@computed_field
@property
def freekassa_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
return None
# Computed YooKassa receipt fields based on recurring toggle
@computed_field
@property
def yk_receipt_payment_mode(self) -> str:
# If autopayments are enabled, use service; otherwise full prepayment
return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment"
@computed_field
@property
def yk_receipt_payment_subject(self) -> str:
# If autopayments are enabled, use full_payment; otherwise payment
return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment"
@computed_field
@property
def subscription_options(self) -> Dict[int, float]:
options: Dict[int, float] = {}
if self.PRICE_1_MONTH is not None:
options[1] = float(self.PRICE_1_MONTH / 100.0)
if self.PRICE_3_MONTHS is not None:
options[3] = float(self.PRICE_3_MONTHS / 100.0)
if self.PRICE_6_MONTHS is not None:
options[6] = float(self.PRICE_6_MONTHS / 100.0)
if self.PRICE_12_MONTHS is not None:
options[12] = float(self.PRICE_12_MONTHS / 100.0)
if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
options[1] = float(self.RUB_PRICE_1_MONTH)
if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
options[3] = float(self.RUB_PRICE_3_MONTHS)
if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
options[6] = float(self.RUB_PRICE_6_MONTHS)
if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
options[12] = float(self.RUB_PRICE_12_MONTHS)
return options
@computed_field
@property
def stars_subscription_options(self) -> Dict[int, int]:
options: Dict[int, int] = {}
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
options[1] = self.STARS_PRICE_1_MONTH
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
options[3] = self.STARS_PRICE_3_MONTHS
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
options[6] = self.STARS_PRICE_6_MONTHS
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
options[12] = self.STARS_PRICE_12_MONTHS
return options
@computed_field
@property
def tribute_payment_links(self) -> Dict[int, str]:
links: Dict[int, str] = {}
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
links[1] = self.TRIBUTE_LINK_1_MONTH
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
links[3] = self.TRIBUTE_LINK_3_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
links[6] = self.TRIBUTE_LINK_6_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
links[12] = self.TRIBUTE_LINK_12_MONTHS
return links
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
@@ -183,6 +355,41 @@ 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
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
@classmethod
def sanitize_optional_link(cls, v):
if isinstance(v, str) and not v.strip():
return None
return v
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
@classmethod
def validate_optional_int(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
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',
@@ -211,6 +418,22 @@ def get_settings() -> Settings:
logging.warning(
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
)
if _settings_instance.FREEKASSA_ENABLED:
if (
not _settings_instance.FREEKASSA_MERCHANT_ID
or not _settings_instance.FREEKASSA_API_KEY
):
logging.warning(
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work."
)
if not _settings_instance.FREEKASSA_SECOND_SECRET:
logging.warning(
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified."
)
if not _settings_instance.subscription_options:
logging.warning(
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
)
except ValidationError as e:
logging.critical(
+21
View File
@@ -0,0 +1,21 @@
from . import user_dal
from . import payment_dal
from . import subscription_dal
from . import promo_code_dal
from . import panel_sync_dal
from . import message_log_dal
from . import user_billing_dal
from . import ad_dal
__all__ = (
"user_dal",
"payment_dal",
"subscription_dal",
"promo_code_dal",
"panel_sync_dal",
"message_log_dal",
"user_billing_dal",
"ad_dal",
)
+206
View File
@@ -0,0 +1,206 @@
import logging
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_
from ..models import AdCampaign, AdAttribution, Payment
async def create_campaign(
session: AsyncSession, *, source: str, start_param: str, cost: float
) -> AdCampaign:
existing = await get_campaign_by_start_param(session, start_param)
if existing:
raise ValueError("ad_campaign_start_param_exists")
campaign = AdCampaign(source=source, start_param=start_param, cost=float(cost))
session.add(campaign)
await session.flush()
await session.refresh(campaign)
logging.info(
f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}"
)
return campaign
async def get_campaign_by_id(session: AsyncSession, campaign_id: int) -> Optional[AdCampaign]:
stmt = select(AdCampaign).where(AdCampaign.ad_campaign_id == campaign_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_campaign_by_start_param(session: AsyncSession, start_param: str) -> Optional[AdCampaign]:
clean = start_param.strip()
stmt = select(AdCampaign).where(AdCampaign.start_param == clean)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def list_campaigns(session: AsyncSession, *, only_active: bool = False) -> List[AdCampaign]:
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc())
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def toggle_campaign_active(session: AsyncSession, campaign_id: int, is_active: bool) -> bool:
stmt = (
update(AdCampaign)
.where(AdCampaign.ad_campaign_id == campaign_id)
.values(is_active=is_active)
)
result = await session.execute(stmt)
return result.rowcount > 0
async def ensure_attribution(session: AsyncSession, *, user_id: int, campaign_id: int) -> AdAttribution:
existing = await get_attribution_for_user(session, user_id)
if existing:
return existing
attrib = AdAttribution(user_id=user_id, ad_campaign_id=campaign_id)
session.add(attrib)
await session.flush()
await session.refresh(attrib)
logging.info(f"AdAttribution created for user {user_id} -> campaign {campaign_id}")
return attrib
async def get_attribution_for_user(session: AsyncSession, user_id: int) -> Optional[AdAttribution]:
stmt = select(AdAttribution).where(AdAttribution.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def mark_trial_activated(session: AsyncSession, user_id: int) -> bool:
stmt = (
update(AdAttribution)
.where(and_(AdAttribution.user_id == user_id, AdAttribution.trial_activated_at.is_(None)))
.values(trial_activated_at=func.now())
)
result = await session.execute(stmt)
return result.rowcount > 0
async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[str, Any]:
# Starts (attributed users)
starts_stmt = select(func.count(AdAttribution.user_id)).where(
AdAttribution.ad_campaign_id == campaign_id
)
starts = (await session.execute(starts_stmt)).scalar() or 0
# Trials
trials_stmt = select(func.count(AdAttribution.user_id)).where(
and_(AdAttribution.ad_campaign_id == campaign_id, AdAttribution.trial_activated_at.is_not(None))
)
trials = (await session.execute(trials_stmt)).scalar() or 0
# Payers (unique users with succeeded payments)
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.where(AdAttribution.ad_campaign_id == campaign_id)
.subquery()
)
payers_stmt = (
select(func.count(func.distinct(Payment.user_id)))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
payers = (await session.execute(payers_stmt)).scalar() or 0
# Revenue sum
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {
"starts": int(starts),
"trials": int(trials),
"payers": int(payers),
"revenue": revenue,
}
async def count_campaigns(session: AsyncSession, *, only_active: bool = False) -> int:
stmt = select(func.count(AdCampaign.ad_campaign_id))
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
return int((await session.execute(stmt)).scalar() or 0)
async def list_campaigns_paged(
session: AsyncSession, *, page: int, page_size: int, only_active: bool = False
) -> List[AdCampaign]:
offset = max(0, page) * max(1, page_size)
stmt = select(AdCampaign).order_by(AdCampaign.created_at.desc()).offset(offset).limit(page_size)
if only_active:
stmt = stmt.where(AdCampaign.is_active == True)
result = await session.execute(stmt)
return result.scalars().all()
async def get_totals(session: AsyncSession) -> Dict[str, float]:
# Total cost across all campaigns
total_cost_stmt = select(func.coalesce(func.sum(AdCampaign.cost), 0.0))
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
# Total revenue from all attributed users (unique users counted across all campaigns)
attrib_subq = (
select(
AdAttribution.user_id.label("user_id"),
AdAttribution.first_start_at.label("first_start_at"),
)
.subquery()
)
revenue_stmt = (
select(func.coalesce(func.sum(Payment.amount), 0.0))
.select_from(Payment)
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= attrib_subq.c.first_start_at,
)
)
)
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
return {"cost": total_cost, "revenue": total_revenue}
async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
"""Delete ad campaign by id along with related attributions.
Returns True if campaign existed and was deleted, False otherwise.
"""
try:
campaign = await session.get(AdCampaign, campaign_id)
if not campaign:
return False
await session.delete(campaign)
await session.flush()
logging.info(f"AdCampaign deleted id={campaign_id}")
return True
except Exception as e:
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
raise
+174 -25
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,47 @@ 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]:
async def get_payment_by_provider_payment_id(
session: AsyncSession, provider_payment_id: str) -> Optional[Payment]:
"""Fetch a payment by provider-specific identifier."""
stmt = select(Payment).where(
Payment.yookassa_payment_id == yookassa_payment_id)
Payment.provider_payment_id == provider_payment_id)
result = await session.execute(stmt)
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]:
@@ -55,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,
@@ -87,29 +111,154 @@ async def update_payment_status_by_db_id(
return payment
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)
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))
.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()
async def count_user_succeeded_payments(
session: AsyncSession, user_id: int, exclude_payment_id: Optional[int] = None
) -> int:
"""Count succeeded payments for a specific user.
If exclude_payment_id is provided, that specific payment will be excluded
from the count. Useful to check "prior" payments while processing the
current payment in the same transaction.
"""
conditions = [Payment.user_id == user_id, Payment.status == 'succeeded']
if exclude_payment_id is not None:
conditions.append(Payment.payment_id != exclude_payment_id)
stmt = select(func.count(Payment.payment_id)).where(and_(*conditions))
result = await session.execute(stmt)
return result.scalar() or 0
async def update_provider_payment_and_status(
session: AsyncSession, payment_db_id: int,
provider_payment_id: str, new_status: str) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.provider_payment_id = provider_payment_id
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}."
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}."
)
else:
logging.warning(
f"Payment record with YK ID {yookassa_payment_id} not found for status update."
f"Payment record with DB ID {payment_db_id} not found for provider 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))
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.scalars().all()
return result.scalar_one_or_none()
async def get_last_tribute_payment(
session: AsyncSession, user_id: int) -> Optional[Payment]:
"""Return the most recent succeeded Tribute payment for the user."""
stmt = (select(Payment).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()
+69 -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,68 @@ 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)
if not promo:
return None
for key, value in update_data.items():
setattr(promo, key, value)
await session.flush()
await session.refresh(promo)
return promo
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
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
async def increment_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
promo = await get_promo_code_by_id(session, promo_code_id)
@@ -69,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)
@@ -82,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:
@@ -107,7 +173,6 @@ async def record_promo_activation(
logging.error(
f"Cannot record promo activation: Payment {payment_id} not found."
)
return None
activation_data = {
+75 -37
View File
@@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id(
user_id: int,
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
stmt = select(Subscription).where(
Subscription.user_id == user_id, Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc))
Subscription.user_id == user_id,
Subscription.is_active == True,
Subscription.end_date > datetime.now(timezone.utc),
)
if panel_user_uuid:
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
stmt = stmt.order_by(Subscription.end_date.desc())
stmt = stmt.order_by(Subscription.end_date.desc()).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
return result.scalars().first()
async def get_subscription_by_panel_subscription_uuid(
@@ -31,27 +33,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 +55,34 @@ async def update_subscription(
return sub
async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]:
"""Toggle auto_renew_enabled for a subscription."""
return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled})
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")
@@ -129,6 +146,33 @@ async def deactivate_other_active_subscriptions(
)
async def deactivate_all_user_subscriptions(
session: AsyncSession, user_id: int) -> int:
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id, Subscription.is_active == True)
.values(is_active=False, status_from_panel="INACTIVE_USER_NOT_FOUND")
)
result = await session.execute(stmt)
if result.rowcount > 0:
logging.info(
f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user."
)
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]:
@@ -156,7 +200,9 @@ async def get_subscriptions_near_expiration(
threshold_date = now_utc + timedelta(days=days_threshold)
stmt = (select(Subscription).join(Subscription.user).where(
Subscription.is_active == True, Subscription.end_date > now_utc,
Subscription.is_active == True,
Subscription.skip_notifications == False,
Subscription.end_date > now_utc,
Subscription.end_date <= threshold_date,
or_(
Subscription.last_notification_sent == None,
@@ -176,17 +222,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]:
@@ -203,3 +238,6 @@ async def find_subscription_for_notification_update(
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
+164
View File
@@ -0,0 +1,164 @@
from typing import Optional, Dict, Any, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update
from sqlalchemy.sql import func
from db.models import UserBilling, UserPaymentMethod
async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]:
stmt = select(UserBilling).where(UserBilling.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def upsert_yk_payment_method(
session: AsyncSession,
*,
user_id: int,
payment_method_id: str,
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
) -> UserBilling:
existing = await get_user_billing(session, user_id)
if existing:
existing.yookassa_payment_method_id = payment_method_id
existing.card_last4 = card_last4
existing.card_network = card_network
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
record = UserBilling(
user_id=user_id,
yookassa_payment_method_id=payment_method_id,
card_last4=card_last4,
card_network=card_network,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def delete_yk_payment_method(session: AsyncSession, user_id: int) -> bool:
existing = await get_user_billing(session, user_id)
if not existing:
return False
existing.yookassa_payment_method_id = None
existing.card_last4 = None
existing.card_network = None
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return True
# Multi-card support API
async def upsert_user_payment_method(
session: AsyncSession,
*,
user_id: int,
provider_payment_method_id: str,
provider: str = "yookassa",
card_last4: Optional[str] = None,
card_network: Optional[str] = None,
set_default: bool = False,
) -> UserPaymentMethod:
existing_stmt = select(UserPaymentMethod).where(UserPaymentMethod.provider_payment_method_id == provider_payment_method_id)
result = await session.execute(existing_stmt)
existing: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if existing:
existing.card_last4 = card_last4
existing.card_network = card_network
if set_default:
# unset previous defaults
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
existing.is_default = True
existing.updated_at = func.now()
await session.flush()
await session.refresh(existing)
return existing
if set_default:
await session.execute(
update(UserPaymentMethod)
.where(UserPaymentMethod.user_id == user_id)
.values(is_default=False)
)
record = UserPaymentMethod(
user_id=user_id,
provider=provider,
provider_payment_method_id=provider_payment_method_id,
card_last4=card_last4,
card_network=card_network,
is_default=set_default,
)
session.add(record)
await session.flush()
await session.refresh(record)
return record
async def list_user_payment_methods(session: AsyncSession, user_id: int, provider: Optional[str] = None) -> List[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
if provider:
stmt = stmt.where(UserPaymentMethod.provider == provider)
stmt = stmt.order_by(UserPaymentMethod.is_default.desc(), UserPaymentMethod.created_at.desc())
result = await session.execute(stmt)
return result.scalars().all()
async def get_user_default_payment_method(session: AsyncSession, user_id: int, provider: str = "yookassa") -> Optional[UserPaymentMethod]:
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider == provider,
UserPaymentMethod.is_default == True,
)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def set_user_default_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
methods = await list_user_payment_methods(session, user_id)
if not any(m.method_id == method_id for m in methods):
return False
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id).values(is_default=False))
await session.execute(update(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id).values(is_default=True))
return True
async def delete_user_payment_method(session: AsyncSession, user_id: int, method_id: int) -> bool:
stmt = select(UserPaymentMethod).where(UserPaymentMethod.method_id == method_id, UserPaymentMethod.user_id == user_id)
result = await session.execute(stmt)
method = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
async def delete_user_payment_method_by_provider_id(
session: AsyncSession,
user_id: int,
provider_payment_method_id: str,
) -> bool:
"""Delete a saved payment method by its provider payment_method.id for a specific user.
Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id.
"""
stmt = select(UserPaymentMethod).where(
UserPaymentMethod.user_id == user_id,
UserPaymentMethod.provider_payment_method_id == provider_payment_method_id,
)
result = await session.execute(stmt)
method: Optional[UserPaymentMethod] = result.scalar_one_or_none()
if not method:
return False
await session.delete(method)
await session.flush()
return True
+173 -68
View File
@@ -4,51 +4,76 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from sqlalchemy import update, delete, func, and_
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.dialects.postgresql import insert as pg_insert
from ..models import User, Subscription
async def get_user_by_id(session: AsyncSession,
user_id: int) -> Optional[User]:
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
stmt = select(User).where(User.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_username(session: AsyncSession,
username: str) -> Optional[User]:
clean_username = username.lstrip('@').lower()
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
clean_username = username.lstrip("@").lower()
stmt = select(User).where(func.lower(User.username) == clean_username)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_panel_uuid(session: AsyncSession,
panel_uuid: str) -> Optional[User]:
async def get_user_by_panel_uuid(
session: AsyncSession, panel_uuid: str
) -> Optional[User]:
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def create_user(session: AsyncSession, user_data: Dict[str,
Any]) -> User:
## Removed unused generic get_user helper to keep DAL explicit and simple
if 'registration_date' not in user_data:
user_data['registration_date'] = datetime.now()
new_user = User(**user_data)
session.add(new_user)
await session.flush()
await session.refresh(new_user)
logging.info(
f"New user {new_user.user_id} created in DAL. Referred by: {new_user.referred_by_id or 'N/A'}."
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple[User, bool]:
"""Create a user if not exists in a race-safe way.
Returns a tuple of (user, created_flag).
"""
if "registration_date" not in user_data:
user_data["registration_date"] = datetime.now(timezone.utc)
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
stmt = (
pg_insert(User)
.values(**user_data)
.on_conflict_do_nothing(index_elements=[User.user_id])
.returning(User.user_id)
)
return new_user
result = await session.execute(stmt)
inserted_row = result.first()
created = inserted_row is not None
# Fetch the user (inserted just now or pre-existing)
user_id: int = user_data["user_id"]
user = await get_user_by_id(session, user_id)
if created and user is not None:
logging.info(
f"New user {user.user_id} created in DAL. Referred by: {user.referred_by_id or 'N/A'}."
)
elif user is not None:
logging.info(
f"User {user.user_id} already exists in DAL. Proceeding without creation."
)
return user, created
async def update_user(session: AsyncSession, user_id: int,
update_data: Dict[str, Any]) -> Optional[User]:
async def update_user(
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
) -> Optional[User]:
user = await get_user_by_id(session, user_id)
if user:
for key, value in update_data.items():
@@ -58,67 +83,147 @@ async def update_user(session: AsyncSession, user_id: int,
return user
async def update_user_language(session: AsyncSession, user_id: int,
lang_code: str) -> bool:
stmt = update(User).where(User.user_id == user_id).values(
language_code=lang_code)
async def update_user_language(
session: AsyncSession, user_id: int, lang_code: str
) -> bool:
stmt = update(User).where(User.user_id == user_id).values(language_code=lang_code)
result = await session.execute(stmt)
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(session: AsyncSession) -> List[User]:
"""Get all banned users"""
stmt = (
select(User)
.where(User.is_banned == True)
.order_by(User.registration_date.desc())
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_banned_users_paginated(session: AsyncSession, limit: int,
offset: int) -> Tuple[List[User], int]:
stmt_users = 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
async def get_all_active_user_ids_for_broadcast(
session: AsyncSession) -> List[int]:
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
stmt = select(User.user_id).where(User.is_banned == False)
result = await session.execute(stmt)
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)
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()
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
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, timezone
# Use timezone-aware UTC to avoid naive/aware comparison issues in SQL queries
now = datetime.now(timezone.utc)
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 (proxy: registered today)
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 (non-trial providers only)
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,
"users_with_active_subscriptions": active_subs_users,
"active_today": active_today,
"paid_subscriptions": paid_subs_users,
"trial_users": trial_users,
"inactive_users": max(0, inactive_users),
"referral_users": referral_users
}
async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who have an active subscription (paid or trial)."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
stmt = (
select(func.distinct(Subscription.user_id))
.join(User, Subscription.user_id == User.user_id)
.where(
and_(
User.is_banned == False,
Subscription.is_active == True,
Subscription.end_date > now,
)
)
)
result = await session.execute(stmt)
return result.scalars().all()
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
"""Return non-banned user IDs who do NOT have any active subscription."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
# Subquery for users with active subscription
active_subs_subq = (
select(Subscription.user_id)
.where(
and_(
Subscription.is_active == True,
Subscription.end_date > now,
)
)
).scalar_subquery()
stmt = (
select(User.user_id)
.where(
and_(
User.is_banned == False,
~User.user_id.in_(active_subs_subq),
)
)
)
result = await session.execute(stmt)
return result.scalars().all()
+2
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from .models import Base
from .migrator import run_database_migrations
async_engine = None
@@ -62,6 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(run_database_migrations)
logging.info(
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
)
+98
View File
@@ -0,0 +1,98 @@
import logging
from dataclasses import dataclass
from typing import Callable, List, Set
from sqlalchemy import inspect, text
from sqlalchemy.engine import Connection
@dataclass(frozen=True)
class Migration:
id: str
description: str
upgrade: Callable[[Connection], None]
def _ensure_migrations_table(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
id VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
)
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
statements: List[str] = []
if "channel_subscription_verified" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
)
if "channel_subscription_checked_at" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
)
if "channel_subscription_verified_for" not in columns:
statements.append(
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
)
for stmt in statements:
connection.execute(text(stmt))
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
description="Add columns to track required channel subscription verification",
upgrade=_migration_0001_add_channel_subscription_fields,
),
]
def run_database_migrations(connection: Connection) -> None:
"""
Apply pending migrations sequentially. Already applied revisions are skipped.
"""
_ensure_migrations_table(connection)
applied_revisions: Set[str] = {
row[0]
for row in connection.execute(
text("SELECT id FROM schema_migrations")
)
}
for migration in MIGRATIONS:
if migration.id in applied_revisions:
continue
logging.info(
"Migrator: applying %s %s", migration.id, migration.description
)
try:
with connection.begin_nested():
migration.upgrade(connection)
connection.execute(
text(
"INSERT INTO schema_migrations (id) VALUES (:revision)"
),
{"revision": migration.id},
)
except Exception as exc:
logging.error(
"Migrator: failed to apply %s (%s)",
migration.id,
migration.description,
exc_info=True,
)
raise exc
else:
logging.info("Migrator: migration %s applied successfully", migration.id)
+72
View File
@@ -24,6 +24,10 @@ class User(Base):
referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"),
nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True),
nullable=True)
channel_subscription_verified_for = Column(BigInteger, nullable=True)
referrer = relationship("User", remote_side=[user_id], backref="referrals")
subscriptions = relationship("Subscription",
@@ -70,6 +74,9 @@ class Subscription(Base):
traffic_limit_bytes = Column(BigInteger, nullable=True)
traffic_used_bytes = Column(BigInteger, nullable=True)
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
provider = Column(String, nullable=True)
skip_notifications = Column(Boolean, default=False)
auto_renew_enabled = Column(Boolean, default=True, index=True)
user = relationship("User", back_populates="subscriptions")
@@ -89,6 +96,8 @@ class Payment(Base):
unique=True,
index=True,
nullable=True)
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
currency = Column(String, nullable=False)
@@ -108,6 +117,37 @@ class Payment(Base):
back_populates="payments_where_used")
class UserBilling(Base):
__tablename__ = "user_billing"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
# Saved payment method for off-session recurring charges (YooKassa)
yookassa_payment_method_id = Column(String, nullable=True, unique=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
class UserPaymentMethod(Base):
__tablename__ = "user_payment_methods"
method_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
provider_payment_method_id = Column(String, nullable=False, unique=True, index=True)
card_last4 = Column(String, nullable=True)
card_network = Column(String, nullable=True)
is_default = Column(Boolean, default=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
user = relationship("User")
__table_args__ = (
UniqueConstraint('user_id', 'provider_payment_method_id', name='uq_user_provider_method'),
)
class PromoCode(Base):
__tablename__ = "promo_codes"
@@ -191,3 +231,35 @@ class PanelSyncStatus(Base):
subscriptions_synced = Column(Integer, default=0)
__table_args__ = (UniqueConstraint('id'), )
class AdCampaign(Base):
__tablename__ = "ad_campaigns"
ad_campaign_id = Column(Integer, primary_key=True, autoincrement=True)
source = Column(String, nullable=False, index=True)
start_param = Column(String, nullable=False, unique=True, index=True)
cost = Column(Float, nullable=False, default=0.0)
is_active = Column(Boolean, default=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
attributions = relationship(
"AdAttribution",
back_populates="campaign",
cascade="all, delete-orphan",
)
def __repr__(self):
return f"<AdCampaign(id={self.ad_campaign_id}, source='{self.source}', start_param='{self.start_param}', cost={self.cost})>"
class AdAttribution(Base):
__tablename__ = "ad_attributions"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True, index=True)
ad_campaign_id = Column(Integer, ForeignKey("ad_campaigns.ad_campaign_id"), nullable=False, index=True)
first_start_at = Column(DateTime(timezone=True), server_default=func.now())
trial_activated_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User")
campaign = relationship("AdCampaign", back_populates="attributions")
+19 -10
View File
@@ -6,26 +6,35 @@ services:
hostname: remnawave-tg-shop
env_file:
- .env
# networks:
# - remnawave-network
# volumes:
# - ./locales:/app/locales
networks:
- remnawave-network
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
remnawave-tg-shop-db:
condition: service_healthy
postgres:
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks:
# - remnawave-network
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
# networks:
# remnawave-network:
# external: true
networks:
remnawave-network:
external: true
volumes:
remnawave-tg-shop-db-data:
+363 -110
View File
@@ -1,109 +1,135 @@
{
"welcome": "Welcome, {user_name}!",
"channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".",
"channel_subscription_join_button": "Open channel",
"channel_subscription_verify_button": "Check subscription",
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
"menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Referrals",
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
"menu_apply_promo_button": "🎟 Promo Code",
"menu_language_settings_inline": "🌐 Language",
"menu_server_status_button": "📊 Status",
"menu_support_button": "💬 Support",
"menu_terms_button": "📄 Terms of Service",
"back_to_main_menu_button": "⬅️ Back",
"choose_language": "Choose language / Выберите язык:",
"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.",
"select_subscription_period": "Select subscription period:",
"no_subscription_options_available": "No subscription options available at the moment.",
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
"confirm_subscription_prompt": "Confirm subscription purchase:\nDuration: {months} mo.\nPrice: {price} {currency_symbol}",
"choose_payment_method": "Choose payment method:",
"pay_button": "💳 Pay",
"confirm_payment_button": " Yes ({price} {currency_symbol})",
"pay_with_yookassa_button": "💳 YooKassa",
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
"yookassa_autopay_pay_saved_card_button": "💳 Pay with saved card",
"yookassa_autopay_pay_new_card_button": " Pay with new card",
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
"back_to_autopay_method_choice_button": "⬅️ Back",
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
"pay_with_sbp_button": "📱 SBP",
"back_to_payment_methods_button": "⬅️ Back",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"cancel_button": "❌ Cancel",
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nDate connected: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
"device_disconnected": "Device disconnected successfully.",
"no_devices_found": "Error loading device list.",
"devices_unlimited_label": "Unlimited",
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
"payment_description_subscription": "Subscription payment for {months} mo.",
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
"payment_service_unavailable_alert": "Payment service unavailable",
"error_creating_payment_record": "Error creating payment record. Please try again later.",
"error_payment_gateway_link_failed": "Failed to get payment link. Please contact support.",
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
"error_payment_gateway": "Payment gateway error. Please try again later or contact support.",
"payment_successful": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.",
"payment_successful_with_promo": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) is active until {end_date}.",
"payment_successful_with_referral_bonus": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.",
"free_kassa_order_info": "Order #{order_id} from {date}",
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"error_processing_your_payment": "An error occurred processing your payment after success. Please contact support with the details.",
"my_subscription_details": "<b>🔐 My Subscription:</b>\n\nStatus: <b>{status}</b>\nActive until: <b>{end_date}</b> (days left: {days_left})\n\nTraffic: <b>{traffic_used}</b> of <b>{traffic_limit}</b>\n\nConfiguration link:\n<code>{config_link}</code>",
"config_link_not_available": "not available, contact support",
"subscription_not_active": "You have no active subscription.\nWould you like to purchase one?",
"status_active": "Active",
"status_expired": "Expired",
"status_disabled": "Disabled",
"traffic_unlimited": "Unlimited",
"traffic_na": "n/a",
"error_service_unavailable": "Service temporarily unavailable. Please try again later.",
"promo_code_prompt": "Please enter your promo code:",
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_no_active_subscription": "You must have an active subscription to apply this promo code.",
"promo_code_applied_success": "✅ Promo code <code>{code}</code> applied successfully!\nYour subscription is extended by {bonus_days} days and is now active until {new_end_date}.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.",
"trial_already_had_subscription_or_trial": "You have already used a free trial or had a paid subscription. Free trial is available only once for new users.",
"trial_confirm_prompt": "Would you like to activate a free trial for {days} days with {traffic_gb} GB traffic limit?",
"trial_confirm_activate_button": "✅ Activate!",
"trial_activated_alert": "Trial activated!",
"trial_activated_details_message": "🚀 Your {days}-day free trial is activated!\n\nValid until: <b>{end_date}</b>\nTraffic: <b>{traffic_gb} GB</b>\n\nYour configuration link:\n<code>{config_link}</code>",
"trial_activation_failed": "Failed to activate free trial. Please try again later.",
"trial_activation_failed_panel_link": "Failed to link your account to the server for trial activation. Please try again later.",
"trial_activation_failed_db": "Database error during trial activation. Please try again later.",
"trial_activation_failed_panel_update": "Failed to update trial details on the server. Please try again later.",
"user_not_found_for_trial": "Your account was not found in the system. Please run /start first.",
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"yes_button": "Yes",
"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}.",
"user_is_banned": "🚫 Your account is banned. Please contact support.",
"admin_panel_title": "Admin Panel",
"admin_stats_button": "📊 Statistics",
"admin_broadcast_button": "📢 Broadcast",
"admin_create_promo_button": "🎁 Create Promo",
"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_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_action_cancelled_default": "Action cancelled. Returning to menu.",
"admin_action_cancelled_default_alert": "Action cancelled",
"back_to_admin_panel_button": "⬅️ Back to Admin",
"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",
"back_to_admin_panel_button": "⬅️ To Admin",
"admin_stats_header": "📊 Bot Statistics",
"admin_stats_users": "👥 Users: Total - {total_users}, Banned - {banned_users}, Active Subs - {active_subs}",
"admin_enhanced_users_stats_header": "Users",
"admin_financial_stats_header": "Financial Statistics",
"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",
@@ -111,96 +137,323 @@
"admin_stats_sync_subs_synced": "Subscriptions Synced",
"admin_stats_sync_details_label": "Details",
"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_short": "The message above will be sent. Confirm?",
"broadcast_target_all_button": "👥 All",
"broadcast_target_active_button": "✅ Active",
"broadcast_target_inactive_button": "⌛ Inactive",
"confirm_broadcast_send_button": "✅ Send",
"cancel_broadcast_button": "❌ Cancel",
"admin_broadcast_sending_started": "Starting broadcast...",
"admin_broadcast_error_no_message": "Error: no message to broadcast.",
"admin_broadcast_error_no_message_alert": "Broadcast message is empty!",
"admin_broadcast_finished_stats": "🏁 Broadcast finished!\nSent: {sent_count}\nFailed: {failed_count}",
"admin_broadcast_cancelled": "Broadcast cancelled.",
"admin_broadcast_cancelled_alert": "Broadcast cancelled!",
"admin_broadcast_cancelled_nav_back": "Broadcast cancelled. You are returned to the admin panel.",
"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]",
"broadcast_queue_result": "🚀 Broadcast queued!\n📤 Enqueued: {sent_count}\n❌ Errors: {failed_count}\n\n📊 Queue Status:\n👥 User queue: {user_queue_size} messages\n📢 Group queue: {group_queue_size} messages\n\n️ Messages will be sent automatically within Telegram limits.",
"admin_promo_invalid_code_format": "Code must be 330 alphanumeric characters.",
"admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.",
"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_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_invalid_values": "Invalid values. {error}",
"admin_promo_invalid_format_general": "Promo parsing error. Check the format.",
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_creation_failed_duplicate": "❌ Error: Promo code <code>{code}</code> already exists.",
"admin_promo_creation_failed": "❌ Failed to create promo code. Please try again later.",
"admin_promo_set_validity_days": "⏰ Set validity (days)",
"admin_back_to_panel": "⬅️ Back to panel",
"admin_promo_unlimited": "♾️ Unlimited",
"admin_bulk_promo_created_title": "📦 Bulk creation completed",
"admin_bulk_promo_created_stats": "📊 Created: <b>{created}</b> of <b>{total}</b>",
"admin_bulk_promo_settings": "🎁 Bonus days: <b>{bonus_days}</b>\n📊 Max activations: <b>{max_activations}</b>\n⏰ Validity: <b>{validity}</b>",
"admin_promo_list_page_info": "Page {current}/{total} ({count} promo codes)",
"admin_queue_status_button": "📊 Queue Status",
"admin_queue_status_title": "📊 Message Queue Status",
"admin_queue_status_info": "📤 <b>Message Queues:</b>\n\n👥 <b>Users (25 msg/sec):</b>\n 📋 In queue: {user_queue_size}\n 🔄 Processing: {user_processing}\n 📈 Sent per minute: {user_recent}\n\n📢 <b>Groups/channels (15 msg/min):</b>\n 📋 In queue: {group_queue_size}\n 🔄 Processing: {group_processing}\n 📈 Sent per minute: {group_recent}",
"admin_active_promos_list_header": "Active Promo Codes:",
"admin_no_active_promos": "No active promo codes.",
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
"admin_promo_valid_indefinitely": "indefinite",
"admin_promo_valid_until_display": "until {date}",
"admin_promo_edit_button": "✏️ Edit",
"admin_promo_delete_button": "🗑 Delete",
"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_export_all_generating": "📄 Generating CSV...",
"admin_promo_export_all_caption": "📄 Export of all promo codes\n📊 Total: {count} promo codes",
"admin_promo_csv_code": "Code",
"admin_promo_csv_bonus_days": "Bonus Days",
"admin_promo_csv_max_activations": "Max Activations",
"admin_promo_csv_current_activations": "Current Activations",
"admin_promo_csv_status": "Status",
"admin_promo_csv_is_active": "Active",
"admin_promo_csv_valid_until": "Valid Until",
"admin_promo_csv_created_at": "Created",
"admin_promo_csv_created_by_admin_id": "Created By (Admin ID)",
"csv_yes": "Yes",
"csv_no": "No",
"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.",
"admin_cannot_ban_self_or_admin": "You cannot ban yourself or another admin.",
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
"admin_user_banned_success_combined": "✅ User {user_id_or_username} banned in bot {panel_status_part}.",
"admin_panel_ban_success_part": "and on panel",
"admin_panel_ban_fail_part": ", but failed to ban on panel",
"admin_user_ban_failed_local_db_error": "❌ Failed to ban user in local database.",
"admin_unban_user_prompt": "Enter user ID or @username to unban:",
"admin_user_not_banned": "User {user_id_or_username} is not banned.",
"admin_user_unbanned_success_combined": "✅ User {user_id_or_username} unbanned in bot {panel_status_part}.",
"admin_panel_unban_success_part": "and on panel",
"admin_panel_unban_fail_part": ", but failed to unban on panel",
"admin_user_unban_failed_local_db_error": "❌ Failed to unban user in local database.",
"admin_no_banned_users": "There are no banned users at the moment.",
"admin_banned_list_title": "Banned Users (page {current_page}/{total_pages}):",
"admin_user_not_banned": "⚠️ User is not banned",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Prev.",
"next_page_button": "Next ➡️",
"admin_user_card_title": "User Profile: {user_display}",
"user_card_info": "ID: <code>{user_id}</code>\nUsername: @{username}\nName: {first_name} {last_name}\nLanguage: {language_code}\nPanel UUID: <code>{panel_user_uuid}</code>\nStatus: <b>{ban_status}</b>\nRegistered: {reg_date}\nSubscription until: <b>{sub_end_date}</b>",
"user_card_banned": "BANNED",
"user_card_active": "Active",
"user_card_sub_na": "n/a",
"admin_user_card_title": "User Card",
"user_card_ban_button": "🚫 Ban",
"user_card_unban_button": "✅ Unban",
"user_card_back_to_banned_list": "⬅️ Back to Ban List",
"admin_confirm_action_title": "Confirm: {action_text}",
"ban_verb_l": "ban",
"unban_verb_l": "unban",
"admin_confirm_ban_prompt": "Are you sure you want to ban {user_display} (ID: {user_id})?",
"admin_confirm_unban_prompt": "Are you sure you want to unban {user_display} (ID: {user_id})?",
"admin_user_banned_from_card_alert": "User {user_display} (ID: {user_id}) has been banned.",
"admin_user_unbanned_from_card_alert": "User {user_display} (ID: {user_id}) has been unbanned.",
"admin_user_ban_failed_db_error": "Error banning user in database.",
"admin_user_unban_failed_db_error": "Error unbanning user in database.",
"admin_panel_status_update_fail_part": ", but panel status not updated",
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
"admin_logs_menu_title": "Logs Menu:",
"admin_view_all_logs_button": "📜 All Message Logs",
"admin_view_user_logs_prompt_button": "👤 User Logs",
"admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):",
"admin_export_logs_csv_button": "📄 Export to CSV",
"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}",
"system_or_unknown_user": "System/Unknown",
"admin_prompt_for_user_id_or_username_logs": "Enter user ID or @username to view logs:",
"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}",
"no_errors_placeholder": "none",
"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",
"admin_sync_initiated_from_panel": "Sync initiated...",
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
"error_displaying_statistics": "Error displaying statistics.",
"stub_page_display": "Page",
"subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.",
"error_unknown": "An unknown error occurred."
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
"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.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
"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}",
"yookassa_auto_renewal": "🔄 <b>Subscription Auto-Renewed</b>\n\nYour subscription was automatically renewed for {months} month(s).\nNew expiration date: {end_date}",
"admin_user_management_prompt": "👤 User Management\n\nEnter user ID or @username to search:",
"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_recent_actions_title": "📜 Recent actions of user {user_id}:",
"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_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
"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_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_user_stats_description": "Total: {total}, Paid: {active}",
"inline_admin_user_stats_title": "👥 User Statistics",
"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_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",
"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_logs_csv_export_started": "📄 Starting log export to CSV...",
"admin_logs_csv_export_success": "✅ Logs exported! File attached above.",
"admin_user_logs_title": "Logs for {user_display} (page {current_page}/{total_pages}):",
"admin_all_logs_title": "All Logs (page {current_page}/{total_pages}):",
"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_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_unlimited_validity": "♾️ Unlimited",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
"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_none": "No active subscription",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
"admin_user_subscription_error": "Loading error",
"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_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_expired_label": "Expired",
"admin_panel_limited_label": "Limited",
"admin_panel_total_users_label": "Total users",
"admin_panel_memory_usage_label": "RAM Usage",
"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_stats_fetch_error": "Error fetching panel data",
"admin_panel_stats_error_details": "Details",
"inline_referral_title": "Referral link",
"inline_referral_description": "Share referral link to get bonuses",
"inline_financial_description": "Today: {today} RUB",
"inline_system_description": "🟢 Online: {online}, 📊 Active: {active}",
"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",
"admin_sync_details": "📊 Synchronization Statistics:\n🔍 Panel records checked: {panel_records_checked}\n👥 Users found in DB: {users_found_in_db}\n✨ New users created: {users_created}\n🔄 Users updated: {users_updated}\n📋 Subscriptions synced: {subscriptions_synced_count}\n ├── Created new: {subscriptions_created}\n └── Updated existing: {subscriptions_updated}{additional_stats}",
"admin_sync_no_telegram_id": "\n⚠️ Records without telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}",
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
"autorenew_enable_button": "🔄 Enable auto-renew",
"autorenew_disable_button": "🛑 Disable auto-renew",
"subscription_autorenew_updated": "Auto-renew settings updated.",
"payment_methods_manage_button": "💳 Payment Methods",
"payment_methods_title": "💳 <b>Payment Methods</b>",
"payment_method_bind_button": " Add method",
"payment_method_delete_button": "🗑 Remove",
"payment_method_view_button": "️ Details",
"payment_method_none": "You don't have a saved payment method yet.",
"payment_method_bound_success": "✅ Payment method added.",
"payment_method_deleted_success": "✅ Payment method removed.",
"payment_method_delete_confirm": "Remove saved payment method?",
"payment_method_card_title": "💳 {network} ••••{last4}",
"payment_method_generic_title": "💳 {network}",
"payment_method_wallet_title": "💼 YooMoney wallet ••••{last4}",
"payment_network_card": "Card",
"payment_network_generic": "Payment method",
"payment_method_added_at": "Added: {date}",
"payment_method_last_tx": "Last transaction: {date}",
"payment_method_tx_history_title": "📜 Transactions history",
"payment_method_no_history": "No transactions history.",
"subscription_purchase_title": "Subscription purchase for {months} mo.",
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
"subscription_not_active": "You don't have an active subscription.",
"error_service_unavailable": "Service unavailable. Please try again later.",
"error_payment_gateway": "Payment service error. Please try again later.",
"traffic_na": "N/A",
"payment_service_unavailable": "Payment service is unavailable.",
"payment_service_unavailable_alert": "Payment service is unavailable. Please try again later.",
"error_creating_payment_record": "Error creating payment record. Please try again later.",
"error_payment_gateway_link_failed": "Error creating payment link. Please try again later.",
"status_active": "Active",
"status_inactive": "Inactive",
"admin_ads_section": "📈 Ads",
"admin_ads_header": "📈 Ad Campaigns:",
"admin_ads_empty": "📭 No ad campaigns. Click \"Create\" to add one.",
"admin_ads_item": "ID: {id}\nSource: <b>{source}</b>\nstart={start_param}\nCost: {cost} RUB\nActive: {active}\n— Starts: {starts}\n— Trials: {trials}\n— Payers: {payers}\n— Revenue: {revenue} RUB",
"admin_ads_create_button": " Create campaign",
"admin_ads_create_source_prompt": "Enter source (e.g., AEZA, VK, TG-channel):",
"admin_ads_create_start_param_prompt": "Enter start link parameter (e.g., AEZA). Will be used as start=AEZA",
"admin_ads_create_cost_prompt": "Enter campaign cost (RUB):",
"admin_ads_invalid_source": "❌ Invalid source. Enter up to 64 characters.",
"admin_ads_invalid_start_param": "❌ Invalid parameter. Allowed letters/digits/underscore/dash (2-64).",
"admin_ads_invalid_cost": "❌ Invalid amount. Enter a non-negative number.",
"admin_ads_start_param_exists": "❌ A campaign with this start parameter already exists.",
"admin_ads_created_success": "✅ Campaign created!\nID: {id}\nSource: {source}\nParam: {start_param}\nCost: {cost} RUB",
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
"back_to_ads_list_button": "⬅️ Back to list",
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>",
"admin_ads_delete_button": "🗑 Delete campaign",
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
"admin_ads_deleted_success": "Campaign deleted.",
"admin_ads_not_found": "Campaign not found.",
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
}
+359 -106
View File
@@ -1,109 +1,135 @@
{
"welcome": "Добро пожаловать, {user_name}!",
"channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".",
"channel_subscription_join_button": "Перейти в канал",
"channel_subscription_verify_button": "Проверить подписку",
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",
"menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не натроена администратором бота",
"menu_referral_inline": "🎁 Рефералы",
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
"menu_apply_promo_button": "🎟 Промокод",
"menu_language_settings_inline": "🌐 Язык",
"menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса",
"back_to_main_menu_button": "⬅️ Назад",
"choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!",
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
"error_try_again": "Попробуйте еще раз.",
"error_displaying_menu": "Ошибка отображения меню.",
"main_menu_unknown_action": "Неизвестное действие.",
"select_subscription_period": "Выберите срок подписки:",
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
"choose_payment_method": "Выберите способ оплаты:",
"pay_button": "💳 Оплатить",
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
"pay_with_yookassa_button": "💳 ЮKassa",
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
"yookassa_autopay_pay_saved_card_button": "💳 Оплата привязанной картой",
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
"back_to_autopay_method_choice_button": "⬅️ Назад",
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
"pay_with_sbp_button": "📱 СБП",
"back_to_payment_methods_button": "⬅️ Назад",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
"device_disconnected": "Устройство успешно отключено.",
"no_devices_found": "Ошибка загрузки списка устройств.",
"devices_unlimited_label": "Без ограничений",
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
"cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
"payment_service_unavailable_alert": "Платежный сервис недоступен",
"error_creating_payment_record": "Ошибка при создании записи о платеже. Попробуйте позже.",
"error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.",
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
"error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.",
"payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.",
"payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.",
"payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.",
"free_kassa_order_info": "Заказ №{order_id} от {date}",
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.",
"my_subscription_details": "<b>🔐 Моя подписка:</b>\n\nСтатус: <b>{status}</b>\nАктивна до: <b>{end_date}</b> (осталось дней: {days_left})\n\nТрафик: <b>{traffic_used}</b> из <b>{traffic_limit}</b>\n\nСсылка на конфигурацию:\n<code>{config_link}</code>",
"config_link_not_available": "недоступна, обратитесь в поддержку",
"subscription_not_active": "У вас нет активной подписки. \nХотите приобрести?",
"status_active": "Активна",
"status_expired": "Истекла",
"status_disabled": "Отключена",
"traffic_unlimited": "Безлимитный",
"traffic_na": "н/д",
"error_service_unavailable": "Сервис временно недоступен. Пожалуйста, попробуйте позже.",
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
"promo_code_applied_success": "✅ Промокод <code>{code}</code> успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
"trial_confirm_prompt": "Хотите активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb}?",
"trial_confirm_activate_button": "✅ Активировать!",
"trial_activated_alert": "Пробный период активирован!",
"trial_activated_details_message": "🚀 Ваш пробный период на {days} дней активирован!\n\nДействует до: <b>{end_date}</b>\nТрафик: <b>{traffic_gb}</b>\n\nВаша ссылка на конфигурацию:\n<code>{config_link}</code>",
"trial_activation_failed": "Не удалось активировать пробный период. Пожалуйста, попробуйте позже.",
"trial_activation_failed_panel_link": "Не удалось связать ваш аккаунт с сервером для активации пробного периода. Попробуйте позже.",
"trial_activation_failed_db": "Ошибка базы данных при активации пробного периода. Попробуйте позже.",
"trial_activation_failed_panel_update": "Не удалось обновить детали на сервере для пробного периода. Попробуйте позже.",
"user_not_found_for_trial": "Ваш аккаунт не найден в системе. Пожалуйста, сначала выполните команду /start.",
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"yes_button": "Да",
"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}.",
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
"admin_panel_title": "Панель администратора",
"admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка",
"admin_create_promo_button": "🎁 Создать промо",
"admin_view_promos_button": "👀 Спиок промо",
"admin_create_bulk_promo_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_action_cancelled_default": "Действие отменено. Возврат в меню.",
"admin_action_cancelled_default_alert": "Действие отменено",
"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": "⬅️ К пользователям",
"back_to_admin_panel_button": "⬅️ В админку",
"admin_stats_header": "📊 Статистика Бота",
"admin_stats_users": "👥 Пользователи: Всего - {total_users}, Забанено - {banned_users}, С активной подпиской - {active_subs}",
"admin_enhanced_users_stats_header": "Пользователи",
"admin_financial_stats_header": "Финансовая статистика",
"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": "Статус",
@@ -111,96 +137,323 @@
"admin_stats_sync_subs_synced": "Синхронизировано подписок",
"admin_stats_sync_details_label": "Детали",
"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_short": "Сообщение выше будет отправлено. Подтвердить отправку?",
"broadcast_target_all_button": "👥 Все",
"broadcast_target_active_button": "✅ Активные",
"broadcast_target_inactive_button": "⌛ Неактивные",
"confirm_broadcast_send_button": "✅ Отправить",
"cancel_broadcast_button": "❌ Отмена",
"admin_broadcast_sending_started": "Начинаю рассылку...",
"admin_broadcast_error_no_message": "Ошибка: сообщение для рассылки не найдено.",
"admin_broadcast_error_no_message_alert": "Сообщение для рассылки пустое!",
"admin_broadcast_finished_stats": "🏁 Рассылка завершена!\nОтправлено: {sent_count}\nНе удалось отправить: {failed_count}",
"admin_broadcast_cancelled": "Рассылка отменена.",
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
"broadcast_queue_result": "🚀 Рассылка поставлена в очередь!\n📤 В очередь добавлено: {sent_count}\n❌ Ошибок: {failed_count}\n\n📊 Статус очередей:\n👥 Очередь пользователей: {user_queue_size} сообщений\n📢 Очередь групп: {group_queue_size} сообщений\n\nℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram.",
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
"admin_promo_invalid_max_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_creation_failed_duplicate": "❌ Ошибка: Промокод <code>{code}</code> уже существует.",
"admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"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Продлите её по кнопке ниже.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
"tribute_subscription_cancelled": "🚨 <b>Подписка отменена</b>\n\nВаша подписка Tribute была отменена. У вас есть 24 часа для восстановления доступа, после чего подписка будет заблокирована.\n\nДля продления подписки нажмите кнопку ниже.",
"yookassa_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"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_active_promos_list_header": "Активные промокоды:",
"admin_no_active_promos": "Нет активных промокодов.",
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
"admin_promo_valid_indefinitely": "бессрочно",
"admin_promo_valid_until_display": "до {date}",
"admin_promo_edit_button": "✏️ Изменить",
"admin_promo_delete_button": "🗑 Удалить",
"admin_promo_deleted_success": "Промокод {code} удален.",
"admin_promo_not_found": "Промокод не найден.",
"admin_promo_export_csv_button": "📄 Экспорт в CSV",
"admin_promo_export_caption": "📄 Активации промокода {code}",
"admin_promo_export_all_generating": "📄 Создаю CSV файл...",
"admin_promo_export_all_caption": "📄 Экспорт всех промокодов\n📊 Всего: {count} промокодов",
"admin_promo_csv_code": "Код",
"admin_promo_csv_bonus_days": "Бонусные дни",
"admin_promo_csv_max_activations": "Максимальные активации",
"admin_promo_csv_current_activations": "Текущие активации",
"admin_promo_csv_status": "Статус",
"admin_promo_csv_is_active": "Активен",
"admin_promo_csv_valid_until": "Действителен до",
"admin_promo_csv_created_at": "Создан",
"admin_promo_csv_created_by_admin_id": "Создал (Admin ID)",
"csv_yes": "Да",
"csv_no": "Нет",
"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> не найден в базе данных бота.",
"admin_cannot_ban_self_or_admin": "Вы не можете заблокировать себя или другого администратора.",
"admin_user_already_banned": "Пользователь {user_id_or_username} уже заблокирован.",
"admin_user_banned_success_combined": "✅ Пользователь {user_id_or_username} успешно заблокирован в боте {panel_status_part}.",
"admin_panel_ban_success_part": "и на панели",
"admin_panel_ban_fail_part": ", но не удалось заблокировать на панели",
"admin_user_ban_failed_local_db_error": "❌ Не удалось заблокировать пользователя в локальной БД.",
"admin_unban_user_prompt": "Введите ID или @username пользователя для разблокировки:",
"admin_user_not_banned": "Пользователь {user_id_or_username} не заблокирован.",
"admin_user_unbanned_success_combined": "✅ Пользователь {user_id_or_username} успешно разблокирован в боте {panel_status_part}.",
"admin_panel_unban_success_part": "и на панели",
"admin_panel_unban_fail_part": ", но не удалось разблокировать на панели",
"admin_user_unban_failed_local_db_error": "❌ Не удалось разблокировать пользователя в локальной БД.",
"admin_no_banned_users": "В данный момент нет заблокированных пользователей.",
"admin_banned_list_title": "Список забаненных (стр. {current_page}/{total_pages}):",
"admin_user_not_banned": "⚠️ Пользователь не заблокирован",
"admin_banned_user_button_text": "{user_display} (ID: {user_id})",
"prev_page_button": "⬅️ Пред.",
"next_page_button": "След. ➡️",
"admin_user_card_title": "Карточка пользователя: {user_display}",
"user_card_info": "ID: <code>{user_id}</code>\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: <code>{panel_user_uuid}</code>\nСтатус: <b>{ban_status}</b>\nРегистрация: {reg_date}\nПодписка до: <b>{sub_end_date}</b>",
"user_card_banned": "ЗАБЛОКИРОВАН",
"user_card_active": "Активен",
"user_card_sub_na": "н/д",
"admin_user_card_title": "Карточка пользователя",
"user_card_ban_button": "🚫 Заблокировать",
"user_card_unban_button": "✅ Разблокировать",
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
"admin_confirm_action_title": "Подтверждение: {action_text}",
"ban_verb_l": "блокировка",
"unban_verb_l": "разблокировка",
"admin_confirm_ban_prompt": "Вы уверены, что хотите заблокировать пользователя {user_display} (ID: {user_id})?",
"admin_confirm_unban_prompt": "Вы уверены, что хотите разблокировать пользователя {user_display} (ID: {user_id})?",
"admin_user_banned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) заблокирован.",
"admin_user_unbanned_from_card_alert": "Пользователь {user_display} (ID: {user_id}) разблокирован.",
"admin_user_ban_failed_db_error": "Ошибка блокировки пользователя в БД.",
"admin_user_unban_failed_db_error": "Ошибка разблокировки пользователя в БД.",
"admin_panel_status_update_fail_part": ", но статус на панели не обновлен",
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
"admin_logs_menu_title": "Меню логов:",
"admin_view_all_logs_button": "📜 Все логи сообщений",
"admin_view_user_logs_prompt_button": "👤 Логи пользователя",
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
"admin_export_logs_csv_button": "📄 Экспорт в CSV",
"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}",
"system_or_unknown_user": "Система/Неизв.",
"admin_prompt_for_user_id_or_username_logs": "Введите ID или @username пользователя для просмотра его логов:",
"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}",
"no_errors_placeholder": "нет",
"sync_started_simple": "🔄 Начинаю синхронизацию...",
"sync_success_simple": "✅ Синхронизация успешно завершена",
"sync_failed_simple": "❌ Синхронизация завершилась с ошибкой",
"sync_errors_simple": "⚠️ Синхронизация завершена с ошибками ({errors_count} ошибок)",
"sync_critical_error": "❌ Критическая ошибка синхронизации",
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
"admin_broadcast_invalid_html": "❌ Некорректный HTML в сообщении. Пожалуйста, отправьте корректный HTML (поддерживаются теги Telegram) или уберите теги.",
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
"error_displaying_statistics": "Ошибка отображения статистики.",
"stub_page_display": "Страница",
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
"error_unknown": "Произошла неизвестная ошибка."
"tribute_auto_renewal": "🔄 <b>Подписка автоматически продлена</b>\n\nВаша подписка Tribute была автоматически продлена на {months} мес.\nНовая дата окончания: {end_date}",
"admin_user_management_prompt": "👤 Управление пользователями\n\nВведите ID пользователя или @username для поиска:",
"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_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
"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_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
"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_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>{active_today}</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_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_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": "🖥 Системная статистика",
"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_logs_csv_export_started": "📄 Начинаю экспорт логов в CSV...",
"admin_logs_csv_export_success": "✅ Логи экспортированы! Файл прикреплен выше.",
"admin_user_logs_title": "Логи пользователя {user_display} (стр. {current_page}/{total_pages}):",
"admin_all_logs_title": "Все логи (стр. {current_page}/{total_pages}):",
"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_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_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
"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_none": "Нет активной подписки",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
"admin_user_subscription_error": "Ошибка загрузки",
"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_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_expired_label": "Истекшие",
"admin_panel_limited_label": "Ограниченные",
"admin_panel_total_users_label": "Всего пользователей",
"admin_panel_memory_usage_label": "Использование RAM",
"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_stats_fetch_error": "Ошибка получения данных с панели",
"admin_panel_stats_error_details": "Детали",
"inline_referral_title": "Реферальная ссылка",
"inline_referral_description": "Поделиться реферальной ссылкой для получения бонусов",
"inline_financial_description": "Сегодня: {today} RUB",
"inline_system_description": "🟢 Онлайн: {online}, 📊 Активных: {active}",
"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": "платежей",
"admin_sync_details": "📊 Статистика синхронизации:\n🔍 Проверено записей панели: {panel_records_checked}\n👥 Найдено пользователей в БД: {users_found_in_db}\n✨ Создано новых пользователей: {users_created}\n🔄 Пользователей обновлено: {users_updated}\n📋 Подписок синхронизировано: {subscriptions_synced_count}\n ├── Создано новых: {subscriptions_created}\n └── Обновлено существующих: {subscriptions_updated}{additional_stats}",
"admin_sync_no_telegram_id": "\n⚠️ Записей без telegramId: {count}",
"admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}",
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
"autorenew_enable_button": "🔄 Включить автопродление",
"autorenew_disable_button": "🛑 Отключить автопродление",
"subscription_autorenew_updated": "Настройки автопродления обновлены.",
"payment_methods_manage_button": "💳 Способы оплаты",
"payment_methods_title": "💳 <b>Способы оплаты</b>",
"payment_method_bind_button": "➕ Добавить способ",
"payment_method_delete_button": "🗑 Удалить",
"payment_method_view_button": "️ Детали",
"payment_method_none": "У вас пока нет сохранённого способа оплаты.",
"payment_method_bound_success": "✅ Способ оплаты добавлен.",
"payment_method_deleted_success": "✅ Способ оплаты удалён.",
"payment_method_delete_confirm": "Удалить сохранённый способ оплаты?",
"payment_method_card_title": "💳 {network} ••••{last4}",
"payment_method_generic_title": "💳 {network}",
"payment_method_wallet_title": "💼 Кошелёк YooMoney ••••{last4}",
"payment_network_card": "Карта",
"payment_network_generic": "Способ оплаты",
"payment_method_added_at": "Добавлена: {date}",
"payment_method_last_tx": "Последняя операция: {date}",
"payment_method_tx_history_title": "📜 История операций",
"payment_method_no_history": "История операций отсутствует.",
"subscription_purchase_title": "Покупка подписки на {months} мес.",
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
"subscription_not_active": "У вас нет активной подписки.",
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
"traffic_na": "N/A",
"payment_service_unavailable": "Платежный сервис недоступен.",
"payment_service_unavailable_alert": "Платежный сервис недоступен. Попробуйте позже.",
"error_creating_payment_record": "Ошибка создания записи платежа. Попробуйте позже.",
"error_payment_gateway_link_failed": "Ошибка создания платежной ссылки. Попробуйте позже.",
"status_active": "Активна",
"status_inactive": "Неактивна",
"admin_ads_section": "📈 Реклама",
"admin_ads_header": "📈 Рекламные кампании:",
"admin_ads_empty": "📭 Рекламные кампании отсутствуют. Нажмите \"Создать\" чтобы добавить новую.",
"admin_ads_item": "ID: {id}\nИсточник: <b>{source}</b>\nstart={start_param}\nСтоимость: {cost} RUB\nАктивна: {active}\n— Запустили: {starts}\n— Взяли триал: {trials}\n— Оплатили: {payers}\n— Доход: {revenue} RUB",
"admin_ads_create_button": "➕ Создать кампанию",
"admin_ads_create_source_prompt": "Введите источник (например: AEZA, VK, TG-канал):",
"admin_ads_create_start_param_prompt": "Введите параметр старт-ссылки (например: AEZA). Будет использован как start=AEZA",
"admin_ads_create_cost_prompt": "Введите сумму затрат на кампанию (в RUB):",
"admin_ads_invalid_source": "❌ Неверный источник. Введите до 64 символов.",
"admin_ads_invalid_start_param": "❌ Неверный параметр. Допустимы буквы/цифры/подчёркивания/дефисы (2-64).",
"admin_ads_invalid_cost": "❌ Неверная сумма. Введите неотрицательное число.",
"admin_ads_start_param_exists": "❌ Кампания с таким start-параметром уже существует.",
"admin_ads_created_success": "✅ Кампания создана!\nID: {id}\nИсточник: {source}\nПараметр: {start_param}\nЗатраты: {cost} RUB",
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
"back_to_ads_list_button": "⬅️ К списку",
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>",
"admin_ads_delete_button": "🗑 Удалить кампанию",
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
"admin_ads_deleted_success": "Кампания удалена.",
"admin_ads_not_found": "Кампания не найдена.",
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
}
+4 -4
View File
@@ -1,11 +1,11 @@
aiogram==3.20.0
aiogram==3.21.0
python-dotenv==1.0.1
aiohttp==3.9.5
aiohttp==3.12.14
pydantic==2.7.1
yookassa==3.5.0
pycountry==23.12.11
apscheduler==3.10.4
pydantic_settings
sqlalchemy[asyncio]==2.0.29
asyncpg==0.29.0
alembic==1.13.1
alembic==1.13.1
aiocryptopay==0.4.8