Initial commit
This commit is contained in:
+74
@@ -0,0 +1,74 @@
|
||||
# Telegram Bot Token and Admin IDs
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
ADMIN_IDS=comma_separated_admin_ids
|
||||
|
||||
# Database Configuration
|
||||
DB_NAME=bot_database.sqlite3
|
||||
|
||||
# Localization and Display
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# Telegram Webhook Configuration (Optional)
|
||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# Subscription Prices (integer values)
|
||||
PRICE_1_MONTH=150
|
||||
PRICE_3_MONTHS=300
|
||||
PRICE_6_MONTHS=500
|
||||
PRICE_12_MONTHS=900
|
||||
|
||||
# Subscription Expiration Notifications
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0
|
||||
|
||||
# Referral Bonus Days
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS=30
|
||||
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
|
||||
|
||||
# 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"
|
||||
|
||||
# Default Inbounds for Panel Users (Optional, comma-separated UUIDs)
|
||||
PANEL_USER_DEFAULT_INBOUND_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"
|
||||
|
||||
# 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
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Игнорировать базу данных
|
||||
bot_database.sqlite3
|
||||
|
||||
# Игнорировать файлы окружения
|
||||
.env
|
||||
|
||||
# Игнорировать кэш Python
|
||||
__pycache__/
|
||||
|
||||
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Игнорировать Docker артефакты (если вдруг)
|
||||
*.log
|
||||
*.pid
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
@@ -0,0 +1,220 @@
|
||||
|
||||
# Remnawave Subscription Sales Telegram Bot
|
||||
|
||||
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
|
||||
|
||||
* **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.
|
||||
|
||||
## 🚀 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
|
||||
|
||||
## ⚙️ 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.
|
||||
|
||||
### Configuration Steps
|
||||
|
||||
1. **Clone the Repository (if applicable):**
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd <your-repo-directory>
|
||||
```
|
||||
|
||||
2. **Create an `.env` File:**
|
||||
Copy the `env.example` file to `.env` and fill in your specific values:
|
||||
```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.
|
||||
|
||||
4. **Build and Run with Docker Compose:**
|
||||
```bash
|
||||
docker compose up --build -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`.
|
||||
|
||||
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.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
```bash
|
||||
docker compose logs -f vpn-shop
|
||||
```
|
||||
|
||||
## 🐳 Docker Setup
|
||||
|
||||
### `Dockerfile`
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
# Consider adding build arguments for proxy if needed in your environment
|
||||
# ARG HTTP_PROXY
|
||||
# ARG HTTPS_PROXY
|
||||
# ENV http_proxy=$HTTP_PROXY
|
||||
# ENV https_proxy=$HTTPS_PROXY
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure main.py is executable if needed, though python command handles it
|
||||
# RUN chmod +x main.py
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
|
||||
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
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings model
|
||||
├── 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
|
||||
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome!
|
||||
|
||||
## 🔮 Future Enhancements (Example Section)
|
||||
|
||||
- More detailed analytics for admin.
|
||||
- Support for different payment methods.
|
||||
- Advanced promo code types (e.g., percentage discounts).
|
||||
@@ -0,0 +1,14 @@
|
||||
from typing import List ,Union
|
||||
from aiogram .filters import Filter
|
||||
from aiogram .types import Message ,CallbackQuery ,User
|
||||
|
||||
class AdminFilter (Filter ):
|
||||
def __init__ (self ,admin_ids :List [int ]):
|
||||
self .admin_ids =admin_ids
|
||||
|
||||
async def __call__ (self ,event :Union [Message ,CallbackQuery ],event_from_user :User )->bool :
|
||||
if not event_from_user :
|
||||
return False
|
||||
if not self .admin_ids :
|
||||
return False
|
||||
return event_from_user .id in self .admin_ids
|
||||
@@ -0,0 +1,23 @@
|
||||
from aiogram import Router
|
||||
|
||||
|
||||
from .common import router as common_admin_router
|
||||
from .promo_codes import router as promo_codes_admin_router
|
||||
from .user_management import router as user_management_admin_router
|
||||
from .broadcast import router as broadcast_admin_router
|
||||
from .statistics import router as statistics_admin_router
|
||||
from .sync_admin import router as sync_admin_router
|
||||
from .logs_admin import router as logs_admin_router
|
||||
|
||||
|
||||
admin_router_aggregate =Router (name ="admin_router_aggregate")
|
||||
|
||||
|
||||
admin_router_aggregate .include_router (common_admin_router )
|
||||
admin_router_aggregate .include_router (promo_codes_admin_router )
|
||||
admin_router_aggregate .include_router (user_management_admin_router )
|
||||
admin_router_aggregate .include_router (broadcast_admin_router )
|
||||
admin_router_aggregate .include_router (statistics_admin_router )
|
||||
admin_router_aggregate .include_router (sync_admin_router )
|
||||
admin_router_aggregate .include_router (logs_admin_router )
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
from db .database import get_all_users_for_broadcast ,log_user_action
|
||||
from bot .states .admin_states import AdminStates
|
||||
from bot .keyboards .inline .admin_keyboards import get_broadcast_confirmation_keyboard ,get_back_to_admin_panel_keyboard
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
router =Router (name ="admin_broadcast_router")
|
||||
|
||||
async def broadcast_message_prompt_handler (
|
||||
callback :types .CallbackQuery ,
|
||||
state :FSMContext ,
|
||||
i18n_data :dict ,
|
||||
settings :Settings
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in broadcast_message_prompt_handler")
|
||||
await callback .answer ("Language service error.",show_alert =True )
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
prompt_text =_ ("admin_broadcast_enter_message")
|
||||
|
||||
if callback .message :
|
||||
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 broadcast prompt: {e}")
|
||||
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_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
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in process_broadcast_message_handler")
|
||||
await message .reply ("Language service error.")
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
broadcast_message_text =message .html_text
|
||||
await state .update_data (broadcast_message =broadcast_message_text )
|
||||
|
||||
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 )
|
||||
|
||||
await message .answer (
|
||||
confirmation_prompt ,
|
||||
reply_markup =get_broadcast_confirmation_keyboard (current_lang ,i18n ),
|
||||
parse_mode ="HTML"
|
||||
)
|
||||
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 ):
|
||||
from .common import admin_panel_actions_callback_handler
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
if callback .message :
|
||||
try :await callback .message .edit_text (_ ("admin_broadcast_cancelled"),reply_markup =None )
|
||||
except Exception :await callback .message .answer (_ ("admin_broadcast_cancelled"))
|
||||
await callback .answer (_ ("admin_broadcast_cancelled"))
|
||||
await state .clear ()
|
||||
|
||||
|
||||
callback .data ="admin_action:main"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from bot .keyboards .inline .admin_keyboards import get_admin_panel_keyboard
|
||||
if callback .message :
|
||||
await callback .message .answer (_ ("admin_panel_title"),reply_markup =get_admin_panel_keyboard (i18n ,current_lang ))
|
||||
|
||||
|
||||
@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
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",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 =user_fsm_data .get ("broadcast_message")
|
||||
|
||||
if not callback .message :
|
||||
await callback .answer ("Error: message context lost.",show_alert =True )
|
||||
await state .clear ();return
|
||||
|
||||
if action =="send":
|
||||
if not broadcast_message :
|
||||
await callback .message .edit_text (_ ("admin_broadcast_error_no_message"))
|
||||
await state .clear ();await callback .answer (show_alert =True );return
|
||||
|
||||
await callback .message .edit_text (_ ("admin_broadcast_sending_started"),reply_markup =None )
|
||||
await callback .answer ()
|
||||
|
||||
users_to_broadcast =await get_all_users_for_broadcast ()
|
||||
sent_count =0 ;failed_count =0
|
||||
logging .info (f"Starting broadcast: '{broadcast_message[:50]}...' to {len(users_to_broadcast)} users.")
|
||||
|
||||
admin_user =callback .from_user
|
||||
|
||||
for user_row in users_to_broadcast :
|
||||
user_id =user_row ['user_id']
|
||||
try :
|
||||
await bot .send_message (user_id ,broadcast_message ,parse_mode ="HTML")
|
||||
sent_count +=1
|
||||
|
||||
await log_user_action (
|
||||
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}: {broadcast_message[:70]}...",
|
||||
is_admin_event =True ,
|
||||
target_user_id =user_id
|
||||
)
|
||||
except Exception as e :
|
||||
failed_count +=1
|
||||
logging .warning (f"Failed to send broadcast to user {user_id}: {type(e).__name__} - {e}")
|
||||
|
||||
await log_user_action (
|
||||
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}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
is_admin_event =True ,
|
||||
target_user_id =user_id
|
||||
)
|
||||
await asyncio .sleep (0.1 )
|
||||
|
||||
result_message =_ ("admin_broadcast_finished_stats",sent_count =sent_count ,failed_count =failed_count )
|
||||
await callback .message .answer (result_message ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ))
|
||||
|
||||
elif action =="cancel":
|
||||
await callback .message .edit_text (_ ("admin_broadcast_cancelled"),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ))
|
||||
await callback .answer ()
|
||||
|
||||
await state .clear ()
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional
|
||||
|
||||
from config .settings import Settings
|
||||
from bot .keyboards .inline .admin_keyboards import get_admin_panel_keyboard
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from bot .services .panel_api_service import PanelApiService
|
||||
|
||||
|
||||
from .import broadcast as admin_broadcast_handlers
|
||||
from .import promo_codes as admin_promo_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
|
||||
from .import logs_admin as admin_logs_handlers
|
||||
|
||||
router =Router (name ="admin_common_router")
|
||||
|
||||
@router .message (Command ("admin"))
|
||||
async def admin_panel_command_handler (message :types .Message ,settings :Settings ,i18n_data :dict ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in admin_panel_command_handler")
|
||||
await message .answer ("Language service error.")
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
await message .answer (
|
||||
_ (key ="admin_panel_title"),
|
||||
reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings )
|
||||
)
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_action:"))
|
||||
async def admin_panel_actions_callback_handler (
|
||||
callback :types .CallbackQuery ,
|
||||
state :FSMContext ,
|
||||
settings :Settings ,
|
||||
i18n_data :dict ,
|
||||
bot :Bot ,
|
||||
panel_service :PanelApiService
|
||||
):
|
||||
action_parts =callback .data .split (":")
|
||||
action =action_parts [1 ]
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in admin_panel_actions_callback_handler")
|
||||
await callback .answer ("Language error.",show_alert =True )
|
||||
return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
if not callback .message :
|
||||
logging .error (f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for action {action}")
|
||||
await callback .answer ("Error processing action: message context lost.",show_alert =True )
|
||||
return
|
||||
|
||||
if action =="stats":
|
||||
await admin_stats_handlers .show_statistics_handler (callback ,i18n_data ,settings )
|
||||
elif action =="broadcast":
|
||||
await admin_broadcast_handlers .broadcast_message_prompt_handler (callback ,state ,i18n_data ,settings )
|
||||
elif action =="create_promo":
|
||||
await admin_promo_handlers .create_promo_prompt_handler (callback ,state ,i18n_data ,settings )
|
||||
elif action =="view_promos":
|
||||
await admin_promo_handlers .view_promo_codes_handler (callback ,i18n_data ,settings )
|
||||
elif action =="ban_user_prompt":
|
||||
await admin_user_mgmnt_handlers .ban_user_prompt_handler (callback ,state ,i18n_data ,settings )
|
||||
elif action =="unban_user_prompt":
|
||||
await admin_user_mgmnt_handlers .unban_user_prompt_handler (callback ,state ,i18n_data ,settings )
|
||||
elif action =="view_banned":
|
||||
|
||||
await admin_user_mgmnt_handlers .view_banned_users_handler (callback ,i18n_data ,settings ,state )
|
||||
elif action =="view_logs_menu":
|
||||
await admin_logs_handlers .display_logs_menu (callback ,i18n_data ,settings )
|
||||
elif action =="sync_panel":
|
||||
await admin_sync_handlers .sync_command_handler (callback .message ,bot =bot ,settings =settings ,i18n_data =i18n_data ,panel_service =panel_service )
|
||||
await callback .answer (_ ("admin_sync_initiated_from_panel"))
|
||||
elif action =="main":
|
||||
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 ()
|
||||
else :
|
||||
logging .warning (f"Unknown admin_action received: {action} from callback {callback.data}")
|
||||
await callback .answer (_ ("admin_unknown_action"),show_alert =True )
|
||||
@@ -0,0 +1,233 @@
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional ,List ,Dict ,Any
|
||||
import aiosqlite
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import (
|
||||
get_all_message_logs_paginated ,count_all_message_logs ,
|
||||
get_user_message_logs_paginated ,count_user_message_logs ,
|
||||
get_user ,get_user_by_telegram_username
|
||||
)
|
||||
from bot .states .admin_states import AdminStates
|
||||
from bot .keyboards .inline .admin_keyboards import (
|
||||
get_logs_menu_keyboard ,
|
||||
get_logs_pagination_keyboard ,
|
||||
get_back_to_admin_panel_keyboard ,
|
||||
get_admin_panel_keyboard
|
||||
)
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
router =Router (name ="admin_logs_router")
|
||||
USERNAME_REGEX =re .compile (r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
async def display_logs_menu (callback :types .CallbackQuery ,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 :
|
||||
logging .error ("i18n_instance missing in display_logs_menu")
|
||||
await callback .answer ("Language service error.",show_alert =True )
|
||||
return
|
||||
if not callback .message :
|
||||
logging .error ("CallbackQuery has no message in display_logs_menu")
|
||||
await callback .answer ("Error processing request.",show_alert =True )
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
try :
|
||||
await callback .message .edit_text (
|
||||
text =_ (key ="admin_logs_menu_title"),
|
||||
reply_markup =get_logs_menu_keyboard (i18n ,current_lang )
|
||||
)
|
||||
except Exception as e :
|
||||
logging .warning (f"Failed to edit message for logs menu: {e}")
|
||||
await callback .message .answer (
|
||||
text =_ (key ="admin_logs_menu_title"),
|
||||
reply_markup =get_logs_menu_keyboard (i18n ,current_lang )
|
||||
)
|
||||
await callback .answer ()
|
||||
|
||||
async def _display_formatted_logs (
|
||||
target_message :types .Message ,
|
||||
logs :List [aiosqlite .Row ],
|
||||
total_logs :int ,
|
||||
current_page :int ,
|
||||
settings :Settings ,
|
||||
title_key :str ,
|
||||
base_pagination_callback_data :str ,
|
||||
i18n :JsonI18n ,
|
||||
current_lang :str ,
|
||||
title_kwargs :Optional [Dict [str ,Any ]]=None
|
||||
):
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
page_size =settings .LOGS_PAGE_SIZE
|
||||
|
||||
actual_title_kwargs =title_kwargs or {}
|
||||
|
||||
if not logs and total_logs ==0 :
|
||||
text =_ (title_key ,current_page =1 ,total_pages =1 ,**actual_title_kwargs )+"\n\n"+_ ("admin_no_logs_found")
|
||||
reply_markup =get_logs_pagination_keyboard (current_page ,1 ,base_pagination_callback_data ,i18n ,current_lang ,back_to_logs_menu =True )
|
||||
else :
|
||||
total_pages =math .ceil (total_logs /page_size )if page_size >0 else 1
|
||||
text =_ (title_key ,current_page =current_page +1 ,total_pages =max (1 ,total_pages ),**actual_title_kwargs )+"\n"
|
||||
|
||||
log_entries_text =[]
|
||||
for log_entry in logs :
|
||||
user_display_parts =[]
|
||||
|
||||
telegram_first_name =log_entry ['telegram_first_name']if 'telegram_first_name'in log_entry .keys ()and log_entry ['telegram_first_name']else None
|
||||
telegram_username =log_entry ['telegram_username']if 'telegram_username'in log_entry .keys ()and log_entry ['telegram_username']else None
|
||||
user_id_from_log =log_entry ['user_id']if 'user_id'in log_entry .keys ()and log_entry ['user_id']else None
|
||||
|
||||
if telegram_first_name :user_display_parts .append (telegram_first_name )
|
||||
if telegram_username :user_display_parts .append (f"(@{telegram_username})")
|
||||
|
||||
user_display =" ".join (user_display_parts ).strip ()
|
||||
if not user_display :
|
||||
user_display =_ ("system_or_unknown_user")if not user_id_from_log else f"ID: {user_id_from_log}"
|
||||
|
||||
user_id_display =str (user_id_from_log )if user_id_from_log is not None else "N/A"
|
||||
content_raw =log_entry ['content']if 'content'in log_entry .keys ()and log_entry ['content']else ""
|
||||
content_preview =(content_raw [:100 ]+"...")if len (content_raw )>100 else (content_raw or "N/A")
|
||||
|
||||
log_entries_text .append (
|
||||
_ ("admin_log_entry_format",
|
||||
timestamp_str =log_entry ['timestamp_str']if 'timestamp_str'in log_entry .keys ()else 'N/A',
|
||||
user_display =user_display ,
|
||||
user_id =user_id_display ,
|
||||
event_type =log_entry ['event_type']if 'event_type'in log_entry .keys ()else 'N/A',
|
||||
content_preview =content_preview
|
||||
).replace ("\n","\n ")
|
||||
)
|
||||
text +="\n\n".join (log_entries_text )
|
||||
reply_markup =get_logs_pagination_keyboard (current_page ,total_pages ,base_pagination_callback_data ,i18n ,current_lang ,back_to_logs_menu =True )
|
||||
|
||||
try :
|
||||
await target_message .edit_text (text ,reply_markup =reply_markup ,parse_mode ="HTML",disable_web_page_preview =True )
|
||||
except Exception as e :
|
||||
logging .warning (f"Failed to edit message for logs display: {e}. Content length: {len(text)}")
|
||||
chunk_size =4000
|
||||
for i in range (0 ,len (text ),chunk_size ):
|
||||
chunk =text [i :i +chunk_size ];is_last_chunk =(i +chunk_size )>=len (text )
|
||||
await target_message .answer (chunk ,reply_markup =reply_markup if is_last_chunk else None ,parse_mode ="HTML",disable_web_page_preview =True )
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_logs:view_all"))
|
||||
async def view_all_logs_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ):
|
||||
page =0
|
||||
parts =callback .data .split (":")
|
||||
|
||||
if len (parts )==3 :
|
||||
try :
|
||||
page =int (parts [2 ])
|
||||
except ValueError :
|
||||
page =0
|
||||
|
||||
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 request.",show_alert =True )
|
||||
return
|
||||
|
||||
logs ,total_logs =await get_all_message_logs_paginated (settings .LOGS_PAGE_SIZE ,page *settings .LOGS_PAGE_SIZE ),await count_all_message_logs ()
|
||||
|
||||
await _display_formatted_logs (
|
||||
target_message =callback .message ,logs =logs ,total_logs =total_logs ,
|
||||
current_page =page ,settings =settings ,
|
||||
title_key ="admin_all_logs_title",base_pagination_callback_data ="admin_logs:view_all",
|
||||
i18n =i18n ,current_lang =current_lang
|
||||
)
|
||||
await callback .answer ()
|
||||
|
||||
@router .callback_query (F .data =="admin_logs:prompt_user")
|
||||
async def prompt_user_for_logs_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ):
|
||||
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");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
await callback .message .edit_text (
|
||||
text =_ ("admin_prompt_for_user_id_or_username_logs"),
|
||||
reply_markup =get_logs_menu_keyboard (i18n ,current_lang )
|
||||
)
|
||||
await state .set_state (AdminStates .waiting_for_user_id_for_logs )
|
||||
await callback .answer ()
|
||||
|
||||
@router .message (AdminStates .waiting_for_user_id_for_logs ,F .text )
|
||||
async def process_user_id_for_logs_handler (message :types .Message ,state :FSMContext ,settings :Settings ,i18n_data :dict ):
|
||||
current_state_fsm =await state .get_state ()
|
||||
logging .info (f"Processing user input for logs in state {current_state_fsm}: '{message.text}'")
|
||||
await state .clear ()
|
||||
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE )
|
||||
if not i18n :await message .reply ("Language error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
input_text =message .text .strip ()
|
||||
user_data_for_logs :Optional [aiosqlite .Row ]=None
|
||||
|
||||
if input_text .isdigit ():
|
||||
try :user_data_for_logs =await get_user (int (input_text ))
|
||||
except ValueError :pass
|
||||
elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]):
|
||||
user_data_for_logs =await get_user_by_telegram_username (input_text [1 :])
|
||||
elif USERNAME_REGEX .match (input_text ):
|
||||
user_data_for_logs =await get_user_by_telegram_username (input_text )
|
||||
|
||||
if not user_data_for_logs :
|
||||
await message .answer (_ ("admin_log_user_not_found",input =input_text ))
|
||||
return
|
||||
|
||||
target_user_id =user_data_for_logs ['user_id']
|
||||
user_display =user_data_for_logs ['first_name']or (f"@{user_data_for_logs['username']}"if user_data_for_logs .get ('username')else f"ID {target_user_id}")
|
||||
|
||||
logs ,total_logs =await get_user_message_logs_paginated (target_user_id ,settings .LOGS_PAGE_SIZE ,0 ),await count_user_message_logs (target_user_id )
|
||||
|
||||
await _display_formatted_logs (
|
||||
target_message =message ,logs =logs ,total_logs =total_logs ,
|
||||
current_page =0 ,settings =settings ,
|
||||
title_key ="admin_user_logs_title",
|
||||
base_pagination_callback_data =f"admin_logs:view_user:{target_user_id}",
|
||||
i18n =i18n ,current_lang =current_lang ,
|
||||
title_kwargs ={"user_display":user_display }
|
||||
)
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_logs:view_user:"))
|
||||
async def view_user_logs_paginated_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ):
|
||||
try :
|
||||
parts =callback .data .split (":")
|
||||
target_user_id =int (parts [2 ])
|
||||
page =int (parts [3 ])
|
||||
except (IndexError ,ValueError ):
|
||||
await callback .answer ("Invalid log request.",show_alert =True );return
|
||||
|
||||
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");return
|
||||
|
||||
user_data_for_logs =await get_user (target_user_id )
|
||||
if not user_data_for_logs :
|
||||
await callback .message .edit_text ("User not found for logs.");await callback .answer ();return
|
||||
user_display =user_data_for_logs ['first_name']or (f"@{user_data_for_logs['username']}"if user_data_for_logs .get ('username')else f"ID {target_user_id}")
|
||||
|
||||
logs ,total_logs =await get_user_message_logs_paginated (target_user_id ,settings .LOGS_PAGE_SIZE ,page *settings .LOGS_PAGE_SIZE ),await count_user_message_logs (target_user_id )
|
||||
|
||||
await _display_formatted_logs (
|
||||
target_message =callback .message ,logs =logs ,total_logs =total_logs ,
|
||||
current_page =page ,settings =settings ,
|
||||
title_key ="admin_user_logs_title",
|
||||
base_pagination_callback_data =f"admin_logs:view_user:{target_user_id}",
|
||||
i18n =i18n ,current_lang =current_lang ,
|
||||
title_kwargs ={"user_display":user_display }
|
||||
)
|
||||
await callback .answer ()
|
||||
|
||||
@router .callback_query (F .data =="admin_action:view_logs_menu",AdminStates .waiting_for_user_id_for_logs )
|
||||
async def cancel_log_user_input_state_to_menu (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ):
|
||||
await state .clear ()
|
||||
await display_logs_menu (callback ,i18n_data ,settings )
|
||||
@@ -0,0 +1,157 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
from typing import Optional
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import create_promo_code_db ,get_promo_codes_db
|
||||
from bot .states .admin_states import AdminStates
|
||||
from bot .keyboards .inline .admin_keyboards import get_back_to_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
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in create_promo_prompt_handler")
|
||||
await callback .answer ("Language service error.",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")
|
||||
|
||||
if callback .message :
|
||||
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}")
|
||||
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
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in process_promo_code_details_handler")
|
||||
await message .reply ("Language service error.")
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
parts =message .text .strip ().split ()
|
||||
if not (3 <=len (parts )<=4 ):
|
||||
await message .answer (_ ("admin_promo_invalid_format"))
|
||||
return
|
||||
|
||||
try :
|
||||
code =parts [0 ].upper ()
|
||||
if not (3 <=len (code )<=30 and code .isalnum ()):
|
||||
raise ValueError ("Promo code must be 3-30 alphanumeric characters.")
|
||||
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 ("Validity days (if provided) must be positive.")
|
||||
|
||||
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 ("Bonus days and max activations must be positive.")
|
||||
except ValueError as e :
|
||||
await message .answer (_ ("admin_promo_invalid_values",error =str (e )))
|
||||
return
|
||||
|
||||
admin_id =message .from_user .id
|
||||
|
||||
promo_id =await create_promo_code_db (code ,bonus_days ,max_activations ,admin_id ,valid_until_date )
|
||||
|
||||
if promo_id :
|
||||
success_text =_ ("admin_promo_created_success",code =code ,bonus_days =bonus_days ,max_activations =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"))
|
||||
await state .clear ()
|
||||
|
||||
async def view_promo_codes_handler (
|
||||
callback :types .CallbackQuery ,
|
||||
i18n_data :dict ,
|
||||
settings :Settings
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in view_promo_codes_handler")
|
||||
await callback .answer ("Language error.",show_alert =True )
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
promos =await get_promo_codes_db (is_active_only =True ,limit =20 )
|
||||
|
||||
if not callback .message :
|
||||
await callback .answer ("Error: message context lost.",show_alert =True );return
|
||||
|
||||
if not promos :
|
||||
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 promos :
|
||||
valid_until_display_text =_ ("admin_promo_valid_indefinitely")
|
||||
if promo ['valid_until']:
|
||||
try :
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
valid_until_dt :Optional [datetime ]=None
|
||||
if 'T'in promo ['valid_until']:
|
||||
valid_until_dt =datetime .fromisoformat (promo ['valid_until'].replace ("Z","+00:00"))
|
||||
else :
|
||||
valid_until_dt =datetime .strptime (promo ['valid_until'],'%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
if valid_until_dt and valid_until_dt .tzinfo is None :
|
||||
valid_until_dt =valid_until_dt .replace (tzinfo =timezone .utc )
|
||||
|
||||
valid_until_display_text =valid_until_dt .strftime ('%Y-%m-%d')
|
||||
except ValueError as e :
|
||||
logging .warning (f"Could not parse valid_until date string '{promo['valid_until']}' for promo code {promo['code']}: {e}")
|
||||
valid_until_display_text =promo ['valid_until']
|
||||
|
||||
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}")
|
||||
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 ()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types
|
||||
from typing import Optional ,Dict
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_user_count_stats ,get_payment_logs ,get_message_logs_db ,get_last_sync_status
|
||||
from bot .keyboards .inline .admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
router =Router (name ="admin_statistics_router")
|
||||
|
||||
|
||||
async def show_statistics_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in show_statistics_handler")
|
||||
await callback .answer ("Language service error.",show_alert =True )
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
await callback .answer ()
|
||||
|
||||
stats_text_parts =[f"<b>{_('admin_stats_header')}</b>"]
|
||||
|
||||
|
||||
user_stats =await get_user_count_stats ()
|
||||
stats_text_parts .append (
|
||||
_ ("admin_stats_users",
|
||||
total_users =user_stats .get ("total_users",0 ),
|
||||
banned_users =user_stats .get ("banned_users",0 ),
|
||||
active_subs =user_stats .get ("users_with_active_subscriptions",0 )
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
last_payments =await get_payment_logs (limit =5 )
|
||||
if last_payments :
|
||||
stats_text_parts .append (f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments :
|
||||
status_emoji ="✅"if payment ['status']=='succeeded'else ("⏳"if payment ['status']=='pending'else "❌")
|
||||
user_info =f"User {payment['user_id']}"+(f" (@{payment['username']})"if payment ['username']else "")
|
||||
payment_date_str =payment ['created_at']
|
||||
if isinstance (payment_date_str ,str )and len (payment_date_str )>10 :
|
||||
payment_date_str =payment_date_str [:10 ]
|
||||
|
||||
stats_text_parts .append (
|
||||
_ ("admin_stats_payment_item",
|
||||
status_emoji =status_emoji ,amount =payment ['amount'],currency =payment ['currency'],
|
||||
user_info =user_info ,p_status =payment ['status'],p_date =payment_date_str
|
||||
)
|
||||
)
|
||||
else :
|
||||
stats_text_parts .append (f"\n{_('admin_stats_no_payments_found')}")
|
||||
|
||||
|
||||
sync_status =await get_last_sync_status ()
|
||||
if sync_status :
|
||||
stats_text_parts .append (f"\n<b>{_('admin_stats_last_sync_header')}</b>")
|
||||
|
||||
sync_time_val =sync_status ['last_sync_time']
|
||||
sync_time_str ="N/A"
|
||||
if isinstance (sync_time_val ,datetime ):
|
||||
sync_time_str =sync_time_val .strftime ('%Y-%m-%d %H:%M:%S UTC')
|
||||
elif isinstance (sync_time_val ,str ):
|
||||
sync_time_str =sync_time_val [:19 ]if len (sync_time_val )>19 else sync_time_val
|
||||
|
||||
details_val =sync_status ['details']
|
||||
details_str =(details_val [:100 ]+"...")if details_val and len (details_val )>100 else (details_val or "N/A")
|
||||
|
||||
|
||||
stats_text_parts .append (f" {_('admin_stats_sync_time')}: {sync_time_str}")
|
||||
stats_text_parts .append (f" {_('admin_stats_sync_status')}: {sync_status['status']}")
|
||||
stats_text_parts .append (f" {_('admin_stats_sync_users_processed')}: {sync_status['users_processed_from_panel']}")
|
||||
stats_text_parts .append (f" {_('admin_stats_sync_subs_synced')}: {sync_status['subscriptions_synced']}")
|
||||
stats_text_parts .append (f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
else :
|
||||
stats_text_parts .append (f"\n{_('admin_sync_status_never_run')}")
|
||||
|
||||
final_text ="\n".join (stats_text_parts )
|
||||
|
||||
if callback .message :
|
||||
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 .error (f"Error editing message for statistics: {e}",exc_info =True )
|
||||
|
||||
for chunk in [final_text [i :i +4000 ]for i in range (0 ,len (final_text ),4000 )]:
|
||||
await callback .message .answer (
|
||||
chunk ,
|
||||
reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n )if chunk ==final_text [-len (chunk ):]else None ,
|
||||
parse_mode ="HTML"
|
||||
)
|
||||
else :
|
||||
logging .error ("Cannot send statistics, callback.message is None.")
|
||||
@@ -0,0 +1,126 @@
|
||||
import logging
|
||||
from aiogram import Router ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from typing import Optional
|
||||
|
||||
from config .settings import Settings
|
||||
from bot .services .panel_api_service import PanelApiService
|
||||
|
||||
from db .database import update_sync_status ,get_last_sync_status ,sync_panel_user_data
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from bot .keyboards .inline .admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
|
||||
router =Router (name ="admin_sync_router")
|
||||
|
||||
@router .message (Command ("sync"))
|
||||
async def sync_command_handler (
|
||||
message :types .Message ,
|
||||
bot :Bot ,
|
||||
settings :Settings ,
|
||||
i18n_data :dict ,
|
||||
panel_service :PanelApiService
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await message .answer ("Language error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
await message .answer (_ ("sync_started"))
|
||||
logging .info ("Admin triggered panel sync.")
|
||||
|
||||
users_processed_count =0
|
||||
users_synced_successfully =0
|
||||
sync_errors =[]
|
||||
|
||||
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 update_sync_status ("failed",error_msg )
|
||||
await message .answer (_ ("sync_failed",details =error_msg ))
|
||||
return
|
||||
|
||||
if not panel_users_data :
|
||||
status_msg ="No users found in the panel to sync."
|
||||
await update_sync_status ("success",status_msg ,0 ,0 )
|
||||
await message .answer (_ ("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
|
||||
telegram_id_from_panel =panel_user_dict .get ('telegramId')
|
||||
panel_uuid =panel_user_dict .get ('uuid')
|
||||
|
||||
if not telegram_id_from_panel :
|
||||
logging .info (f"Panel user {panel_uuid} (username: {panel_user_dict.get('username')}) has no 'telegramId'. Skipping TG ID based sync.")
|
||||
|
||||
|
||||
|
||||
continue
|
||||
|
||||
if not panel_uuid :
|
||||
logging .warning (f"Panel user (TG ID: {telegram_id_from_panel}) missing 'uuid'. Skipping.")
|
||||
sync_errors .append (f"Panel user data for TG ID {telegram_id_from_panel} missing UUID.")
|
||||
continue
|
||||
|
||||
|
||||
if await sync_panel_user_data (panel_user_dict ):
|
||||
users_synced_successfully +=1
|
||||
else :
|
||||
sync_errors .append (f"Sync issue for panel user: {panel_uuid} (TG ID: {telegram_id_from_panel})")
|
||||
|
||||
if users_processed_count %20 ==0 :
|
||||
logging .info (f"Sync progress: {users_processed_count}/{total_panel_users} users processed.")
|
||||
|
||||
status_msg =f"Panel users checked: {total_panel_users}. Users/Subscriptions synced via TG ID: {users_synced_successfully}."
|
||||
if sync_errors :
|
||||
status_msg +=f" Errors encountered: {len(sync_errors)}. See logs for details."
|
||||
error_preview ="\n".join (sync_errors [:3 ])
|
||||
await update_sync_status ("partial_success",status_msg +" "+error_preview ,total_panel_users ,users_synced_successfully )
|
||||
await message .answer (_ ("sync_completed",status ="Partial Success",details =status_msg ))
|
||||
else :
|
||||
await update_sync_status ("success",status_msg ,total_panel_users ,users_synced_successfully )
|
||||
await message .answer (_ ("sync_completed",status ="Success",details =status_msg ))
|
||||
|
||||
except Exception as e :
|
||||
logging .error (f"Error during /sync command: {e}",exc_info =True )
|
||||
error_detail =f"An unexpected error occurred during sync: {str(e)}"
|
||||
await update_sync_status ("failed",error_detail ,users_processed_count ,users_synced_successfully )
|
||||
await message .answer (_ ("sync_failed",details =error_detail ))
|
||||
|
||||
@router .message (Command ("syncstatus"))
|
||||
async def sync_status_command_handler (message :types .Message ,i18n_data :dict ,settings :Settings ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :await message .answer ("Language error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );status_record =await get_last_sync_status ();response_text =""
|
||||
if status_record :
|
||||
last_time_val =status_record ['last_sync_time'];last_time_str =last_time_val .strftime ('%Y-%m-%d %H:%M:%S UTC')if isinstance (last_time_val ,datetime )else str (last_time_val )
|
||||
if isinstance (last_time_str ,str )and len (last_time_str )>19 :last_time_str =last_time_str [:19 ]
|
||||
details_val =status_record ['details'];details_str =(details_val [:200 ]+"...")if details_val and len (details_val )>200 else (details_val or "N/A")
|
||||
response_text =(f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
f" {_('admin_stats_sync_time')}: {last_time_str}\n"
|
||||
f" {_('admin_stats_sync_status')}: {status_record['status']}\n"
|
||||
f" {_('admin_stats_sync_users_processed')}: {status_record['users_processed_from_panel']}\n"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {status_record['subscriptions_synced']}\n"
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
else :response_text =_ ("admin_sync_status_never_run")
|
||||
await message .answer (response_text ,parse_mode ="HTML")
|
||||
@@ -0,0 +1,220 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional ,Tuple ,List ,Any
|
||||
import math
|
||||
import aiosqlite
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import (
|
||||
get_user ,set_user_ban_status_db ,get_user_by_telegram_username ,
|
||||
get_banned_users_list_paginated ,get_user_active_subscription_end_date
|
||||
)
|
||||
from bot .services .panel_api_service import PanelApiService
|
||||
from bot .states .admin_states import AdminStates
|
||||
from bot .keyboards .inline .admin_keyboards import (
|
||||
get_back_to_admin_panel_keyboard ,get_user_card_keyboard ,
|
||||
get_banned_users_keyboard ,get_confirmation_keyboard ,get_admin_panel_keyboard
|
||||
)
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
router =Router (name ="admin_user_management_router")
|
||||
|
||||
USERNAME_REGEX =re .compile (r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
|
||||
async def ban_user_prompt_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );prompt_text =_ ("admin_ban_user_prompt")
|
||||
if callback .message :
|
||||
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"Edit failed: {e}");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_user_id_to_ban )
|
||||
|
||||
|
||||
@router .message (AdminStates .waiting_for_user_id_to_ban ,F .text )
|
||||
async def process_user_input_to_ban_handler (message :types .Message ,state :FSMContext ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await message .reply ("Language error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );input_text =message .text .strip ();user_to_ban_data :Optional [aiosqlite .Row ]=None ;user_id_or_username_for_msg =input_text
|
||||
if input_text .isdigit ():
|
||||
try :user_id_to_ban_val =int (input_text );user_to_ban_data =await get_user (user_id_to_ban_val )
|
||||
except ValueError :await message .answer (_ ("admin_invalid_user_id_format"));return
|
||||
elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]):user_to_ban_data =await get_user_by_telegram_username (input_text [1 :])
|
||||
elif USERNAME_REGEX .match (input_text ):user_to_ban_data =await get_user_by_telegram_username (input_text )
|
||||
else :await message .answer (_ ("admin_invalid_user_id_format")+" "+_ ("admin_invalid_username_format"));return
|
||||
if not user_to_ban_data :await message .answer (_ ("admin_user_not_found_in_bot_db",user_id =input_text ));await state .clear ();return
|
||||
user_id_to_ban =user_to_ban_data ['user_id'];user_id_or_username_for_msg =f"@{user_to_ban_data['username']}"if user_to_ban_data .get ('username')else str (user_id_to_ban )
|
||||
if user_id_to_ban ==message .from_user .id or user_id_to_ban in settings .ADMIN_IDS :await message .answer (_ ("admin_cannot_ban_self_or_admin"));await state .clear ();return
|
||||
if user_to_ban_data ['is_banned']:await message .answer (_ ("admin_user_already_banned",user_id_or_username =user_id_or_username_for_msg ));await state .clear ();return
|
||||
panel_user_uuid =user_to_ban_data ['panel_user_uuid']if user_to_ban_data and 'panel_user_uuid'in user_to_ban_data .keys ()and user_to_ban_data ['panel_user_uuid']else None
|
||||
await set_user_ban_status_db (user_id_to_ban ,is_banned =True )
|
||||
reply_markup_val =get_back_to_admin_panel_keyboard (current_lang ,i18n )
|
||||
if panel_user_uuid :
|
||||
panel_ban_success =await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =False )
|
||||
if panel_ban_success :await message .answer (_ ("admin_user_banned_success_panel_too",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
else :await message .answer (_ ("admin_user_banned_local_panel_fail",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
else :await message .answer (_ ("admin_user_banned_local_no_panel_uuid",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
await state .clear ()
|
||||
|
||||
|
||||
async def unban_user_prompt_handler (callback :types .CallbackQuery ,state :FSMContext ,i18n_data :dict ,settings :Settings ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );prompt_text =_ ("admin_unban_user_prompt")
|
||||
if callback .message :
|
||||
try :await callback .message .edit_text (prompt_text ,reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ))
|
||||
except :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_user_id_to_unban )
|
||||
|
||||
@router .message (AdminStates .waiting_for_user_id_to_unban ,F .text )
|
||||
async def process_user_input_to_unban_handler (message :types .Message ,state :FSMContext ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await message .reply ("Language error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );input_text =message .text .strip ();user_to_unban_data :Optional [aiosqlite .Row ]=None ;user_id_or_username_for_msg =input_text
|
||||
if input_text .isdigit ():
|
||||
try :user_id_to_unban_val =int (input_text );user_to_unban_data =await get_user (user_id_to_unban_val )
|
||||
except ValueError :await message .answer (_ ("admin_invalid_user_id_format"));return
|
||||
elif input_text .startswith ("@")and USERNAME_REGEX .match (input_text [1 :]):user_to_unban_data =await get_user_by_telegram_username (input_text [1 :])
|
||||
elif USERNAME_REGEX .match (input_text ):user_to_unban_data =await get_user_by_telegram_username (input_text )
|
||||
else :await message .answer (_ ("admin_invalid_user_id_format")+" "+_ ("admin_invalid_username_format"));return
|
||||
if not user_to_unban_data :await message .answer (_ ("admin_user_not_found_in_bot_db",user_id =input_text ));await state .clear ();return
|
||||
user_id_to_unban =user_to_unban_data ['user_id'];user_id_or_username_for_msg =f"@{user_to_unban_data['username']}"if user_to_unban_data .get ('username')else str (user_id_to_unban )
|
||||
if not user_to_unban_data ['is_banned']:await message .answer (_ ("admin_user_not_banned",user_id_or_username =user_id_or_username_for_msg ));await state .clear ();return
|
||||
panel_user_uuid =user_to_unban_data ['panel_user_uuid']if user_to_unban_data and 'panel_user_uuid'in user_to_unban_data .keys ()and user_to_unban_data ['panel_user_uuid']else None
|
||||
await set_user_ban_status_db (user_id_to_unban ,is_banned =False )
|
||||
reply_markup_val =get_back_to_admin_panel_keyboard (current_lang ,i18n )
|
||||
if panel_user_uuid :
|
||||
panel_unban_success =await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =True )
|
||||
if panel_unban_success :await message .answer (_ ("admin_user_unbanned_success_panel_too",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
else :await message .answer (_ ("admin_user_unbanned_local_panel_fail",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
else :await message .answer (_ ("admin_user_unbanned_local_no_panel_uuid",user_id_or_username =user_id_or_username_for_msg ),reply_markup =reply_markup_val )
|
||||
await state .clear ()
|
||||
|
||||
|
||||
async def view_banned_users_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,state :FSMContext ):
|
||||
await state .clear ();current_page =0 ;
|
||||
if ":"in callback .data and callback .data .count (":")==2 :
|
||||
try :current_page =int (callback .data .split (":")[-1 ])
|
||||
except ValueError :current_page =0
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
|
||||
|
||||
banned_users ,total_banned =await get_banned_users_list_paginated (limit =settings .LOGS_PAGE_SIZE ,offset =current_page *settings .LOGS_PAGE_SIZE )
|
||||
|
||||
if not callback .message :await callback .answer ("Error.");return
|
||||
if total_banned ==0 :
|
||||
await callback .message .edit_text (_ ("admin_no_banned_users"),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ))
|
||||
else :
|
||||
total_pages =math .ceil (total_banned /settings .LOGS_PAGE_SIZE )if settings .LOGS_PAGE_SIZE >0 else 1
|
||||
await callback .message .edit_text (text =_ ("admin_banned_list_title",current_page =current_page +1 ,total_pages =max (1 ,total_pages )),reply_markup =get_banned_users_keyboard (banned_users ,current_page ,total_banned ,i18n ,current_lang ,settings ))
|
||||
await callback .answer ()
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_user_card:"))
|
||||
async def show_user_card_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ,force_user_id :Optional [int ]=None ,force_page :Optional [int ]=None ):
|
||||
|
||||
await state .clear ();user_id_to_show =0 ;banned_list_page_to_return =0
|
||||
if force_user_id is not None and force_page is not None :user_id_to_show =force_user_id ;banned_list_page_to_return =force_page
|
||||
else :
|
||||
try :parts =callback .data .split (":");user_id_to_show =int (parts [1 ]);banned_list_page_to_return =int (parts [2 ])if len (parts )>2 else 0
|
||||
except (IndexError ,ValueError ):await callback .answer ("Invalid user card data.",show_alert =True );return
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
user_data =await get_user (user_id_to_show )
|
||||
if not callback .message :await callback .answer ("Error.");return
|
||||
if not user_data :await callback .message .edit_text (_ ("admin_user_not_found_in_bot_db",user_id =user_id_to_show ),reply_markup =get_back_to_admin_panel_keyboard (current_lang ,i18n ));await callback .answer ();return
|
||||
user_display_name =user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID: {user_id_to_show}")
|
||||
sub_end_date_str =await get_user_active_subscription_end_date (user_id_to_show )or _ ("user_card_sub_na")
|
||||
reg_date_from_db =user_data ['registration_date_str']if 'registration_date_str'in user_data .keys ()else "N/A"
|
||||
reg_date_display =reg_date_from_db [:10 ]if reg_date_from_db and reg_date_from_db !="N/A"else "N/A"
|
||||
card_text =_ ("user_card_info",user_id =user_data ['user_id'],username =user_data .get ('username',"N/A"),first_name =user_data .get ('first_name',""),last_name =user_data .get ('last_name',""),language_code =user_data .get ('language_code',"N/A"),panel_user_uuid =user_data .get ('panel_user_uuid',"N/A"),ban_status =_ (key ="user_card_banned")if user_data ['is_banned']else _ (key ="user_card_active"),reg_date =reg_date_display ,sub_end_date =sub_end_date_str )
|
||||
await callback .message .edit_text (text =f"{_('admin_user_card_title', user_display=user_display_name)}\n\n{card_text}",reply_markup =get_user_card_keyboard (user_id_to_show ,bool (user_data ['is_banned']),i18n ,current_lang ,banned_list_page_to_return ),parse_mode ="HTML")
|
||||
if force_user_id is None :await callback .answer ()
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_unban_confirm:"))
|
||||
async def confirm_unban_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
|
||||
try :_ ,user_id_str ,page_str =callback .data .split (":");user_id =int (user_id_str );banned_list_page =int (page_str )
|
||||
except :await callback .answer ("Invalid data",show_alert =True );return
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
user_data =await get_user (user_id );user_display =(user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID {user_id}"))if user_data else f"ID {user_id}"
|
||||
if callback .message :await callback .message .edit_text (text =_ ("admin_confirm_unban_prompt",user_display =user_display ,user_id =user_id ),reply_markup =get_confirmation_keyboard (yes_callback_data =f"admin_unban_do:{user_id}:{banned_list_page}",no_callback_data =f"admin_user_card:{user_id}:{banned_list_page}",i18n_instance =i18n ,lang =current_lang ),parse_mode ="HTML")
|
||||
await callback .answer ()
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_ban_confirm:"))
|
||||
async def confirm_ban_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
try :_ ,user_id_str ,page_str =callback .data .split (":");user_id =int (user_id_str );banned_list_page =int (page_str )
|
||||
except :await callback .answer ("Invalid data",show_alert =True );return
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
user_data =await get_user (user_id );user_display =(user_data ['first_name']or (f"@{user_data['username']}"if user_data .get ('username')else f"ID {user_id}"))if user_data else f"ID {user_id}"
|
||||
if callback .message :
|
||||
await callback .message .edit_text (text =_ ("admin_confirm_ban_prompt",user_display =user_display ,user_id =user_id ),reply_markup =get_confirmation_keyboard (yes_callback_data =f"admin_ban_do:{user_id}:{banned_list_page}",no_callback_data =f"admin_user_card:{user_id}:{banned_list_page}",i18n_instance =i18n ,lang =current_lang ),parse_mode ="HTML")
|
||||
await callback .answer ()
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_unban_do:"))
|
||||
async def do_unban_user_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ):
|
||||
|
||||
try :_ ,user_id_str ,page_str =callback .data .split (":");user_id_to_unban =int (user_id_str );banned_list_page =int (page_str )
|
||||
except :await callback .answer ("Invalid data",show_alert =True );return
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
user_to_unban_data =await get_user (user_id_to_unban );user_display_name =(user_to_unban_data ['first_name']or (f"@{user_to_unban_data['username']}"if user_to_unban_data .get ('username')else f"ID {user_id_to_unban}"))if user_to_unban_data else f"ID {user_id_to_unban}"
|
||||
if not user_to_unban_data or not user_to_unban_data ['is_banned']:
|
||||
await callback .answer (_ ("admin_user_not_banned",user_id_or_username =user_display_name ),show_alert =True )
|
||||
if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_unban ,force_page =banned_list_page )
|
||||
return
|
||||
await set_user_ban_status_db (user_id_to_unban ,is_banned =False )
|
||||
panel_user_uuid =user_to_unban_data ['panel_user_uuid']if user_to_unban_data and 'panel_user_uuid'in user_to_unban_data .keys ()and user_to_unban_data ['panel_user_uuid']else None
|
||||
if panel_user_uuid :
|
||||
if not await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =True ):logging .warning (f"Panel status update fail for unban {user_id_to_unban}")
|
||||
await callback .answer (_ ("admin_user_unbanned_from_card",user_display =user_display_name ,user_id =user_id_to_unban ),show_alert =False )
|
||||
if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_unban ,force_page =banned_list_page )
|
||||
|
||||
@router .callback_query (F .data .startswith ("admin_ban_do:"))
|
||||
async def do_ban_user_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,panel_service :PanelApiService ,state :FSMContext ):
|
||||
try :_ ,user_id_str ,page_str =callback .data .split (":");user_id_to_ban =int (user_id_str );banned_list_page =int (page_str )
|
||||
except :await callback .answer ("Invalid data",show_alert =True );return
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 )
|
||||
user_to_ban_data =await get_user (user_id_to_ban );user_display_name =(user_to_ban_data ['first_name']or (f"@{user_to_ban_data['username']}"if user_to_ban_data .get ('username')else f"ID {user_id_to_ban}"))if user_to_ban_data else f"ID {user_id_to_ban}"
|
||||
if not user_to_ban_data :await callback .answer (_ ("admin_user_not_found_in_bot_db",user_id =user_id_to_ban ),show_alert =True );return
|
||||
if user_to_ban_data ['is_banned']:await callback .answer (_ ("admin_user_already_banned",user_id_or_username =user_display_name ),show_alert =True );return
|
||||
if user_id_to_ban ==callback .from_user .id or user_id_to_ban in settings .ADMIN_IDS :await callback .answer (_ ("admin_cannot_ban_self_or_admin"),show_alert =True );return
|
||||
|
||||
await set_user_ban_status_db (user_id_to_ban ,is_banned =True )
|
||||
panel_user_uuid =user_to_ban_data ['panel_user_uuid']if user_to_ban_data and 'panel_user_uuid'in user_to_ban_data .keys ()and user_to_ban_data ['panel_user_uuid']else None
|
||||
if panel_user_uuid :
|
||||
if not await panel_service .update_user_status_on_panel (panel_user_uuid ,enable =False ):logging .warning (f"Panel status update fail for ban {user_id_to_ban}")
|
||||
await callback .answer (_ ("admin_user_banned_from_card",user_display =user_display_name ,user_id =user_id_to_ban ),show_alert =False )
|
||||
if callback .message :await show_user_card_handler (callback ,i18n_data ,settings ,panel_service ,state ,force_user_id =user_id_to_ban ,force_page =banned_list_page )
|
||||
|
||||
|
||||
@router .callback_query (F .data =="admin_action:main",AdminStates .waiting_for_user_id_to_ban )
|
||||
@router .callback_query (F .data =="admin_action:main",AdminStates .waiting_for_user_id_to_unban )
|
||||
async def cancel_user_management_input_state (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ,bot :Bot ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'));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 callback .message :
|
||||
try :await callback .message .edit_text (_ ("admin_action_cancelled_default"),reply_markup =get_admin_panel_keyboard (i18n ,current_lang ,settings ))
|
||||
except :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"));await state .clear ()
|
||||
@@ -0,0 +1,21 @@
|
||||
from aiogram import Router
|
||||
|
||||
|
||||
from .import start
|
||||
from .import subscription
|
||||
from .import referral
|
||||
from .import promo_user
|
||||
from .import trial_handler
|
||||
|
||||
|
||||
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 (referral .router )
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import logging
|
||||
import json
|
||||
import aiosqlite
|
||||
import asyncio
|
||||
from datetime import datetime ,timezone
|
||||
from typing import Optional ,Dict ,Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from yookassa .domain .notification import WebhookNotification
|
||||
from yookassa .domain .models import Amount
|
||||
|
||||
from db .database import get_db_connection_manager ,_setup_db_connection ,get_user
|
||||
from bot .services .subscription_service import SubscriptionService
|
||||
from bot .services .referral_service import ReferralService
|
||||
from bot .services .panel_api_service import PanelApiService
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from config .settings import Settings
|
||||
from bot .services .payment_service import YooKassaService
|
||||
|
||||
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'
|
||||
YOOKASSA_EVENT_REFUND_SUCCEEDED ='refund.succeeded'
|
||||
|
||||
async def process_successful_payment (
|
||||
bot :Bot ,payment_info_from_webhook :dict ,i18n :JsonI18n ,
|
||||
settings :Settings ,panel_service :PanelApiService ,yk_service :YooKassaService ,
|
||||
subscription_service :SubscriptionService ,
|
||||
referral_service :ReferralService
|
||||
):
|
||||
metadata =payment_info_from_webhook .get ("metadata",{})
|
||||
user_id_str =metadata .get ("user_id")
|
||||
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")
|
||||
|
||||
if not user_id_str or not subscription_months_str or not payment_db_id_str :
|
||||
logging .error (f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}")
|
||||
return
|
||||
try :
|
||||
user_id =int (user_id_str );subscription_months =int (subscription_months_str )
|
||||
payment_db_id =int (payment_db_id_str )
|
||||
promo_code_id =int (promo_code_id_str )if promo_code_id_str and promo_code_id_str .isdigit ()else None
|
||||
amount_data =payment_info_from_webhook .get ("amount",{});
|
||||
payment_value =float (amount_data .get ("value",0.0 ))
|
||||
except (TypeError ,ValueError )as e :
|
||||
logging .error (f"Invalid metadata format for payment processing: {metadata} - {e}")
|
||||
return
|
||||
|
||||
final_end_date_for_user :Optional [datetime ]=None
|
||||
applied_referee_bonus_days :Optional [int ]=None
|
||||
base_subscription_end_date :Optional [datetime ]=None
|
||||
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
await db .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",(payment_info_from_webhook .get ("status","succeeded"),payment_db_id ,payment_info_from_webhook .get ("id")))
|
||||
|
||||
new_sub_details =await subscription_service .activate_subscription (user_id ,subscription_months ,payment_value ,payment_db_id ,db_conn =db ,promo_code_id =promo_code_id )
|
||||
|
||||
if new_sub_details and new_sub_details .get ('end_date'):
|
||||
base_subscription_end_date =new_sub_details ['end_date']
|
||||
final_end_date_for_user =base_subscription_end_date
|
||||
|
||||
referral_bonus_info =await referral_service .apply_referral_bonuses_for_payment (user_id ,subscription_months ,db_conn =db )
|
||||
|
||||
if referral_bonus_info and referral_bonus_info .get ("referee_new_end_date"):
|
||||
final_end_date_for_user =referral_bonus_info ["referee_new_end_date"]
|
||||
applied_referee_bonus_days =referral_bonus_info .get ("referee_bonus_applied_days")
|
||||
|
||||
await db .commit ()
|
||||
|
||||
user_lang =await subscription_service .get_user_language (user_id )
|
||||
_ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs )
|
||||
|
||||
success_message =""
|
||||
if applied_referee_bonus_days and final_end_date_for_user :
|
||||
referee_user_data =await get_user (user_id )
|
||||
inviter_name_for_msg =_ ("friend_placeholder")
|
||||
|
||||
if referee_user_data and referee_user_data ['referred_by_id']is not None :
|
||||
inviter_user_data_for_msg =await get_user (referee_user_data ['referred_by_id'])
|
||||
if inviter_user_data_for_msg and inviter_user_data_for_msg ['first_name']:
|
||||
inviter_name_for_msg =inviter_user_data_for_msg ['first_name']
|
||||
|
||||
success_message =_ ("payment_successful_with_referral_bonus",
|
||||
months =subscription_months ,
|
||||
base_end_date =base_subscription_end_date .strftime ('%Y-%m-%d')if base_subscription_end_date else "N/A",
|
||||
bonus_days =applied_referee_bonus_days ,
|
||||
final_end_date =final_end_date_for_user .strftime ('%Y-%m-%d'),
|
||||
inviter_name =inviter_name_for_msg
|
||||
)
|
||||
elif final_end_date_for_user :
|
||||
success_message =_ ("payment_successful",
|
||||
months =subscription_months ,
|
||||
end_date =final_end_date_for_user .strftime ('%Y-%m-%d')
|
||||
)
|
||||
else :
|
||||
logging .error (f"Critical error: final_end_date_for_user is None for user {user_id}")
|
||||
success_message =_ ("payment_successful_error_details")
|
||||
|
||||
try :await bot .send_message (user_id ,success_message )
|
||||
except Exception as e :logging .error (f"Failed to send final payment success message to user {user_id}: {e}")
|
||||
else :
|
||||
logging .error (f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}")
|
||||
await db .rollback ()
|
||||
except Exception as e :
|
||||
logging .error (f"Error during process_successful_payment transaction for user {user_id}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
try :
|
||||
user_lang_for_error =await subscription_service .get_user_language (user_id )
|
||||
_err =lambda key ,**kwargs :i18n .gettext (user_lang_for_error ,key ,**kwargs )
|
||||
await bot .send_message (user_id ,_err ("error_processing_your_payment"))
|
||||
except Exception as notify_err :logging .error (f"Failed to send error notification to user {user_id}: {notify_err}")
|
||||
|
||||
|
||||
async def process_cancelled_payment (bot :Bot ,payment_info_from_webhook :dict ,i18n :JsonI18n ,settings :Settings ):
|
||||
metadata =payment_info_from_webhook .get ("metadata",{})
|
||||
user_id_str =metadata .get ("user_id");payment_db_id_str =metadata .get ("payment_db_id")
|
||||
if not user_id_str or not payment_db_id_str :logging .warning (f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}");return
|
||||
try :user_id =int (user_id_str );payment_db_id =int (payment_db_id_str )
|
||||
except ValueError :logging .error (f"Invalid metadata in cancelled payment: {metadata}");return
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
await db .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",(payment_info_from_webhook .get ("status","canceled"),payment_db_id ,payment_info_from_webhook .get ("id")))
|
||||
await db .commit ()
|
||||
user_lang =getattr (settings ,'DEFAULT_LANGUAGE','en')
|
||||
_ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs )
|
||||
try :await bot .send_message (user_id ,_ ("payment_failed"))
|
||||
except Exception as e :logging .error (f"Failed to send payment cancellation message to user {user_id}: {e}")
|
||||
|
||||
async def yookassa_webhook_route (request :web .Request ):
|
||||
logging .info (f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}")
|
||||
try :
|
||||
bot :Bot =request .app ['bot'];i18n_instance :JsonI18n =request .app ['i18n'];settings :Settings =request .app ['settings']
|
||||
yk_service :YooKassaService =request .app ['yookassa_service'];panel_service :PanelApiService =request .app ['panel_service']
|
||||
subscription_service :SubscriptionService =request .app ['subscription_service']
|
||||
referral_service :ReferralService =request .app ['referral_service']
|
||||
except KeyError as e :logging .error (f"KeyError accessing app context in yookassa_webhook_route: {e}.",exc_info =True );return web .Response (status =500 ,text ="Internal Server Error: Missing app context")
|
||||
try :
|
||||
event_json =await request .json ();notification_object =WebhookNotification (event_json )
|
||||
payment_data_from_notification =notification_object .object
|
||||
logging .info (f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'")
|
||||
if not payment_data_from_notification or not hasattr (payment_data_from_notification ,'metadata')or payment_data_from_notification .metadata is None :
|
||||
logging .error (f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata.");return web .Response (status =200 ,text ="ok_error_no_metadata")
|
||||
payment_dict_for_processing ={}
|
||||
if hasattr (payment_data_from_notification ,'model_dump'):
|
||||
payment_dict_for_processing =payment_data_from_notification .model_dump (exclude_none =True )
|
||||
if 'amount'in payment_dict_for_processing and isinstance (payment_dict_for_processing ['amount'],Amount ):amount_obj =payment_dict_for_processing ['amount'];payment_dict_for_processing ['amount']={"value":str (amount_obj .value ),"currency":str (amount_obj .currency )}
|
||||
elif 'amount'in payment_dict_for_processing and not isinstance (payment_dict_for_processing ['amount'],dict ):amount_obj_original =payment_data_from_notification .amount ;payment_dict_for_processing ['amount']={"value":str (amount_obj_original .value ),"currency":str (amount_obj_original .currency )}if hasattr (amount_obj_original ,'value')and hasattr (amount_obj_original ,'currency')else {"value":"0.0","currency":"RUB"}
|
||||
elif hasattr (payment_data_from_notification ,'amount')and hasattr (payment_data_from_notification .amount ,'value')and hasattr (payment_data_from_notification .amount ,'currency'):amount_obj =payment_data_from_notification .amount ;payment_dict_for_processing ={"id":str (payment_data_from_notification .id ),"status":str (payment_data_from_notification .status ),"paid":bool (payment_data_from_notification .paid ),"amount":{"value":str (amount_obj .value ),"currency":str (amount_obj .currency )},"metadata":dict (payment_data_from_notification .metadata )if payment_data_from_notification .metadata else {},"description":str (payment_data_from_notification .description )if payment_data_from_notification .description else None }
|
||||
else :logging .error (f"Could not serialize payment_data for payment {payment_data_from_notification.id}");return web .Response (status =200 ,text ="ok_error_serialization")
|
||||
async with payment_processing_lock :
|
||||
if notification_object .event ==YOOKASSA_EVENT_PAYMENT_SUCCEEDED :
|
||||
if payment_dict_for_processing .get ("paid")and payment_dict_for_processing .get ("status")=="succeeded":
|
||||
await process_successful_payment (bot ,payment_dict_for_processing ,i18n_instance ,settings ,panel_service ,yk_service ,subscription_service ,referral_service )
|
||||
else :logging .warning (f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'")
|
||||
elif notification_object .event ==YOOKASSA_EVENT_PAYMENT_CANCELED :
|
||||
await process_cancelled_payment (bot ,payment_dict_for_processing ,i18n_instance ,settings )
|
||||
return web .Response (status =200 ,text ="ok")
|
||||
except json .JSONDecodeError :logging .error ("YooKassa Webhook: Invalid JSON.");return web .Response (status =200 ,text ="ok_invalid_json")
|
||||
except KeyError as e :logging .error (f"KeyError in yookassa_webhook_route after initial context access: {e}.",exc_info =True );return web .Response (status =500 ,text ="Internal Server Error: Context error post-access")
|
||||
except Exception as e :logging .error (f"YooKassa Webhook processing error: {e}",exc_info =True );return web .Response (status =200 ,text ="ok_internal_error")
|
||||
@@ -0,0 +1,127 @@
|
||||
import logging
|
||||
import re
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional
|
||||
|
||||
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 .middlewares .i18n import JsonI18n
|
||||
from aiogram .utils .markdown import hcode
|
||||
|
||||
from .start import send_main_menu
|
||||
|
||||
router =Router (name ="user_promo_router")
|
||||
|
||||
SUSPICIOUS_SQL_KEYWORDS_REGEX =re .compile (
|
||||
r"\b(DROP\s*TABLE|DELETE\s*FROM|ALTER\s*TABLE|TRUNCATE\s*TABLE|UNION\s*SELECT|;\s*SELECT|;\s*INSERT|;\s*UPDATE|;\s*DELETE|xp_cmdshell|sysdatabases|sysobjects|INFORMATION_SCHEMA)\b",
|
||||
re .IGNORECASE
|
||||
)
|
||||
SUSPICIOUS_CHARS_REGEX =re .compile (r"(--|#\s|;|\*\/|\/\*)")
|
||||
|
||||
async def prompt_promo_code_input (
|
||||
callback :types .CallbackQuery ,
|
||||
state :FSMContext ,
|
||||
i18n_data :dict ,
|
||||
settings :Settings
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
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 :
|
||||
logging .error ("CallbackQuery has no message in prompt_promo_code_input")
|
||||
await callback .answer (_ ("error_occurred_processing_request"),show_alert =True )
|
||||
return
|
||||
|
||||
try :
|
||||
await callback .message .edit_text (
|
||||
text =_ (key ="promo_code_prompt"),
|
||||
reply_markup =get_back_to_main_menu_markup (current_lang ,i18n )
|
||||
)
|
||||
except Exception as e :
|
||||
logging .warning (f"Failed to edit message for promo prompt: {e}")
|
||||
await callback .message .answer (text =_ (key ="promo_code_prompt"),reply_markup =get_back_to_main_menu_markup (current_lang ,i18n ))
|
||||
|
||||
await callback .answer ()
|
||||
await state .set_state (UserPromoStates .waiting_for_promo_code )
|
||||
logging .info (f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. FSM state: {await state.get_state()}")
|
||||
|
||||
|
||||
@router .message (UserPromoStates .waiting_for_promo_code ,F .text )
|
||||
async def process_promo_code_input (
|
||||
message :types .Message ,
|
||||
state :FSMContext ,
|
||||
settings :Settings ,
|
||||
i18n_data :dict ,
|
||||
promo_code_service :PromoCodeService ,
|
||||
bot :Bot
|
||||
):
|
||||
logging .info (f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'")
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
|
||||
if not i18n or not promo_code_service :
|
||||
logging .error ("Deps missing in process_promo_code_input")
|
||||
await message .reply ("Service error. Please try again.")
|
||||
await state .clear ();return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
code_input =message .text .strip ();user =message .from_user
|
||||
is_suspicious =False
|
||||
if SUSPICIOUS_SQL_KEYWORDS_REGEX .search (code_input )or SUSPICIOUS_CHARS_REGEX .search (code_input )or len (code_input )>100 :
|
||||
is_suspicious =True
|
||||
logging .warning (f"Suspicious input for promo by user {user.id} (len: {len(code_input)}): '{code_input}'")
|
||||
|
||||
response_to_user_text =""
|
||||
if is_suspicious :
|
||||
admin_notify_key ="admin_suspicious_promo_attempt_notification_no_username"if not user .username else "admin_suspicious_promo_attempt_notification"
|
||||
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 ))
|
||||
try :await bot .send_message (settings .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: {e_admin_notify}")
|
||||
response_to_user_text =_ ("promo_code_not_found",code =code_input .upper ())
|
||||
else :
|
||||
success ,response_text_from_service =await promo_code_service .apply_promo_code (user .id ,code_input ,current_lang )
|
||||
response_to_user_text =response_text_from_service
|
||||
|
||||
await message .answer (response_to_user_text ,reply_markup =get_back_to_main_menu_markup (current_lang ,i18n ))
|
||||
await state .clear ()
|
||||
logging .info (f"Promo code '{code_input}' processing finished for user {message.from_user.id}. State cleared.")
|
||||
|
||||
|
||||
|
||||
@router .callback_query (F .data =="main_action:back_to_main",UserPromoStates .waiting_for_promo_code )
|
||||
async def cancel_promo_input_via_button (
|
||||
callback :types .CallbackQuery ,
|
||||
state :FSMContext ,
|
||||
settings :Settings ,
|
||||
i18n_data :dict ,
|
||||
subscription_service :SubscriptionService
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in cancel_promo_input_via_button")
|
||||
await callback .answer ("Language error",show_alert =True )
|
||||
return
|
||||
|
||||
logging .info (f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state.")
|
||||
await state .clear ()
|
||||
logging .info (f"State after clear for user {callback.from_user.id}: {await state.get_state()}")
|
||||
|
||||
if callback .message :
|
||||
show_trial_button_on_back =False
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ):
|
||||
show_trial_button_on_back =True
|
||||
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True )
|
||||
else :
|
||||
|
||||
await callback .answer ("Promo code input cancelled.",show_alert =False )
|
||||
@@ -0,0 +1,81 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from typing import Optional ,Dict
|
||||
|
||||
from config .settings import Settings
|
||||
from bot .services .referral_service import ReferralService
|
||||
from db .database import get_db_connection_manager
|
||||
from bot .keyboards .inline .user_keyboards import get_referral_link_keyboard ,get_back_to_main_menu_markup
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
router =Router (name ="user_referral_router")
|
||||
|
||||
async def referral_command_handler (
|
||||
event :types .Message |types .CallbackQuery ,
|
||||
settings :Settings ,
|
||||
i18n_data :dict ,
|
||||
referral_service :ReferralService ,
|
||||
bot :Bot
|
||||
):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
|
||||
target_message =event .message if isinstance (event ,types .CallbackQuery )else event
|
||||
if not target_message :
|
||||
logging .error ("Target message is None in referral_command_handler from callback.")
|
||||
if isinstance (event ,types .CallbackQuery ):await event .answer ("Error displaying referral info.")
|
||||
return
|
||||
|
||||
if not i18n or not referral_service :
|
||||
logging .error ("Deps missing in referral_command_handler")
|
||||
await target_message .answer ("Service error."if isinstance (event ,types .Message )else "Service error.",parse_mode =None )
|
||||
if isinstance (event ,types .CallbackQuery ):await event .answer ()
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
bot_info =await bot .get_me ()
|
||||
bot_username =bot_info .username
|
||||
inviter_user_id =event .from_user .id
|
||||
referral_link =referral_service .generate_referral_link (bot_username ,inviter_user_id )
|
||||
bonus_info_parts =[]
|
||||
|
||||
if hasattr (settings ,'subscription_options')and settings .subscription_options :
|
||||
for months_period in sorted (settings .subscription_options .keys ()):
|
||||
inv_bonus =settings .referral_bonus_inviter .get (months_period )
|
||||
ref_bonus =settings .referral_bonus_referee .get (months_period )
|
||||
if inv_bonus is not None or ref_bonus is not None :
|
||||
bonus_info_parts .append (
|
||||
_ ("referral_bonus_per_period",
|
||||
months =months_period ,
|
||||
inviter_bonus_days =inv_bonus if inv_bonus is not None else _ ("no_bonus_days"),
|
||||
referee_bonus_days =ref_bonus if ref_bonus is not None else _ ("no_bonus_days")
|
||||
)
|
||||
)
|
||||
bonus_details_str ="\n".join (bonus_info_parts )if bonus_info_parts else _ ("referral_no_bonuses_configured")
|
||||
text =_ ("referral_program_info_new",referral_link =referral_link ,bonus_details =bonus_details_str )
|
||||
|
||||
|
||||
|
||||
reply_markup_val =get_back_to_main_menu_markup (current_lang ,i18n )
|
||||
|
||||
if isinstance (event ,types .Message ):
|
||||
await event .answer (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True )
|
||||
elif isinstance (event ,types .CallbackQuery ):
|
||||
try :
|
||||
await event .message .edit_text (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True )
|
||||
except Exception as e :
|
||||
logging .warning (f"Failed to edit message for referral info: {e}")
|
||||
|
||||
await event .message .answer (text ,reply_markup =reply_markup_val ,disable_web_page_preview =True )
|
||||
await event .answer ()
|
||||
|
||||
|
||||
@router .callback_query (F .data =="copy_referral_link_ack")
|
||||
async def copy_referral_link_ack_callback_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :return await callback .answer ("Language service error.",show_alert =True )
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
await callback .answer (text =_ (key ="referral_link_for_copying_reminder"),show_alert =False )
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import CommandStart ,Command
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional ,Dict ,Any ,Callable ,Awaitable
|
||||
from datetime import datetime ,timezone ,timedelta
|
||||
|
||||
from db .database import add_user_if_not_exists ,update_user_language_code
|
||||
from bot .keyboards .inline .user_keyboards import get_main_menu_inline_keyboard ,get_language_selection_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 aiogram .types import InlineKeyboardMarkup
|
||||
|
||||
router =Router (name ="user_start_router")
|
||||
|
||||
async def send_main_menu (message_or_callback :types .Message |types .CallbackQuery ,settings :Settings ,i18n_data :dict ,show_trial_button_flag :bool ,is_edit :bool =False ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
answered_callback_internally =False
|
||||
|
||||
if not i18n :
|
||||
logging .error ("i18n_instance missing in send_main_menu")
|
||||
target_mc_for_error =message_or_callback if isinstance (message_or_callback ,types .Message )else message_or_callback .message
|
||||
error_text_fallback ="Error: Language service unavailable."
|
||||
if target_mc_for_error :
|
||||
try :await target_mc_for_error .answer (error_text_fallback )
|
||||
except Exception as e_ans :logging .error (f"Failed to send error message in send_main_menu: {e_ans}")
|
||||
if isinstance (message_or_callback ,types .CallbackQuery ):await message_or_callback .answer ();answered_callback_internally =True
|
||||
return
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
user_full_name =message_or_callback .from_user .full_name
|
||||
text =_ (key ="main_menu_greeting",user_name =user_full_name )
|
||||
reply_markup :Optional [InlineKeyboardMarkup ]=get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_button_flag )
|
||||
target_message :Optional [types .Message ]=None
|
||||
if isinstance (message_or_callback ,types .Message ):target_message =message_or_callback
|
||||
elif isinstance (message_or_callback ,types .CallbackQuery ):target_message =message_or_callback .message
|
||||
|
||||
if not target_message :
|
||||
logging .error (f"send_main_menu: target_message is None for event from user {message_or_callback.from_user.id}.")
|
||||
if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally :await message_or_callback .answer ("Error displaying menu.");answered_callback_internally =True
|
||||
return
|
||||
|
||||
try :
|
||||
if is_edit :
|
||||
await target_message .edit_text (text ,reply_markup =reply_markup )
|
||||
else :
|
||||
await target_message .answer (text ,reply_markup =reply_markup )
|
||||
|
||||
if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally :
|
||||
await message_or_callback .answer ()
|
||||
answered_callback_internally =True
|
||||
except Exception as e_send_edit :
|
||||
logging .warning (f"Failed to send/edit main menu (user: {message_or_callback.from_user.id}): {e_send_edit}.")
|
||||
if is_edit :
|
||||
try :await target_message .answer (text ,reply_markup =reply_markup )
|
||||
except Exception as e_send_new :logging .error (f"Also failed to send new main menu message: {e_send_new}")
|
||||
if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally :
|
||||
await message_or_callback .answer ()
|
||||
answered_callback_internally =True
|
||||
|
||||
|
||||
if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback_internally :
|
||||
logging .warning (f"Callback {message_or_callback.id} was not answered in send_main_menu main logic paths.")
|
||||
await message_or_callback .answer ()
|
||||
|
||||
|
||||
@router .message (CommandStart ())
|
||||
async def start_command_handler (message :types .Message ,state :FSMContext ,settings :Settings ,i18n_data :dict ,subscription_service :SubscriptionService ,bot :Bot ):
|
||||
|
||||
await state .clear ();current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE )
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n_instance not found");await message .answer ("Language service error.");return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
user_id =message .from_user .id ;referred_by_user_id :Optional [int ]=None ;args =message .text .split ()
|
||||
if len (args )>1 and args [0 ]=="/start":
|
||||
try :
|
||||
referral_param =args [1 ]
|
||||
if referral_param .startswith ("ref_")and referral_param .split ("_")[1 ].isdigit ():
|
||||
potential_referrer_id =int (referral_param .split ("_")[1 ])
|
||||
if potential_referrer_id !=user_id :referred_by_user_id =potential_referrer_id
|
||||
except (ValueError ,IndexError )as e :logging .warning (f"Could not parse referral: '{args[1]}' - {e}")
|
||||
db_op_success ,was_new_bot_user =await add_user_if_not_exists (user_id =user_id ,username =message .from_user .username ,first_name =message .from_user .first_name ,last_name =message .from_user .last_name ,lang_code =current_lang ,referred_by_id =referred_by_user_id )
|
||||
if not db_op_success :await message .answer (_ ("error_occurred_processing_request"));return
|
||||
if referred_by_user_id :logging .info (f"User {user_id} started with referral from {referred_by_user_id}.")
|
||||
await message .answer (_ (key ="welcome",user_name =message .from_user .full_name ))
|
||||
show_trial_button_in_menu =False
|
||||
if settings .TRIAL_ENABLED :
|
||||
if not await subscription_service .has_had_any_subscription (user_id ):
|
||||
show_trial_button_in_menu =True
|
||||
logging .info (f"User {user_id} is eligible for a trial button.")
|
||||
|
||||
else :logging .info (f"User {user_id} not eligible for trial button (already had a subscription).")
|
||||
else :logging .info (f"Trial period is disabled in settings. No trial button.")
|
||||
await send_main_menu (message ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_in_menu )
|
||||
|
||||
|
||||
@router .message (Command ("language"))
|
||||
@router .callback_query (F .data =="main_action:language")
|
||||
async def language_command_handler (event :types .Message |types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
|
||||
current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE );i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
target_message_for_reply :Optional [types .Message ]=None ;is_callback =isinstance (event ,types .CallbackQuery );answered_callback =False
|
||||
if is_callback :await event .answer ();answered_callback =True ;target_message_for_reply =event .message
|
||||
else :target_message_for_reply =event
|
||||
if not i18n :
|
||||
logging .error ("i18n instance is missing in language_command_handler.");error_message_text ="Language service error."
|
||||
if target_message_for_reply :await target_message_for_reply .answer (error_message_text )
|
||||
return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs );text_to_send =_ (key ="choose_language");reply_markup_to_send =get_language_selection_keyboard (i18n ,current_lang )
|
||||
if not target_message_for_reply :logging .warning ("language_command_handler: No target message.");return
|
||||
if is_callback :
|
||||
try :await target_message_for_reply .edit_text (text_to_send ,reply_markup =reply_markup_to_send )
|
||||
except Exception as e :logging .info (f"Could not edit for lang selection: {e}. Sending new.");await target_message_for_reply .answer (text_to_send ,reply_markup =reply_markup_to_send )
|
||||
else :await target_message_for_reply .answer (text_to_send ,reply_markup =reply_markup_to_send )
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("set_lang_"))
|
||||
async def select_language_callback_handler (callback :types .CallbackQuery ,i18n_data :dict ,settings :Settings ,subscription_service :SubscriptionService ):
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n or not callback .message :
|
||||
await callback .answer ("Language service error.",show_alert =True );return
|
||||
|
||||
lang_code =callback .data .split ("_")[2 ];user_id =callback .from_user .id
|
||||
await update_user_language_code (user_id ,lang_code );i18n_data ["current_language"]=lang_code
|
||||
_ =lambda key ,**kwargs :i18n .gettext (lang_code ,key ,**kwargs )
|
||||
|
||||
|
||||
await callback .answer (_ (key ="language_set_alert"))
|
||||
|
||||
show_trial_button_after_lang_change =False
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ):
|
||||
show_trial_button_after_lang_change =True
|
||||
|
||||
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_after_lang_change ,is_edit =True )
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("main_action:"))
|
||||
async def main_action_callback_handler (
|
||||
|
||||
callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ,bot :Bot ,
|
||||
subscription_service :SubscriptionService ,referral_service :ReferralService ,
|
||||
panel_service :PanelApiService ,promo_code_service :PromoCodeService
|
||||
):
|
||||
|
||||
action =callback .data .split (":")[1 ];from .import subscription as user_subscription_handlers ;from .import referral as user_referral_handlers ;from .import promo_user as user_promo_handlers ;from .import trial_handler as user_trial_handlers
|
||||
if not callback .message :logging .error (f"Callback {callback.id} no message for {action}");await callback .answer ("Error.");return
|
||||
|
||||
if action =="subscribe":await user_subscription_handlers .display_subscription_options (callback ,i18n_data ,settings )
|
||||
elif action =="my_subscription":await user_subscription_handlers .my_subscription_command_handler (callback ,i18n_data ,settings ,panel_service ,subscription_service )
|
||||
elif action =="referral":await user_referral_handlers .referral_command_handler (callback ,settings ,i18n_data ,referral_service ,bot )
|
||||
elif action =="apply_promo":await user_promo_handlers .prompt_promo_code_input (callback ,state ,i18n_data ,settings )
|
||||
elif action =="request_trial":await user_trial_handlers .request_trial_confirmation_handler (callback ,settings ,i18n_data ,subscription_service )
|
||||
elif action =="language":await language_command_handler (callback ,i18n_data ,settings )
|
||||
elif action =="back_to_main":
|
||||
show_trial_button_on_back =False
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ):
|
||||
show_trial_button_on_back =True
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True )
|
||||
else :await callback .answer ("Unknown action.",show_alert =True )
|
||||
@@ -0,0 +1,207 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
from aiogram .filters import Command
|
||||
from aiogram .fsm .context import FSMContext
|
||||
from typing import Optional ,Dict ,Any
|
||||
from datetime import datetime ,timezone
|
||||
from aiogram .utils .keyboard import InlineKeyboardBuilder
|
||||
from aiogram .types import InlineKeyboardMarkup
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import add_payment_record ,get_db_connection_manager ,_setup_db_connection
|
||||
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 (message_or_callback :types .Message |types .CallbackQuery ,i18n_data :dict ,settings :Settings ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in display_subscription_options")
|
||||
|
||||
target_msg =message_or_callback .message if isinstance (message_or_callback ,types .CallbackQuery )else message_or_callback
|
||||
if target_msg :await target_msg .answer ("Language service error.")
|
||||
if isinstance (message_or_callback ,types .CallbackQuery ):await message_or_callback .answer ()
|
||||
return
|
||||
|
||||
get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
currency_symbol_val =settings .DEFAULT_CURRENCY_SYMBOL
|
||||
|
||||
text =get_translation ("select_subscription_period")if settings .subscription_options else get_translation ("no_subscription_options_available")
|
||||
reply_markup =get_subscription_options_keyboard (settings .subscription_options ,currency_symbol_val ,current_lang ,i18n )if settings .subscription_options else None
|
||||
|
||||
target_message =message_or_callback .message if isinstance (message_or_callback ,types .CallbackQuery )else message_or_callback
|
||||
answered_callback =False
|
||||
|
||||
if isinstance (message_or_callback ,types .CallbackQuery ):
|
||||
|
||||
await message_or_callback .answer ()
|
||||
answered_callback =True
|
||||
|
||||
if target_message :
|
||||
if isinstance (message_or_callback ,types .CallbackQuery ):
|
||||
try :
|
||||
await target_message .edit_text (text ,reply_markup =reply_markup )
|
||||
except Exception :
|
||||
await target_message .answer (text ,reply_markup =reply_markup )
|
||||
else :
|
||||
await target_message .answer (text ,reply_markup =reply_markup )
|
||||
elif isinstance (message_or_callback ,types .Message ):
|
||||
await message_or_callback .answer (text ,reply_markup =reply_markup )
|
||||
|
||||
|
||||
if isinstance (message_or_callback ,types .CallbackQuery )and not answered_callback :
|
||||
await message_or_callback .answer ()
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("subscribe_period:"))
|
||||
async def select_subscription_period_callback_handler (callback :types .CallbackQuery ,state :FSMContext ,settings :Settings ,i18n_data :dict ):
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :
|
||||
logging .error ("i18n missing in select_subscription_period_callback_handler")
|
||||
await callback .answer ("Service error. Please try again.",show_alert =True )
|
||||
return
|
||||
get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
try :months =int (callback .data .split (":")[-1 ])
|
||||
except ValueError :
|
||||
logging .error (f"Invalid sub period: {callback.data}")
|
||||
await callback .answer (get_translation ("error_try_again"),show_alert =True );return
|
||||
|
||||
price =settings .subscription_options .get (months )
|
||||
if price is None :
|
||||
logging .error (f"Price not found for {months} months subscription.")
|
||||
await callback .answer (get_translation ("error_try_again"),show_alert =True );return
|
||||
|
||||
currency_symbol_val =settings .DEFAULT_CURRENCY_SYMBOL
|
||||
confirmation_text =get_translation ("confirm_subscription_prompt",months =months ,price =price ,currency_symbol =currency_symbol_val )
|
||||
reply_markup =get_confirm_subscription_keyboard (months ,price ,currency_symbol_val ,current_lang ,i18n )
|
||||
|
||||
if callback .message :
|
||||
try :await callback .message .edit_text (confirmation_text ,reply_markup =reply_markup )
|
||||
except Exception as e :logging .warning (f"Edit failed: {e}");await callback .message .answer (confirmation_text ,reply_markup =reply_markup )
|
||||
await callback .answer ()
|
||||
|
||||
|
||||
@router .callback_query (F .data .startswith ("confirm_sub:"))
|
||||
async def confirm_subscription_callback_handler (
|
||||
callback :types .CallbackQuery ,state :FSMContext ,
|
||||
settings :Settings ,i18n_data :dict ,yookassa_service :YooKassaService
|
||||
):
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
if not i18n :logging .error ("i18n missing");await callback .answer ("Language error.",show_alert =True );return
|
||||
get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
if not yookassa_service or not yookassa_service .configured :
|
||||
logging .error ("YooKassa service missing or not configured")
|
||||
await callback .message .edit_text (get_translation ("payment_service_unavailable"))if callback .message else None
|
||||
await callback .answer (get_translation ("payment_service_unavailable"),show_alert =True );return
|
||||
try :_ ,data_payload =callback .data .split (":",1 );months_str ,price_str =data_payload .split (":");months =int (months_str );price =float (price_str )
|
||||
except ValueError :logging .error (f"Invalid confirm data: {callback.data}");await callback .answer (get_translation ("error_try_again"),show_alert =True );return
|
||||
|
||||
user_id =callback .from_user .id
|
||||
description =get_translation ("payment_description_subscription",months =months )
|
||||
currency =settings .DEFAULT_CURRENCY_SYMBOL
|
||||
payment_metadata ={"user_id":str (user_id ),"subscription_months":str (months ),"description":description }
|
||||
payment_db_id =await add_payment_record (user_id ,None ,None ,price ,currency ,"pending_creation",description ,months ,None )
|
||||
if not payment_db_id :
|
||||
if callback .message :await callback .message .edit_text (get_translation ("error_creating_payment_record"))
|
||||
await callback .answer (show_alert =True );return
|
||||
payment_metadata ["payment_db_id"]=str (payment_db_id )
|
||||
payment_response =await yookassa_service .create_payment (price ,currency ,description ,payment_metadata )
|
||||
|
||||
if callback .message :
|
||||
if payment_response and payment_response .get ("confirmation_url"):
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await db .execute ("UPDATE payments SET yookassa_payment_id = ?, idempotence_key = ?, status = ? WHERE payment_id = ?",(payment_response ["id"],payment_response .get ("idempotence_key"),payment_response ["status"],payment_db_id ));await db .commit ()
|
||||
await callback .message .edit_text (get_translation (key ="payment_link_message",months =months ),reply_markup =get_payment_url_keyboard (payment_response ["confirmation_url"],current_lang ,i18n ),disable_web_page_preview =False )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await db .execute ("UPDATE payments SET status = ? WHERE payment_id = ?",("failed_creation",payment_db_id ));await db .commit ()
|
||||
await callback .message .edit_text (get_translation ("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 ):
|
||||
await display_subscription_options (callback ,i18n_data ,settings )
|
||||
|
||||
|
||||
|
||||
async def my_subscription_command_handler (
|
||||
message_event :types .Message |types .CallbackQuery ,
|
||||
i18n_data :dict ,
|
||||
settings :Settings ,
|
||||
panel_service :PanelApiService ,
|
||||
subscription_service :SubscriptionService
|
||||
):
|
||||
target_message =message_event .message if isinstance (message_event ,types .CallbackQuery )else message_event
|
||||
user =message_event .from_user
|
||||
if isinstance (message_event ,types .CallbackQuery ):await message_event .answer ()
|
||||
|
||||
current_lang =i18n_data .get ("current_language",getattr (settings ,'DEFAULT_LANGUAGE','en'))
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n :logging .error ("i18n missing");await target_message .answer ("Lang error");return
|
||||
get_translation =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
if not panel_service or not subscription_service :logging .error ("Services missing");await target_message .answer (get_translation ("error_service_unavailable"));return
|
||||
|
||||
active_sub =await subscription_service .get_active_subscription (user .id )
|
||||
sub_info_text =""
|
||||
if active_sub :
|
||||
end_date_obj =active_sub .get ('end_date')
|
||||
if isinstance (end_date_obj ,str ):
|
||||
try :end_date_obj =datetime .fromisoformat (end_date_obj .replace ("Z","+00:00"))
|
||||
except ValueError :logging .warning (f"Could not parse date string '{end_date_obj}'.");end_date_obj =datetime .now (timezone .utc )
|
||||
|
||||
if not isinstance (end_date_obj ,datetime ):end_date_obj =datetime .now (timezone .utc )
|
||||
if end_date_obj .tzinfo is None :end_date_obj =end_date_obj .replace (tzinfo =timezone .utc )
|
||||
|
||||
|
||||
today_date_utc =datetime .now (timezone .utc ).date ()
|
||||
end_date_only =end_date_obj .date ()
|
||||
days_left =(end_date_only -today_date_utc ).days
|
||||
|
||||
actual_config_link =get_translation ("config_link_not_available");panel_user_uuid =active_sub .get ('panel_user_uuid')
|
||||
if panel_user_uuid :
|
||||
panel_user_data =await panel_service .get_user_by_uuid (panel_user_uuid )
|
||||
if panel_user_data :
|
||||
if panel_user_data .get ('subscriptionUrl'):actual_config_link =panel_user_data ['subscriptionUrl']
|
||||
elif panel_user_data .get ('shortUuid'):
|
||||
link =await panel_service .get_subscription_link (panel_user_data ['shortUuid'])
|
||||
if link :actual_config_link =link
|
||||
|
||||
traffic_limit_gb =get_translation ("traffic_unlimited");traffic_used_gb =get_translation ("traffic_na")
|
||||
if active_sub .get ('traffic_limit_bytes')and active_sub ['traffic_limit_bytes']>0 :traffic_limit_gb =f"{active_sub['traffic_limit_bytes'] / (1024**3):.2f} GB"
|
||||
if active_sub .get ('traffic_used_bytes')is not None :traffic_used_gb =f"{active_sub['traffic_used_bytes'] / (1024**3):.2f} GB"
|
||||
|
||||
sub_info_text =get_translation ("my_subscription_details",end_date =end_date_obj .strftime ("%Y-%m-%d"),days_left =max (0 ,days_left ),status =active_sub .get ('status_from_panel',get_translation ('status_active')).capitalize (),config_link =actual_config_link ,traffic_limit =traffic_limit_gb ,traffic_used =traffic_used_gb )
|
||||
else :
|
||||
sub_info_text =get_translation ("subscription_not_active")
|
||||
|
||||
reply_markup_val =get_back_to_main_menu_markup (current_lang ,i18n )
|
||||
if isinstance (message_event ,types .CallbackQuery )and message_event .message :
|
||||
try :await message_event .message .edit_text (sub_info_text ,reply_markup =reply_markup_val ,parse_mode ="HTML",disable_web_page_preview =True )
|
||||
except Exception as e :logging .warning (f"Edit 'my_sub' failed: {e}");await target_message .answer (sub_info_text ,reply_markup =reply_markup_val ,parse_mode ="HTML",disable_web_page_preview =True )
|
||||
else :await target_message .answer (sub_info_text ,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
|
||||
):
|
||||
"""Handles the /connect command, showing subscription info."""
|
||||
logging .info (f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler (message ,i18n_data ,settings ,panel_service ,subscription_service )
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from aiogram import Router ,F ,types ,Bot
|
||||
|
||||
from typing import Optional ,Dict ,Any
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
|
||||
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 .middlewares .i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
router =Router (name ="user_trial_router")
|
||||
|
||||
|
||||
async def request_trial_confirmation_handler (callback :types .CallbackQuery ,settings :Settings ,i18n_data :dict ,subscription_service :SubscriptionService ):
|
||||
user_id =callback .from_user .id ;current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE );i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
if not i18n or not callback .message :await callback .answer ("Error.",show_alert =True );return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
show_trial_btn_if_fail =False ;
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ):show_trial_btn_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 ,show_trial_btn_if_fail ));await callback .answer ();return
|
||||
if await subscription_service .has_had_any_subscription (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 ,
|
||||
bot :Bot
|
||||
):
|
||||
user_id =callback .from_user .id
|
||||
current_lang =i18n_data .get ("current_language",settings .DEFAULT_LANGUAGE )
|
||||
i18n :Optional [JsonI18n ]=i18n_data .get ("i18n_instance")
|
||||
|
||||
if not i18n or not callback .message :await callback .answer ("Error.",show_alert =True );return
|
||||
_ =lambda key ,**kwargs :i18n .gettext (current_lang ,key ,**kwargs )
|
||||
|
||||
show_trial_button_after_action =False
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (user_id ):
|
||||
show_trial_button_after_action =True
|
||||
|
||||
|
||||
if not settings .TRIAL_ENABLED :
|
||||
await callback .answer (_ ("trial_feature_disabled"),show_alert =True )
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =False ,is_edit =True )
|
||||
return
|
||||
if await subscription_service .has_had_any_subscription (user_id ):
|
||||
await callback .answer (_ ("trial_already_had_subscription_or_trial"),show_alert =True )
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =False ,is_edit =True )
|
||||
return
|
||||
|
||||
activation_result =await subscription_service .activate_trial_subscription (user_id )
|
||||
final_message_text_in_chat =""
|
||||
|
||||
if activation_result and activation_result .get ("activated"):
|
||||
await callback .answer (_ ("trial_activated_alert"),show_alert =True )
|
||||
|
||||
end_date =activation_result .get ("end_date")
|
||||
config_link_for_trial =_ ("config_link_not_available")
|
||||
|
||||
|
||||
|
||||
if activation_result .get ("subscription_url"):
|
||||
config_link_for_trial =activation_result ["subscription_url"]
|
||||
elif activation_result .get ("panel_short_uuid"):
|
||||
link =await panel_service .get_subscription_link (activation_result ["panel_short_uuid"])
|
||||
if link :config_link_for_trial =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")
|
||||
|
||||
final_message_text_in_chat =_ (
|
||||
"trial_activated_details_message",
|
||||
days =activation_result .get ("days",settings .TRIAL_DURATION_DAYS ),
|
||||
end_date =end_date .strftime ('%Y-%m-%d')if isinstance (end_date ,datetime )else "N/A",
|
||||
config_link =config_link_for_trial ,
|
||||
traffic_gb =traffic_display
|
||||
)
|
||||
show_trial_button_after_action =False
|
||||
else :
|
||||
message_key =activation_result .get ("message_key","trial_activation_failed")if activation_result else "trial_activation_failed"
|
||||
final_message_text_in_chat =_ (message_key )
|
||||
await callback .answer (final_message_text_in_chat ,show_alert =True )
|
||||
|
||||
|
||||
if callback .message :
|
||||
try :
|
||||
await callback .message .edit_text (final_message_text_in_chat ,
|
||||
parse_mode ="HTML",
|
||||
reply_markup =get_main_menu_inline_keyboard (current_lang ,i18n ,settings ,show_trial_button_after_action ),
|
||||
disable_web_page_preview =True )
|
||||
except Exception as e_edit :
|
||||
logging .warning (f"Could not edit trial result message: {e_edit}")
|
||||
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 )
|
||||
|
||||
|
||||
|
||||
@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 ):
|
||||
show_trial_button_on_back =False
|
||||
if settings .TRIAL_ENABLED and not await subscription_service .has_had_any_subscription (callback .from_user .id ):
|
||||
show_trial_button_on_back =True
|
||||
await send_main_menu (callback ,settings ,i18n_data ,show_trial_button_flag =show_trial_button_on_back ,is_edit =True )
|
||||
@@ -0,0 +1,82 @@
|
||||
from aiogram .utils .keyboard import InlineKeyboardBuilder ,InlineKeyboardButton
|
||||
from aiogram .types import InlineKeyboardMarkup ,WebAppInfo
|
||||
from typing import Optional ,List ,Any
|
||||
import math
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
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_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_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 ="admin_sync_panel_button"),callback_data ="admin_action:sync_panel")
|
||||
builder .adjust (2 ,2 ,2 ,2 ,1 )
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_logs_menu_keyboard (i18n_instance ,lang :str )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="admin_view_all_logs_button"),callback_data ="admin_logs:view_all:0")
|
||||
builder .button (text =_ (key ="admin_view_user_logs_prompt_button"),callback_data ="admin_logs:prompt_user")
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main"))
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_logs_pagination_keyboard (current_page :int ,total_pages :int ,base_callback_data :str ,i18n_instance ,lang :str ,back_to_logs_menu :bool =False )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
row_buttons =[]
|
||||
if current_page >0 :
|
||||
row_buttons .append (InlineKeyboardButton (text ="⬅️ "+_ ("prev_page_button",default ="Prev"),callback_data =f"{base_callback_data}:{current_page - 1}"))
|
||||
if current_page <total_pages -1 :
|
||||
row_buttons .append (InlineKeyboardButton (text =_ ("next_page_button",default ="Next")+" ➡️",callback_data =f"{base_callback_data}:{current_page + 1}"))
|
||||
|
||||
if row_buttons :builder .row (*row_buttons )
|
||||
|
||||
if back_to_logs_menu :
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="admin_logs_menu_title"),callback_data ="admin_action:view_logs_menu"))
|
||||
else :
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main"))
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_banned_users_keyboard (users :List [Any ],current_page :int ,total_users_banned :int ,i18n_instance ,lang :str ,settings :Settings )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder ()
|
||||
page_size =settings .LOGS_PAGE_SIZE
|
||||
total_pages =math .ceil (total_users_banned /page_size )if page_size >0 else 1
|
||||
if total_users_banned ==0 :total_pages =1
|
||||
for user_row in users :
|
||||
user_display =user_row ['first_name']or f"ID: {user_row['user_id']}";
|
||||
if user_row ['username']:user_display +=f" (@{user_row['username']})"
|
||||
else :user_display =f"ID: {user_row['user_id']}"if not user_row ['first_name']else user_display
|
||||
builder .button (text =user_display ,callback_data =f"admin_user_card:{user_row['user_id']}:{current_page}")
|
||||
pagination_row =[]
|
||||
if current_page >0 :pagination_row .append (InlineKeyboardButton (text ="⬅️ "+_ ("prev_page_button",default ="Prev"),callback_data =f"admin_action:view_banned:{current_page - 1}"))
|
||||
if (current_page +1 )*page_size <total_users_banned :pagination_row .append (InlineKeyboardButton (text =_ ("next_page_button",default ="Next")+" ➡️",callback_data =f"admin_action:view_banned:{current_page + 1}"))
|
||||
if pagination_row :builder .row (*pagination_row )
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main"));builder .adjust (1 );return builder .as_markup ()
|
||||
|
||||
def get_user_card_keyboard (user_id :int ,is_banned :bool ,i18n_instance ,lang :str ,banned_list_page :int =0 )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder ()
|
||||
if is_banned :builder .button (text =_ (key ="user_card_unban_button"),callback_data =f"admin_unban_confirm:{user_id}:{banned_list_page}")
|
||||
else :builder .button (text =_ (key ="user_card_ban_button"),callback_data =f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||
builder .button (text =_ (key ="user_card_back_to_banned_list_button"),callback_data =f"admin_action:view_banned:{banned_list_page}")
|
||||
builder .button (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main");builder .adjust (1 );return builder .as_markup ()
|
||||
|
||||
def get_confirmation_keyboard (yes_callback_data :str ,no_callback_data :str ,i18n_instance ,lang :str )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="yes_button"),callback_data =yes_callback_data );builder .button (text =_ (key ="no_button"),callback_data =no_callback_data );return builder .as_markup ()
|
||||
|
||||
def get_broadcast_confirmation_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="confirm_broadcast_send_button"),callback_data ="broadcast_final_action:send")
|
||||
builder .button (text =_ (key ="cancel_broadcast_button"),callback_data ="broadcast_final_action:cancel");builder .adjust (2 );return builder .as_markup ()
|
||||
|
||||
def get_back_to_admin_panel_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs );builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="back_to_admin_panel_button"),callback_data ="admin_action:main");return builder .as_markup ()
|
||||
@@ -0,0 +1,119 @@
|
||||
from aiogram .utils .keyboard import InlineKeyboardBuilder ,InlineKeyboardButton
|
||||
from aiogram .types import InlineKeyboardMarkup
|
||||
from typing import Dict ,Optional ,List
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
|
||||
def get_main_menu_inline_keyboard (
|
||||
lang :str ,
|
||||
i18n_instance ,
|
||||
settings :Settings ,
|
||||
show_trial_button :bool =False
|
||||
)->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
|
||||
|
||||
if show_trial_button and settings .TRIAL_ENABLED :
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="menu_activate_trial_button"),callback_data ="main_action:request_trial"))
|
||||
|
||||
|
||||
builder .row (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"))
|
||||
|
||||
|
||||
referral_button =InlineKeyboardButton (text =_ (key ="menu_referral_inline"),callback_data ="main_action:referral")
|
||||
promo_button =InlineKeyboardButton (text =_ (key ="menu_apply_promo_button"),callback_data ="main_action:apply_promo")
|
||||
builder .row (referral_button ,promo_button )
|
||||
|
||||
|
||||
language_button =InlineKeyboardButton (text =_ (key ="menu_language_settings_inline"),callback_data ="main_action:language")
|
||||
status_button_list =[]
|
||||
if settings .SERVER_STATUS_URL :
|
||||
status_button_list .append (InlineKeyboardButton (text =_ (key ="menu_server_status_button"),url =settings .SERVER_STATUS_URL ))
|
||||
|
||||
if status_button_list :
|
||||
builder .row (language_button ,*status_button_list )
|
||||
else :
|
||||
builder .row (language_button )
|
||||
|
||||
|
||||
if settings .SUPPORT_LINK :
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="menu_support_button"),url =settings .SUPPORT_LINK ))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return builder .as_markup ()
|
||||
|
||||
|
||||
|
||||
def get_language_selection_keyboard (i18n_instance ,current_lang :str )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (current_lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =f"🇬🇧 English {'✅' if current_lang == 'en' else ''}",callback_data ="set_lang_en")
|
||||
builder .button (text =f"🇷🇺 Русский {'✅' if current_lang == 'ru' else ''}",callback_data ="set_lang_ru")
|
||||
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_trial_confirmation_keyboard (lang :str ,i18n_instance )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="trial_confirm_activate_button"),callback_data ="trial_action:confirm_activate")
|
||||
builder .button (text =_ (key ="cancel_button"),callback_data ="main_action:back_to_main")
|
||||
builder .adjust (1 )
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_subscription_options_keyboard (subscription_options :Dict [int ,Optional [int ]],currency_symbol_val :str ,lang :str ,i18n_instance )->InlineKeyboardMarkup :
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
if subscription_options :
|
||||
for months ,price in subscription_options .items ():
|
||||
if price is not None :
|
||||
button_text =_ ("subscribe_for_months_button",months =months ,price =price ,currency_symbol =currency_symbol_val )
|
||||
builder .button (text =button_text ,callback_data =f"subscribe_period:{months}")
|
||||
builder .adjust (1 )
|
||||
builder .row (InlineKeyboardButton (text =_ (key ="back_to_main_menu_button"),callback_data ="main_action:back_to_main"))
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_confirm_subscription_keyboard (months :int ,price :float ,currency_symbol_val :str ,lang :str ,i18n_instance )->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}")
|
||||
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 :
|
||||
_ =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")
|
||||
builder .adjust (1 )
|
||||
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 ="back_to_main_menu_button"),callback_data ="main_action:back_to_main")
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_back_to_main_menu_markup (lang :str ,i18n_instance )->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")
|
||||
return builder .as_markup ()
|
||||
|
||||
def get_user_banned_keyboard (support_link :Optional [str ],lang :str ,i18n_instance )->Optional [InlineKeyboardMarkup ]:
|
||||
if not support_link :
|
||||
return None
|
||||
_ =lambda key ,**kwargs :i18n_instance .gettext (lang ,key ,**kwargs )
|
||||
builder =InlineKeyboardBuilder ()
|
||||
builder .button (text =_ (key ="contact_support_button"),url =support_link )
|
||||
return builder .as_markup ()
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from aiogram import Bot ,Dispatcher ,Router ,F
|
||||
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 config .settings import Settings ,get_settings
|
||||
from .middlewares .i18n import I18nMiddleware ,get_i18n_instance ,JsonI18n
|
||||
from .middlewares .ban_check_middleware import BanCheckMiddleware
|
||||
from .middlewares .action_logger_middleware import ActionLoggerMiddleware
|
||||
|
||||
from .handlers .user import user_router_aggregate
|
||||
from .handlers .user import payment as user_payment_webhook_module
|
||||
|
||||
from .handlers .admin import admin_router_aggregate
|
||||
from .filters .admin_filter import AdminFilter
|
||||
|
||||
from db .database import get_db_connection_manager
|
||||
|
||||
from .services .notification_service import schedule_subscription_notifications
|
||||
from .services .payment_service import YooKassaService
|
||||
from .services .panel_api_service import PanelApiService
|
||||
from .services .subscription_service import SubscriptionService
|
||||
from .services .referral_service import ReferralService
|
||||
from .services .promo_code_service import PromoCodeService
|
||||
|
||||
|
||||
async def register_all_routers (dp :Dispatcher ,settings :Settings ):
|
||||
dp .include_router (user_router_aggregate )
|
||||
admin_filtered_router_wrapper =Router (name ="admin_filtered_router_wrapper")
|
||||
admin_filter_instance =AdminFilter (admin_ids =settings .ADMIN_IDS )
|
||||
admin_filtered_router_wrapper .message .filter (admin_filter_instance )
|
||||
admin_filtered_router_wrapper .callback_query .filter (admin_filter_instance )
|
||||
admin_filtered_router_wrapper .include_router (admin_router_aggregate )
|
||||
dp .include_router (admin_filtered_router_wrapper )
|
||||
logging .info ("All application routers registered.")
|
||||
|
||||
async def on_startup_configured (dispatcher :Dispatcher ):
|
||||
|
||||
bot :Bot =dispatcher ["bot_instance"];settings :Settings =dispatcher ["settings"];i18n_instance :JsonI18n =dispatcher ["i18n_instance"]
|
||||
logging .info ("STARTUP: on_startup_configured executing...")
|
||||
scheduler =AsyncIOScheduler (timezone ="UTC")
|
||||
try :await schedule_subscription_notifications (bot ,settings ,i18n_instance ,scheduler );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 )
|
||||
if telegram_webhook_url_to_set :
|
||||
if settings .BOT_TOKEN in telegram_webhook_url_to_set :logging .error (f"CRITICAL: Bot token in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}').");full_telegram_webhook_url ="ERROR_URL"
|
||||
else :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}")
|
||||
try :
|
||||
current_webhook_info_before =await bot .get_webhook_info ();logging .info (f"STARTUP: Current webhook info BEFORE: {current_webhook_info_before.model_dump_json(exclude_none=True, indent=2)}")
|
||||
if full_telegram_webhook_url !="ERROR_URL":
|
||||
set_success =await bot .set_webhook (url =full_telegram_webhook_url ,drop_pending_updates =True ,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).")
|
||||
else :logging .error (f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned FAILURE (False).")
|
||||
new_webhook_info =await bot .get_webhook_info ();logging .info (f"STARTUP: Webhook info AFTER: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}")
|
||||
if not new_webhook_info .url :logging .error ("STARTUP: CRITICAL - Webhook URL EMPTY after set attempt.")
|
||||
else :logging .error ("STARTUP: Skipped setting webhook due to URL config error.")
|
||||
except Exception as e_setwebhook :logging .error (f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",exc_info =True )
|
||||
else :
|
||||
logging .info ("STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set. Attempting to delete webhook.");await bot .delete_webhook (drop_pending_updates =True )
|
||||
logging .info ("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured (dispatcher :Dispatcher ):
|
||||
|
||||
logging .warning ("SHUTDOWN: on_shutdown_configured executing...");scheduler :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 APScheduler: {e}",exc_info =True )
|
||||
panel_service :PanelApiService =dispatcher .get ("panel_service")
|
||||
if panel_service and panel_service ._session and not panel_service ._session .closed :await panel_service .close_session ()
|
||||
bot :Bot =dispatcher .get ("bot_instance")
|
||||
if bot and bot .session and not bot .session .closed :await bot .session .close ()
|
||||
logging .info ("SHUTDOWN: Bot on_shutdown_configured completed.")
|
||||
|
||||
|
||||
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 )
|
||||
|
||||
dp =Dispatcher (storage =storage ,settings =settings_param ,bot_instance =bot )
|
||||
|
||||
actual_bot_username ="your_bot_username"
|
||||
try :
|
||||
bot_info =await bot .get_me ()
|
||||
actual_bot_username =bot_info .username
|
||||
logging .info (f"Bot username: @{actual_bot_username}")
|
||||
except Exception as e :
|
||||
logging .error (f"Failed to get bot info: {e}. Using fallback username: {actual_bot_username}")
|
||||
|
||||
default_lang =settings_param .DEFAULT_LANGUAGE
|
||||
i18n_instance =get_i18n_instance (path ="locales",default =default_lang )
|
||||
|
||||
|
||||
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 =actual_bot_username ,
|
||||
settings_obj =settings_param
|
||||
)
|
||||
|
||||
panel_service =PanelApiService (settings_param )
|
||||
subscription_service =SubscriptionService (get_db_connection_manager ,settings_param ,panel_service )
|
||||
referral_service =ReferralService (get_db_connection_manager ,settings_param ,subscription_service ,bot ,i18n_instance )
|
||||
promo_code_service =PromoCodeService (get_db_connection_manager ,settings_param ,subscription_service ,bot ,i18n_instance )
|
||||
|
||||
|
||||
dp ["i18n_instance"]=i18n_instance
|
||||
dp ["i18n_data"]={"i18n_instance":i18n_instance ,"current_language":default_lang }
|
||||
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
|
||||
|
||||
|
||||
|
||||
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 (on_shutdown_configured )
|
||||
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 )
|
||||
logging .info (f"--- Determining Run Mode ---");logging .info (f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}'");logging .info (f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}'")
|
||||
should_run_aiohttp =bool (yk_webhook_base and settings_param .yookassa_webhook_path )or bool (tg_webhook_base )
|
||||
telegram_uses_webhook =bool (tg_webhook_base );telegram_should_poll =not telegram_uses_webhook
|
||||
logging .info (f"Decision: Run AIOHTTP server: {should_run_aiohttp}");logging .info (f"Decision: Telegram uses webhook: {telegram_uses_webhook}");logging .info (f"Decision: Telegram should poll: {telegram_should_poll}");logging .info (f"--- End Run Mode Decision ---")
|
||||
web_app_runner =None ;main_tasks_to_await =[]
|
||||
if should_run_aiohttp :
|
||||
app =web .Application ();app ['bot']=bot ;app ['dp']=dp ;app ['settings']=settings_param ;app ['i18n']=i18n_instance
|
||||
app ['yookassa_service']=yookassa_service ;app ['panel_service']=panel_service ;app ['subscription_service']=subscription_service
|
||||
app ['referral_service']=referral_service ;app ['promo_code_service']=promo_code_service
|
||||
setup_application (app ,dp ,bot =bot );logging .info ("AIOHTTP app context populated and dispatcher lifecycle linked.")
|
||||
if telegram_uses_webhook :telegram_webhook_path =f"/{settings_param.BOT_TOKEN}";app .router .add_post (telegram_webhook_path ,SimpleRequestHandler (dispatcher =dp ,bot =bot ));logging .info (f"Telegram webhook route: {telegram_webhook_path}.")
|
||||
if yk_webhook_base and settings_param .yookassa_webhook_path :app .router .add_post (settings_param .yookassa_webhook_path ,user_payment_webhook_module .yookassa_webhook_route );logging .info (f"YK webhook route: {settings_param.yookassa_webhook_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_wrapper ():await site .start ();logging .info (f"AIOHTTP server started on {settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}.");await asyncio .Event ().wait ()if not telegram_should_poll else await asyncio .sleep (31536000 )
|
||||
main_tasks_to_await .append (asyncio .create_task (web_server_task_wrapper (),name ="AIOHTTPServerWrapperTask"))
|
||||
if telegram_should_poll :logging .info ("TG polling task created.");main_tasks_to_await .append (asyncio .create_task (dp .start_polling (bot ,allowed_updates =dp .resolve_used_update_types ()),name ="TelegramPollingTask"))
|
||||
if not main_tasks_to_await :logging .error ("Bot not configured for any mode.");await dp .emit_shutdown ();return
|
||||
logging .info (f"Starting bot with main tasks: {[task.get_name() for task in main_tasks_to_await]}")
|
||||
try :
|
||||
if main_tasks_to_await :await asyncio .gather (*main_tasks_to_await )
|
||||
except (KeyboardInterrupt ,SystemExit ,asyncio .CancelledError )as e :logging .info (f"Main loop interrupted: {type(e).__name__}")
|
||||
finally :
|
||||
logging .info ("Initiating final shutdown sequence...");
|
||||
for task in main_tasks_to_await :
|
||||
if task and not task .done ():task .cancel ();
|
||||
try :await task
|
||||
except asyncio .CancelledError :logging .info (f"Task '{task.get_name()}' cancelled.")
|
||||
except Exception as e_cancel :logging .error (f"Error cancelling task '{task.get_name()}': {e_cancel}",exc_info =True )
|
||||
if web_app_runner :await web_app_runner .cleanup ();logging .info ("AIOHTTP AppRunner cleaned up.")
|
||||
if not (telegram_should_poll and len (main_tasks_to_await )==1 and any (t .get_name ()=="TelegramPollingTask"for t in main_tasks_to_await if t and not t .done ())):
|
||||
logging .info ("Explicitly calling dp.emit_shutdown() in run_bot finally.")
|
||||
await dp .emit_shutdown ()
|
||||
if bot .session and not bot .session .closed :await bot .session .close ()
|
||||
logging .info ("Bot run_bot function finished.")
|
||||
@@ -0,0 +1,94 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Callable ,Dict ,Any ,Awaitable ,Union ,Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram .types import Update ,Message ,CallbackQuery ,User
|
||||
|
||||
from db .database import log_user_action
|
||||
from config .settings import Settings
|
||||
|
||||
class ActionLoggerMiddleware (BaseMiddleware ):
|
||||
def __init__ (self ,settings :Settings ):
|
||||
super ().__init__ ()
|
||||
self .settings =settings
|
||||
|
||||
async def __call__ (
|
||||
self ,
|
||||
handler :Callable [[Update ,Dict [str ,Any ]],Awaitable [Any ]],
|
||||
event :Update ,
|
||||
data :Dict [str ,Any ]
|
||||
)->Any :
|
||||
|
||||
event_user :Optional [User ]=data .get ("event_from_user")
|
||||
bot :Optional [Bot ]=data .get ("bot")
|
||||
|
||||
user_id :Optional [int ]=None
|
||||
telegram_username :Optional [str ]=None
|
||||
telegram_first_name :Optional [str ]=None
|
||||
event_type :str =event .event_type
|
||||
content :Optional [str ]=None
|
||||
is_admin_event_flag :bool =False
|
||||
|
||||
if event_user :
|
||||
user_id =event_user .id
|
||||
telegram_username =event_user .username
|
||||
telegram_first_name =event_user .first_name
|
||||
if user_id ==self .settings .ADMIN_IDS :
|
||||
is_admin_event_flag =True
|
||||
|
||||
raw_update_snippet =None
|
||||
try :
|
||||
|
||||
raw_update_snippet =event .model_dump_json (exclude_none =True ,indent =None )[:1000 ]
|
||||
except Exception :
|
||||
raw_update_snippet =str (event )[:1000 ]
|
||||
|
||||
|
||||
if event .message :
|
||||
msg =event .message
|
||||
if msg .text :
|
||||
content =msg .text
|
||||
if msg .text .startswith ('/'):
|
||||
event_type ="command"
|
||||
elif msg .caption :
|
||||
content =f"[{msg.content_type}] {msg.caption}"
|
||||
else :
|
||||
content =f"[{msg.content_type}]"
|
||||
|
||||
elif event .callback_query :
|
||||
cb =event .callback_query
|
||||
event_type ="callback_query"
|
||||
content =cb .data
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if user_id and event_type and content :
|
||||
try :
|
||||
await log_user_action (
|
||||
user_id =user_id ,
|
||||
telegram_username =telegram_username ,
|
||||
telegram_first_name =telegram_first_name ,
|
||||
event_type =event_type ,
|
||||
content =content [:1000 ],
|
||||
raw_update_preview =raw_update_snippet ,
|
||||
is_admin_event =is_admin_event_flag
|
||||
)
|
||||
except Exception as e_log :
|
||||
logging .error (f"ActionLoggerMiddleware: Failed to log event for user {user_id}: {e_log}",exc_info =True )
|
||||
elif user_id and event_type :
|
||||
try :
|
||||
await log_user_action (user_id =user_id ,telegram_username =telegram_username ,telegram_first_name =telegram_first_name ,event_type =event_type ,content ="N/A",raw_update_preview =raw_update_snippet ,is_admin_event =is_admin_event_flag )
|
||||
except Exception as e_log :
|
||||
logging .error (f"ActionLoggerMiddleware: Failed to log event (no content) for user {user_id}: {e_log}",exc_info =True )
|
||||
|
||||
|
||||
|
||||
return await handler (event ,data )
|
||||
@@ -0,0 +1,132 @@
|
||||
import logging
|
||||
from typing import Callable ,Dict ,Any ,Awaitable ,Union ,Optional
|
||||
|
||||
from aiogram import BaseMiddleware ,Bot
|
||||
from aiogram .types import Message ,CallbackQuery ,User ,InlineKeyboardMarkup ,Update
|
||||
from aiogram .utils .keyboard import InlineKeyboardBuilder
|
||||
from aiogram .exceptions import TelegramAPIError ,TelegramForbiddenError ,TelegramBadRequest ,AiogramError
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_user
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from bot .keyboards .inline .user_keyboards import get_user_banned_keyboard
|
||||
|
||||
class BanCheckMiddleware (BaseMiddleware ):
|
||||
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 :
|
||||
event_user :Optional [User ]=data .get ("event_from_user")
|
||||
if not event_user :return await handler (event ,data )
|
||||
|
||||
|
||||
if event_user .id in self .settings .ADMIN_IDS :
|
||||
return await handler (event ,data )
|
||||
|
||||
try :
|
||||
db_user_data =await get_user (user_id =event_user .id )
|
||||
except Exception as e_db :
|
||||
logging .error (f"BanCheckMiddleware: DB error fetching user {event_user.id}: {e_db}",exc_info =True )
|
||||
return await handler (event ,data )
|
||||
|
||||
if db_user_data and db_user_data ['is_banned']==1 :
|
||||
logging .info (f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access and preparing notification.")
|
||||
|
||||
ban_message_text ="You are blocked. Please contact support."
|
||||
keyboard :Optional [InlineKeyboardMarkup ]=None
|
||||
current_lang =self .settings .DEFAULT_LANGUAGE
|
||||
i18n_to_use :Optional [JsonI18n ]=None
|
||||
actual_event_object :Optional [Union [Message ,CallbackQuery ]]=None
|
||||
|
||||
|
||||
if event .message :
|
||||
actual_event_object =event .message
|
||||
elif event .callback_query :
|
||||
actual_event_object =event .callback_query
|
||||
|
||||
|
||||
if not actual_event_object :
|
||||
logging .warning (f"BanCheck: Could not determine specific event type (Message/CallbackQuery) for banned user {event_user.id} from Update object. Update type: {event.type}")
|
||||
|
||||
try :
|
||||
bot_instance :Bot =data ["bot"]
|
||||
await bot_instance .send_message (event_user .id ,ban_message_text )
|
||||
except Exception as e_direct_send :
|
||||
logging .error (f"BanCheck: Failed to send direct ban message to {event_user.id}: {e_direct_send}")
|
||||
return
|
||||
|
||||
try :
|
||||
logging .debug ("BanCheck: [A] Inside main try block for banned user notification.")
|
||||
i18n_data_from_event =data .get ("i18n_data",{})
|
||||
current_lang =i18n_data_from_event .get ("current_language",self .settings .DEFAULT_LANGUAGE )
|
||||
i18n_to_use =i18n_data_from_event .get ("i18n_instance")
|
||||
logging .debug (f"BanCheck: [B] i18n_instance from event_data: {type(i18n_to_use)}. Current lang: {current_lang}")
|
||||
|
||||
if not i18n_to_use :
|
||||
i18n_to_use =self .i18n_main_instance
|
||||
logging .warning (f"BanCheck: [B_fallback] Using fallback i18n instance for banned user {event_user.id}. Type: {type(i18n_to_use)}")
|
||||
|
||||
if i18n_to_use :
|
||||
_ =lambda k ,**kw :i18n_to_use .gettext (current_lang ,k ,**kw )
|
||||
logging .debug ("BanCheck: [D] Attempting to get 'user_is_banned' text.")
|
||||
ban_message_text =_ ("user_is_banned")
|
||||
logging .debug (f"BanCheck: [E] Ban message text: '{ban_message_text}'")
|
||||
if self .settings .SUPPORT_LINK :
|
||||
logging .debug ("BanCheck: [F] Support link found. Attempting to get user_banned_keyboard.")
|
||||
keyboard =get_user_banned_keyboard (self .settings .SUPPORT_LINK ,current_lang ,i18n_to_use )
|
||||
logging .debug (f"BanCheck: [G] Keyboard created: {keyboard is not None}")
|
||||
else :logging .debug ("BanCheck: [F_alt] No support link configured.")
|
||||
else :
|
||||
logging .error (f"BanCheck: [CRITICAL] No i18n instance for user {event_user.id}. Using hardcoded text.")
|
||||
if self .settings .SUPPORT_LINK :
|
||||
kb_temp =InlineKeyboardBuilder ();kb_temp .button (text ="Support",url =self .settings .SUPPORT_LINK );keyboard =kb_temp .as_markup ()
|
||||
logging .debug ("BanCheck: [G_alt] Fallback keyboard created due to no i18n.")
|
||||
|
||||
logging .debug (f"BanCheck: [H] Final pre-send check. Message: '{ban_message_text}', Keyboard: {keyboard is not None}")
|
||||
|
||||
|
||||
if isinstance (actual_event_object ,Message ):
|
||||
logging .debug (f"BanCheck: [I_Msg] Attempting actual_event_object.answer for Message to user {event_user.id}")
|
||||
await actual_event_object .answer (ban_message_text ,reply_markup =keyboard )
|
||||
logging .info (f"BanCheck: [J_Msg] Ban notification 'actual_event_object.answer' attempted for user {event_user.id} (Message).")
|
||||
elif isinstance (actual_event_object ,CallbackQuery ):
|
||||
logging .debug (f"BanCheck: [I_CB] Attempting actual_event_object.answer (alert) for CallbackQuery to user {event_user.id}")
|
||||
await actual_event_object .answer (ban_message_text ,show_alert =True )
|
||||
logging .info (f"BanCheck: [J_CB] Ban alert 'actual_event_object.answer' attempted for user {event_user.id} (CallbackQuery).")
|
||||
|
||||
target_message_obj =actual_event_object .message
|
||||
if target_message_obj :
|
||||
target_chat_id =target_message_obj .chat .id
|
||||
try :
|
||||
logging .debug (f"BanCheck: [K_CB_Edit] Attempting target_message_obj.edit_text for user {event_user.id}")
|
||||
await target_message_obj .edit_text (ban_message_text ,reply_markup =keyboard )
|
||||
logging .info (f"BanCheck: [L_CB_Edit] Ban msg 'target_message_obj.edit_text' attempted for user {event_user.id} (Callback).")
|
||||
except Exception as e_edit :
|
||||
logging .warning (f"BanCheck: [M_CB_EditFail] Failed to edit message for banned user {event_user.id}: {type(e_edit).__name__} - {e_edit}. Sending new message.")
|
||||
await actual_event_object .bot .send_message (target_chat_id ,ban_message_text ,reply_markup =keyboard )
|
||||
logging .info (f"BanCheck: [N_CB_NewMsg] Ban msg 'actual_event_object.bot.send_message' (after edit fail) attempted for user {event_user.id} (Callback).")
|
||||
else :
|
||||
logging .warning (f"BanCheck: [K_CB_NoMsg] CallbackQuery from {event_user.id} has no .message attribute. Sending new message directly.")
|
||||
await actual_event_object .bot .send_message (actual_event_object .from_user .id ,ban_message_text ,reply_markup =keyboard )
|
||||
logging .info (f"BanCheck: [L_CB_NoMsg_NewMsg] Ban msg 'actual_event_object.bot.send_message' (no .message) attempted for user {event_user.id} (Callback).")
|
||||
else :
|
||||
logging .error (f"BanCheck: [UNHANDLED_EVENT_TYPE_INTERNAL] actual_event_object type {type(actual_event_object)} was not Message or CallbackQuery.")
|
||||
|
||||
except TelegramForbiddenError as e_forbidden :logging .warning (f"BanCheck: TelegramForbiddenError sending ban msg to {event_user.id}: {e_forbidden}")
|
||||
except TelegramBadRequest as e_bad_req :logging .error (f"BanCheck: TelegramBadRequest sending ban msg to {event_user.id}: {e_bad_req}",exc_info =True )
|
||||
except TelegramAPIError as e_api :logging .error (f"BanCheck: TelegramAPIError sending ban msg to {event_user.id}: {e_api}",exc_info =True )
|
||||
except AiogramError as e_aio :logging .error (f"BanCheck: AiogramError sending ban msg to {event_user.id}: {e_aio}",exc_info =True )
|
||||
except Exception as e_general :
|
||||
logging .error (f"BanCheck: Generic failure preparing or sending ban notification to user {event_user.id}: {e_general}",exc_info =True )
|
||||
|
||||
logging .debug (f"BanCheck: [Z] End of ban processing for user {event_user.id}. Returning to stop further handlers.")
|
||||
return
|
||||
|
||||
return await handler (event ,data )
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import aiosqlite
|
||||
from typing import Any ,Awaitable ,Callable ,Dict ,Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram .types import TelegramObject ,User ,Update
|
||||
|
||||
|
||||
from db .database import get_user ,get_db_connection_manager ,_setup_db_connection
|
||||
from config .settings import Settings
|
||||
|
||||
|
||||
|
||||
|
||||
class JsonI18n :
|
||||
def __init__ (self ,path :str ,default :str ="en",domain :str ="bot"):
|
||||
self .domain =domain
|
||||
self .path =path
|
||||
self .default_lang =default
|
||||
self .locales_data :Dict [str ,Dict [str ,str ]]={}
|
||||
self ._load_locales ()
|
||||
logging .info (f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}")
|
||||
|
||||
def _load_locales (self ):
|
||||
if not os .path .isdir (self .path ):logging .error (f"Locales path not found: {self.path}");return
|
||||
for item in os .listdir (self .path ):
|
||||
if item .endswith (".json"):
|
||||
lang_code =item .split (".")[0 ];file_path =os .path .join (self .path ,item )
|
||||
try :
|
||||
with open (file_path ,"r",encoding ="utf-8")as f :self .locales_data [lang_code ]=json .load (f )
|
||||
except Exception as e :logging .error (f"Error loading locale {lang_code} from {file_path}: {e}")
|
||||
|
||||
def gettext (self ,lang_code :Optional [str ],key :str ,**kwargs )->str :
|
||||
effective_lang_code =lang_code
|
||||
if not effective_lang_code or effective_lang_code not in self .locales_data :
|
||||
effective_lang_code =self .default_lang
|
||||
lang_data =self .locales_data .get (effective_lang_code )
|
||||
if lang_data is None :
|
||||
logging .warning (f"No language data for '{effective_lang_code}' (default: '{self.default_lang}'). Key '{key}'.");return key .format (**kwargs )if kwargs else key
|
||||
text =lang_data .get (key )
|
||||
if text is None :
|
||||
if effective_lang_code !=self .default_lang :
|
||||
default_lang_data =self .locales_data .get (self .default_lang ,{});text =default_lang_data .get (key )
|
||||
if text is None :return key .format (**kwargs )if kwargs else key
|
||||
try :return text .format (**kwargs )if kwargs else text
|
||||
except KeyError as e :logging .warning (f"Missing format key {e} for key '{key}' (lang: {effective_lang_code}). Text: '{text}'");return text
|
||||
except Exception as e :logging .error (f"Error formatting key '{key}' (lang: {effective_lang_code}): {e}. Text: '{text}'");return text
|
||||
|
||||
_i18n_instance :Optional [JsonI18n ]=None
|
||||
def get_i18n_instance (path :str ="locales",default :str ="en",domain :str ="bot")->JsonI18n :
|
||||
global _i18n_instance
|
||||
if _i18n_instance is None :_i18n_instance =JsonI18n (path =path ,default =default ,domain =domain )
|
||||
return _i18n_instance
|
||||
|
||||
|
||||
class I18nMiddleware (BaseMiddleware ):
|
||||
def __init__ (self ,i18n :JsonI18n ,settings :Settings ):
|
||||
super ().__init__ ()
|
||||
self .i18n =i18n
|
||||
self .settings =settings
|
||||
|
||||
async def __call__ (
|
||||
self ,
|
||||
handler :Callable [[Update ,Dict [str ,Any ]],Awaitable [Any ]],
|
||||
event :Update ,
|
||||
data :Dict [str ,Any ]
|
||||
)->Any :
|
||||
event_user :Optional [User ]=data .get ("event_from_user")
|
||||
|
||||
current_language =self .i18n .default_lang
|
||||
|
||||
|
||||
if event_user :
|
||||
logging .debug (f"I18nMiddleware: Processing for user {event_user.id}")
|
||||
|
||||
try :
|
||||
|
||||
user_db_data =await get_user (event_user .id )
|
||||
if user_db_data and user_db_data ['language_code']and user_db_data ['language_code']in self .i18n .locales_data :
|
||||
current_language =user_db_data ['language_code']
|
||||
logging .debug (f"I18nMiddleware: User {event_user.id} language loaded from DB: {current_language}")
|
||||
else :
|
||||
|
||||
if event_user .language_code :
|
||||
lang_prefix =event_user .language_code .split ('-')[0 ]
|
||||
if lang_prefix in self .i18n .locales_data :
|
||||
current_language =lang_prefix
|
||||
logging .debug (f"I18nMiddleware: User {event_user.id} language set from Telegram client (prefix): {current_language}")
|
||||
elif event_user .language_code in self .i18n .locales_data :
|
||||
current_language =event_user .language_code
|
||||
logging .debug (f"I18nMiddleware: User {event_user.id} language set from Telegram client (full): {current_language}")
|
||||
else :
|
||||
|
||||
logging .debug (f"I18nMiddleware: User {event_user.id} Telegram client language '{event_user.language_code}' not supported. Using default: {current_language}")
|
||||
else :
|
||||
logging .debug (f"I18nMiddleware: User {event_user.id} has no language_code from Telegram. Using default: {current_language}")
|
||||
except Exception as e_db_lang :
|
||||
|
||||
logging .error (f"I18nMiddleware: Error fetching user language from DB for user {event_user.id}: {e_db_lang}. Falling back.")
|
||||
if event_user .language_code :
|
||||
lang_prefix =event_user .language_code .split ('-')[0 ]
|
||||
if lang_prefix in self .i18n .locales_data :current_language =lang_prefix
|
||||
elif event_user .language_code in self .i18n .locales_data :current_language =event_user .language_code
|
||||
|
||||
data ["i18n_data"]={
|
||||
"i18n_instance":self .i18n ,
|
||||
"current_language":current_language
|
||||
}
|
||||
logging .debug (f"I18nMiddleware: Final current_language for event: {current_language}")
|
||||
|
||||
return await handler (event ,data )
|
||||
@@ -0,0 +1,97 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from apscheduler .schedulers .asyncio import AsyncIOScheduler
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
from db .database import get_db_connection_manager
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
|
||||
async def send_expiration_warnings (bot :Bot ,settings :Settings ,i18n :JsonI18n ,panel_service :PanelApiService ):
|
||||
"""
|
||||
Sends expiration warnings to users whose subscriptions are ending soon.
|
||||
This function is called by the scheduler.
|
||||
"""
|
||||
logging .info (f"Scheduler job: Checking for expiring subscriptions at {datetime.now()}...")
|
||||
|
||||
|
||||
sub_service =SubscriptionService (get_db_connection_manager ,settings ,panel_service )
|
||||
|
||||
expiring_subs =await sub_service .get_subscriptions_ending_soon (settings .SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS )
|
||||
|
||||
if not expiring_subs :
|
||||
logging .info ("No subscriptions found ending soon for notification.")
|
||||
return
|
||||
|
||||
logging .info (f"Found {len(expiring_subs)} subscriptions ending soon for notification.")
|
||||
for sub_info in expiring_subs :
|
||||
user_id =sub_info ['user_id']
|
||||
if not user_id :
|
||||
logging .warning (f"Skipping notification for subscription without user_id: {sub_info}")
|
||||
continue
|
||||
|
||||
user_lang =sub_info .get ('language_code')if sub_info .get ('language_code')else getattr (settings ,'DEFAULT_LANGUAGE','en')
|
||||
first_name =sub_info .get ('first_name','User')
|
||||
end_date_str =sub_info ['end_date_str']
|
||||
|
||||
days_left_float =sub_info .get ('days_left')
|
||||
days_left_display ='N/A'
|
||||
if days_left_float is not None :
|
||||
|
||||
days_left_display =max (0 ,int (round (days_left_float )))
|
||||
|
||||
_ =lambda key ,**kwargs :i18n .gettext (user_lang ,key ,**kwargs )
|
||||
message_text =_ (
|
||||
"subscription_ending_soon_notification",
|
||||
user_name =first_name ,
|
||||
end_date =end_date_str ,
|
||||
days_left =days_left_display
|
||||
)
|
||||
try :
|
||||
await bot .send_message (user_id ,message_text )
|
||||
|
||||
await sub_service .update_last_notification_sent (user_id ,end_date_str )
|
||||
logging .info (f"Sent expiration warning to user {user_id} for subscription ending {end_date_str}")
|
||||
except Exception as e :
|
||||
|
||||
logging .error (f"Failed to send expiration warning to user {user_id}: {e}")
|
||||
await asyncio .sleep (0.1 )
|
||||
|
||||
|
||||
async def schedule_subscription_notifications (bot :Bot ,settings :Settings ,i18n :JsonI18n ,scheduler :AsyncIOScheduler ):
|
||||
"""Schedules the daily job to send expiration warnings."""
|
||||
|
||||
|
||||
async def job_wrapper ():
|
||||
panel_service =PanelApiService (settings )
|
||||
try :
|
||||
|
||||
await send_expiration_warnings (bot ,settings ,i18n ,panel_service )
|
||||
except Exception as e :
|
||||
logging .error (f"Error in scheduled job 'send_expiration_warnings': {e}",exc_info =True )
|
||||
finally :
|
||||
await panel_service .close_session ()
|
||||
|
||||
|
||||
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. Defaulting to 9:00 UTC.")
|
||||
notification_hour =9
|
||||
notification_minute =0
|
||||
|
||||
scheduler .add_job (
|
||||
job_wrapper ,
|
||||
'cron',
|
||||
hour =notification_hour ,
|
||||
minute =notification_minute ,
|
||||
name ="daily_subscription_expiration_warnings",
|
||||
misfire_grace_time =60 *15
|
||||
)
|
||||
logging .info (f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC.")
|
||||
@@ -0,0 +1,183 @@
|
||||
import aiohttp
|
||||
import logging
|
||||
from typing import Optional ,List ,Dict ,Any
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
class PanelApiService :
|
||||
def __init__ (self ,settings :Settings ):
|
||||
self .settings =settings
|
||||
self .base_url =settings .PANEL_API_URL
|
||||
self .api_key =settings .PANEL_API_KEY
|
||||
self ._session :Optional [aiohttp .ClientSession ]=None
|
||||
self .default_client_ip ="127.0.0.1"
|
||||
|
||||
async def _get_session (self )->aiohttp .ClientSession :
|
||||
if self ._session is None or self ._session .closed :
|
||||
self ._session =aiohttp .ClientSession ()
|
||||
return self ._session
|
||||
|
||||
async def close_session (self ):
|
||||
if self ._session and not self ._session .closed :
|
||||
await self ._session .close ()
|
||||
self ._session =None
|
||||
logging .info ("Panel API service session closed.")
|
||||
|
||||
async def _prepare_headers (self )->Dict [str ,str ]:
|
||||
headers ={
|
||||
"Content-Type":"application/json",
|
||||
"X-Forwarded-Proto":"https",
|
||||
"X-Forwarded-For":self .default_client_ip ,
|
||||
"X-Real-IP":self .default_client_ip ,
|
||||
}
|
||||
if self .api_key :
|
||||
headers ["Authorization"]=f"Bearer {self.api_key}"
|
||||
|
||||
|
||||
return headers
|
||||
|
||||
async def _request (self ,method :str ,endpoint :str ,**kwargs )->Optional [Dict [str ,Any ]]:
|
||||
if not self .base_url :
|
||||
logging .error ("Panel API URL not configured.")
|
||||
return {"error":True ,"status_code":0 ,"message":"Panel API URL not configured."}
|
||||
|
||||
session =await self ._get_session ()
|
||||
headers =await self ._prepare_headers ()
|
||||
|
||||
if "Authorization"not in headers and self .api_key :
|
||||
logging .warning (f"Authorization header missing for panel endpoint {endpoint} despite API key being set.")
|
||||
|
||||
url =f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
json_payload_for_log =kwargs .get ('json')if method in ["POST","PATCH","PUT"]else None
|
||||
log_prefix =f"Panel API {method} {url}"
|
||||
if json_payload_for_log :log_prefix +=f" Payload: {json_payload_for_log}"
|
||||
|
||||
try :
|
||||
async with session .request (method ,url ,headers =headers ,**kwargs )as response :
|
||||
if 200 <=response .status <300 :
|
||||
try :
|
||||
data =await response .json ()
|
||||
logging .debug (f"{log_prefix} - Success ({response.status})")
|
||||
return data
|
||||
except aiohttp .ContentTypeError :
|
||||
logging .debug (f"{log_prefix} - Success ({response.status}) with non-JSON response.")
|
||||
return {"status":"success","code":response .status ,"data_text":await response .text ()}
|
||||
else :
|
||||
try :
|
||||
error_json =await response .json ()
|
||||
logging .error (f"{log_prefix} - Failed ({response.status}): {error_json}")
|
||||
return {"error":True ,"status_code":response .status ,"response":error_json ,
|
||||
"message":error_json .get ("message"),"errorCode":error_json .get ("errorCode")}
|
||||
except aiohttp .ContentTypeError :
|
||||
error_text =await response .text ()
|
||||
logging .error (f"{log_prefix} - Failed ({response.status}): {error_text}")
|
||||
return {"error":True ,"status_code":response .status ,"message":error_text }
|
||||
except aiohttp .ClientError as e :
|
||||
logging .error (f"Panel API client request error to {url}: {e}")
|
||||
return {"error":True ,"status_code":-1 ,"message":str (e )}
|
||||
except Exception as e :
|
||||
logging .error (f"Unexpected Panel API request error to {url}: {e}",exc_info =True )
|
||||
return {"error":True ,"status_code":-2 ,"message":f"Unexpected error: {str(e)}"}
|
||||
|
||||
async def get_users_by_filter (self ,username :Optional [str ]=None )->Optional [List [Dict [str ,Any ]]]:
|
||||
"""Fetches users from panel by username."""
|
||||
if not username :
|
||||
logging .warning ("get_users_by_filter called without username.")
|
||||
return None
|
||||
|
||||
params ={"username":username }
|
||||
response_data =await self ._request ("GET","/users",params =params )
|
||||
|
||||
if response_data and not response_data .get ("error"):
|
||||
users_list =response_data .get ("response",{}).get ("users",[])
|
||||
logging .info (f"Found {len(users_list)} panel users matching filter: {params}")
|
||||
return users_list
|
||||
logging .error (f"Failed to fetch panel users with filter {params}. Response: {response_data}")
|
||||
return None
|
||||
|
||||
async def create_panel_user (
|
||||
self ,
|
||||
username :str ,
|
||||
telegram_id :Optional [int ]=None ,
|
||||
email :Optional [str ]=None ,
|
||||
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
|
||||
)->Optional [Dict [str ,Any ]]:
|
||||
|
||||
if not (6 <=len (username )<=34 and username .replace ('_','').replace ('-','').isalnum ()):
|
||||
msg =f"Username '{username}' for panel does not meet requirements (6-34 chars, alphanumeric, _, -)."
|
||||
logging .error (msg )
|
||||
return {"error":True ,"status_code":400 ,"message":msg ,"response":{"message":msg ,"errorCode":"VALIDATION_ERROR"}}
|
||||
|
||||
now =datetime .now (timezone .utc )
|
||||
expire_at_dt =now +timedelta (days =default_expire_days )
|
||||
expire_at_iso =expire_at_dt .isoformat (timespec ='milliseconds').replace ('+00:00','Z')
|
||||
|
||||
payload :Dict [str ,Any ]={
|
||||
"username":username ,
|
||||
"expireAt":expire_at_iso ,
|
||||
"trafficLimitStrategy":default_traffic_limit_strategy ,
|
||||
"trafficLimitBytes":default_traffic_limit_bytes ,
|
||||
}
|
||||
if specific_inbound_uuids :
|
||||
payload ["activeUserInbounds"]=specific_inbound_uuids
|
||||
|
||||
|
||||
|
||||
payload ["activateAllInbounds"]=False
|
||||
else :
|
||||
payload ["activateAllInbounds"]=activate_all_inbounds_default_flag
|
||||
|
||||
if telegram_id is not None :payload ["telegramId"]=telegram_id
|
||||
if email :payload ["email"]=email
|
||||
|
||||
return await self ._request ("POST","/users",json =payload )
|
||||
|
||||
async def update_user_details_on_panel (self ,user_uuid :str ,update_payload :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
|
||||
if 'uuid'not in update_payload :update_payload ['uuid']=user_uuid
|
||||
|
||||
|
||||
|
||||
|
||||
update_payload .pop ('activateAllInbounds',None )
|
||||
|
||||
full_response =await self ._request ("PATCH","/users",json =update_payload )
|
||||
if full_response and not full_response .get ("error")and full_response .get ("response"):
|
||||
logging .info (f"User {user_uuid} details updated on panel.")
|
||||
return full_response .get ("response")
|
||||
logging .error (f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Resp: {full_response}")
|
||||
return None
|
||||
|
||||
async def get_all_panel_users (self ,page_size :int =100 )->Optional [List [Dict [str ,Any ]]]:
|
||||
all_users =[];start_offset =0
|
||||
while True :
|
||||
params ={"size":page_size ,"start":start_offset }
|
||||
response_data =await self ._request ("GET","/users",params =params )
|
||||
if not response_data or response_data .get ("error"):logging .error (f"Failed to fetch panel users batch: {response_data}");return None
|
||||
users_batch =response_data .get ("response",{}).get ("users",[])
|
||||
if not users_batch :break
|
||||
all_users .extend (users_batch )
|
||||
if len (users_batch )<page_size :break
|
||||
start_offset +=page_size
|
||||
logging .info (f"Fetched {len(all_users)} users from panel API.");return all_users
|
||||
|
||||
async def get_user_by_uuid (self ,user_uuid :str )->Optional [Dict [str ,Any ]]:
|
||||
full_response =await self ._request ("GET",f"/users/{user_uuid}")
|
||||
if full_response and not full_response .get ("error")and full_response .get ("response"):
|
||||
return full_response .get ("response")
|
||||
return None
|
||||
|
||||
async def update_user_status_on_panel (self ,user_uuid :str ,enable :bool )->bool :
|
||||
endpoint =f"/users/{user_uuid}/actions/{'enable' if enable else 'disable'}"
|
||||
response_data =await self ._request ("POST",endpoint )
|
||||
if response_data and not response_data .get ("error")and (response_data .get ("response")or response_data .get ("status")=="success"):
|
||||
logging .info (f"User {user_uuid} status on panel -> {'enabled' if enable else 'disabled'}.");return True
|
||||
logging .error (f"Failed to update user {user_uuid} status on panel. Resp: {response_data}");return False
|
||||
|
||||
async def get_subscription_link (self ,short_uuid_or_sub_uuid :str ,client_type :Optional [str ]=None )->Optional [str ]:
|
||||
if not self .settings .PANEL_API_URL :return None
|
||||
return f"{self.settings.PANEL_API_URL.rstrip('/')}/sub/{short_uuid_or_sub_uuid}"
|
||||
@@ -0,0 +1,144 @@
|
||||
import uuid
|
||||
import logging
|
||||
from yookassa import Configuration ,Payment
|
||||
from yookassa .domain .request .payment_request_builder import PaymentRequestBuilder
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from typing import Optional ,Dict ,Any ,List
|
||||
|
||||
from config .settings import Settings
|
||||
|
||||
class YooKassaService :
|
||||
def __init__ (
|
||||
self ,
|
||||
shop_id :Optional [str ],
|
||||
secret_key :Optional [str ],
|
||||
configured_return_url :Optional [str ],
|
||||
bot_username_for_default :Optional [str ]=None ,
|
||||
settings_obj :Optional [Settings ]=None
|
||||
):
|
||||
self .settings =settings_obj
|
||||
|
||||
if not shop_id or not secret_key :
|
||||
logging .warning ("YooKassa SHOP_ID or SECRET_KEY not configured. Payment functionality will be disabled.")
|
||||
self .configured =False
|
||||
else :
|
||||
Configuration .account_id =shop_id
|
||||
Configuration .secret_key =secret_key
|
||||
self .configured =True
|
||||
logging .info (f"YooKassa configured for shop_id: {shop_id}")
|
||||
|
||||
if configured_return_url :
|
||||
self .return_url =configured_return_url
|
||||
elif bot_username_for_default :
|
||||
self .return_url =f"https://t.me/{bot_username_for_default}"
|
||||
logging .info (f"YOOKASSA_RETURN_URL not set, using dynamic default: {self.return_url}")
|
||||
else :
|
||||
self .return_url ="https://example.com/payment_error_no_return_url"
|
||||
logging .warning (f"YOOKASSA_RETURN_URL not set AND bot username not provided. Using placeholder: {self.return_url}")
|
||||
logging .info (f"YooKassa Service effective return_url: {self.return_url}")
|
||||
|
||||
|
||||
async def create_payment (self ,amount :float ,currency :str ,description :str ,metadata :Dict [str ,Any ])->Optional [Dict [str ,Any ]]:
|
||||
if not self .configured :
|
||||
logging .error ("YooKassa is not configured. Cannot create payment.")
|
||||
return None
|
||||
|
||||
if not self .settings :
|
||||
logging .error ("YooKassaService: Settings object not available for receipt creation.")
|
||||
return {"error":True ,"internal_message":"Service settings not initialized."}
|
||||
|
||||
if not self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
|
||||
logging .error ("CRITICAL: YOOKASSA_DEFAULT_RECEIPT_EMAIL is not configured. YooKassa payment will fail due to missing receipt customer contact.")
|
||||
return {"error":True ,"internal_message":"YooKassa receipt email not configured by admin."}
|
||||
|
||||
try :
|
||||
builder =PaymentRequestBuilder ()
|
||||
builder .set_amount ({"value":str (round (amount ,2 )),"currency":currency .upper ()})
|
||||
builder .set_capture (True )
|
||||
builder .set_confirmation ({"type":"redirect","return_url":self .return_url })
|
||||
builder .set_description (description )
|
||||
builder .set_metadata (metadata )
|
||||
|
||||
|
||||
receipt_items :List [Dict [str ,Any ]]=[
|
||||
{
|
||||
"description":description ,
|
||||
"quantity":"1.00",
|
||||
"amount":{
|
||||
"value":str (round (amount ,2 )),
|
||||
"currency":currency .upper ()
|
||||
},
|
||||
"vat_code":str (self .settings .YOOKASSA_VAT_CODE ),
|
||||
"payment_mode":self .settings .YOOKASSA_PAYMENT_MODE ,
|
||||
"payment_subject":self .settings .YOOKASSA_PAYMENT_SUBJECT
|
||||
}
|
||||
]
|
||||
|
||||
receipt_customer :Dict [str ,str ]={}
|
||||
if self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL :
|
||||
receipt_customer ["email"]=self .settings .YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
||||
|
||||
|
||||
|
||||
|
||||
if not receipt_customer :
|
||||
logging .error ("YooKassa: No customer contact (email/phone) for receipt.")
|
||||
return {"error":True ,"internal_message":"Receipt customer contact missing."}
|
||||
|
||||
receipt_payload :Dict [str ,Any ]={
|
||||
"customer":receipt_customer ,
|
||||
"items":receipt_items
|
||||
}
|
||||
|
||||
|
||||
builder .set_receipt (receipt_payload )
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
idempotence_key =str (uuid .uuid4 ())
|
||||
payment_request =builder .build ()
|
||||
|
||||
logging .info (f"Creating YooKassa payment (IDK: {idempotence_key}) with receipt. Email: {self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL}")
|
||||
|
||||
res =Payment .create (payment_request ,idempotence_key )
|
||||
logging .info (f"YooKassa Payment.create response: ID={res.id}, Status={res.status}")
|
||||
|
||||
return {
|
||||
"id":res .id ,
|
||||
"confirmation_url":res .confirmation .confirmation_url if res .confirmation else None ,
|
||||
"status":res .status ,
|
||||
"metadata":res .metadata ,
|
||||
"amount_value":float (res .amount .value ),
|
||||
"amount_currency":res .amount .currency ,
|
||||
"idempotence_key":idempotence_key ,
|
||||
"paid":res .paid ,
|
||||
"refundable":res .refundable ,
|
||||
"created_at":res .created_at .isoformat ()if hasattr (res .created_at ,'isoformat')else str (res .created_at )
|
||||
}
|
||||
except Exception as e :
|
||||
logging .error (f"YooKassa payment creation failed: {e}",exc_info =True )
|
||||
return None
|
||||
|
||||
async def get_payment_info (self ,payment_id :str )->Optional [Dict [str ,Any ]]:
|
||||
if not self .configured :
|
||||
logging .error ("YooKassa is not configured. Cannot get payment info.")
|
||||
return None
|
||||
try :
|
||||
payment_info =Payment .find_one (payment_id )
|
||||
if payment_info :
|
||||
return {"id":payment_info .id ,"status":payment_info .status ,"paid":payment_info .paid ,
|
||||
"amount_value":float (payment_info .amount .value ),"amount_currency":payment_info .amount .currency ,
|
||||
"metadata":payment_info .metadata ,"description":payment_info .description ,}
|
||||
return None
|
||||
except Exception as e :
|
||||
logging .error (f"YooKassa get payment info for {payment_id} failed: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from datetime import datetime ,timezone
|
||||
from typing import Optional ,Dict ,Callable ,Any ,Tuple
|
||||
|
||||
from aiogram import Bot
|
||||
from config .settings import Settings
|
||||
from db .database import get_promo_code_by_code ,increment_promo_activation ,get_db_connection_manager ,_setup_db_connection
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
class PromoCodeService :
|
||||
def __init__ (
|
||||
self ,
|
||||
db_conn_provider :Callable [[],Any ],
|
||||
settings :Settings ,
|
||||
subscription_service :SubscriptionService ,
|
||||
bot :Bot ,
|
||||
i18n :JsonI18n
|
||||
):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .subscription_service =subscription_service
|
||||
self .bot =bot
|
||||
self .i18n =i18n
|
||||
|
||||
async def apply_promo_code (self ,user_id :int ,code_input :str ,user_lang :str )->Tuple [bool ,str ]:
|
||||
"""
|
||||
Applies a promo code for a user.
|
||||
Returns: (success_status: bool, message_text_for_user: str)
|
||||
"""
|
||||
_ =lambda k ,**kw :self .i18n .gettext (user_lang ,k ,**kw )
|
||||
code_input_upper =code_input .strip ().upper ()
|
||||
|
||||
async with self .db_conn_provider ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
promo_data =await get_promo_code_by_code (code_input_upper ,db_conn =db )
|
||||
|
||||
if not promo_data :
|
||||
return False ,_ ("promo_code_not_found",code =code_input_upper )
|
||||
|
||||
if promo_data ['current_activations']>=promo_data ['max_activations']:
|
||||
return False ,_ ("promo_code_max_activations_reached",code =code_input_upper )
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
active_sub =await self .subscription_service .get_active_subscription (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 (
|
||||
user_id =user_id ,
|
||||
bonus_days =bonus_days ,
|
||||
db_conn =db ,
|
||||
reason =f"promo code {code_input_upper}"
|
||||
)
|
||||
|
||||
if new_end_date :
|
||||
|
||||
|
||||
activation_success =await increment_promo_activation (promo_data ['promo_code_id'],user_id ,db_conn =db ,payment_id =None )
|
||||
if activation_success :
|
||||
await db .commit ()
|
||||
return True ,_ ("promo_code_applied_success",
|
||||
code =code_input_upper ,
|
||||
bonus_days =bonus_days ,
|
||||
new_end_date =new_end_date .strftime ('%Y-%m-%d'))
|
||||
else :
|
||||
|
||||
|
||||
|
||||
await db .rollback ()
|
||||
return False ,_ ("promo_code_invalid_or_expired")
|
||||
else :
|
||||
await db .rollback ()
|
||||
return False ,_ ("error_applying_promo_bonus")
|
||||
|
||||
except Exception as e :
|
||||
logging .error (f"Error applying promo code {code_input_upper} for user {user_id}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
return False ,_ ("error_try_again")
|
||||
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from typing import Optional ,Dict ,Callable ,Any ,TYPE_CHECKING ,Tuple
|
||||
from aiogram import Bot
|
||||
from datetime import datetime
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_user
|
||||
from bot .middlewares .i18n import JsonI18n
|
||||
|
||||
if TYPE_CHECKING :
|
||||
from .subscription_service import SubscriptionService
|
||||
|
||||
|
||||
class ReferralService :
|
||||
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,subscription_service_instance :'SubscriptionService',bot :Bot ,i18n :JsonI18n ):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .subscription_service =subscription_service_instance
|
||||
self .bot =bot
|
||||
self .i18n =i18n
|
||||
|
||||
|
||||
|
||||
|
||||
async def process_new_user_referral (self ,referee_user_id :int ,inviter_user_id :Optional [int ]):
|
||||
if inviter_user_id and referee_user_id !=inviter_user_id :
|
||||
|
||||
|
||||
logging .info (f"Referral link used: User {referee_user_id} was invited by {inviter_user_id}.")
|
||||
|
||||
pass
|
||||
|
||||
async def apply_referral_bonuses_for_payment (
|
||||
self ,referee_user_id :int ,purchased_subscription_months :int ,
|
||||
db_conn :aiosqlite .Connection
|
||||
)->Dict [str ,Any ]:
|
||||
"""Applies bonuses. Notifies inviter. Returns referee bonus details."""
|
||||
referee_final_end_date :Optional [datetime ]=None
|
||||
referee_bonus_applied_days :Optional [int ]=None
|
||||
try :
|
||||
referee_user_row =await get_user (referee_user_id ,db_conn =db_conn )
|
||||
if not referee_user_row or referee_user_row ['referred_by_id']is None :
|
||||
logging .debug (f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses.")
|
||||
return {"referee_bonus_applied_days":None ,"referee_new_end_date":None }
|
||||
|
||||
inviter_user_id =referee_user_row ['referred_by_id']
|
||||
inviter_user_row =await get_user (inviter_user_id ,db_conn =db_conn )
|
||||
|
||||
referee_name =referee_user_row ['first_name']or f"User {referee_user_id}"
|
||||
|
||||
default_lang_for_placeholder =getattr (self .settings ,'DEFAULT_LANGUAGE','en')
|
||||
inviter_name =inviter_user_row ['first_name']if inviter_user_row else self .i18n .gettext (default_lang_for_placeholder ,"friend_placeholder")
|
||||
|
||||
inviter_bonus =self .settings .referral_bonus_inviter .get (purchased_subscription_months )
|
||||
referee_bonus =self .settings .referral_bonus_referee .get (purchased_subscription_months )
|
||||
|
||||
if inviter_bonus and inviter_bonus >0 and inviter_user_row :
|
||||
new_end_date_inviter =await self .subscription_service .extend_subscription_for_referral (
|
||||
user_id =inviter_user_id ,bonus_days =inviter_bonus ,db_conn =db_conn
|
||||
)
|
||||
if new_end_date_inviter :
|
||||
logging .info (f"Bonus applied for inviter {inviter_user_id}.")
|
||||
try :
|
||||
inviter_lang =inviter_user_row .get ('language_code',default_lang_for_placeholder )
|
||||
_i =lambda k ,**kw :self .i18n .gettext (inviter_lang ,k ,**kw )
|
||||
await self .bot .send_message (
|
||||
inviter_user_id ,
|
||||
_i ("referral_bonus_inviter_notification_extended",
|
||||
days =inviter_bonus ,
|
||||
referee_name =referee_name ,
|
||||
new_end_date =new_end_date_inviter .strftime ('%Y-%m-%d')
|
||||
)
|
||||
)
|
||||
except Exception as e :
|
||||
logging .error (f"Failed to send bonus notification to inviter {inviter_user_id}: {e}")
|
||||
else :logging .warning (f"Failed to apply bonus subscription extension for inviter {inviter_user_id}.")
|
||||
|
||||
if referee_bonus and referee_bonus >0 :
|
||||
new_end_date_referee =await self .subscription_service .extend_subscription_for_referral (
|
||||
user_id =referee_user_id ,bonus_days =referee_bonus ,db_conn =db_conn ,is_referee_bonus =True
|
||||
)
|
||||
if new_end_date_referee :
|
||||
logging .info (f"Bonus applied for referee {referee_user_id}.")
|
||||
referee_final_end_date =new_end_date_referee
|
||||
referee_bonus_applied_days =referee_bonus
|
||||
else :logging .warning (f"Failed to apply bonus subscription extension for referee {referee_user_id}.")
|
||||
|
||||
return {"referee_bonus_applied_days":referee_bonus_applied_days ,"referee_new_end_date":referee_final_end_date }
|
||||
except Exception as e :
|
||||
logging .error (f"Error in apply_referral_bonuses_for_payment (db_conn: {db_conn is not None}): {e}",exc_info =True )
|
||||
|
||||
raise
|
||||
|
||||
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}"
|
||||
@@ -0,0 +1,336 @@
|
||||
import logging
|
||||
import aiosqlite
|
||||
from datetime import datetime ,timedelta ,timezone
|
||||
from typing import Optional ,Dict ,Any ,List ,Callable ,Awaitable
|
||||
|
||||
from config .settings import Settings
|
||||
from db .database import get_db_connection_manager ,_setup_db_connection ,get_user ,has_had_any_subscription as db_has_had_any_subscription
|
||||
from .panel_api_service import PanelApiService
|
||||
|
||||
|
||||
if False :
|
||||
from .referral_service import ReferralService
|
||||
|
||||
class SubscriptionService :
|
||||
def __init__ (self ,db_conn_provider :Callable [[],Any ],settings :Settings ,panel_service :PanelApiService ):
|
||||
self .db_conn_provider =db_conn_provider
|
||||
self .settings =settings
|
||||
self .panel_service =panel_service
|
||||
|
||||
async def _get_db (self ,existing_conn :Optional [aiosqlite .Connection ]=None )->tuple [aiosqlite .Connection ,bool ]:
|
||||
"""Helper to get a DB connection. Returns (connection, should_manage_flag)."""
|
||||
if existing_conn :
|
||||
return existing_conn ,False
|
||||
|
||||
conn_manager =self .db_conn_provider ()
|
||||
conn =await conn_manager .__aenter__ ()
|
||||
try :
|
||||
await _setup_db_connection (conn )
|
||||
except Exception as e :
|
||||
await conn_manager .__aexit__ (type (e ),e ,e .__traceback__ )
|
||||
raise
|
||||
return conn ,True
|
||||
|
||||
async def _release_db (self ,db :aiosqlite .Connection ,should_manage :bool ,exc_type =None ,exc_val =None ,exc_tb =None ):
|
||||
"""Helper to release/close a DB connection if this service instance opened it."""
|
||||
if should_manage :
|
||||
await db .__aexit__ (exc_type ,exc_val ,exc_tb )
|
||||
|
||||
async def get_user_language (self ,user_id :int )->str :
|
||||
"""Fetches the user's language preference from the local database."""
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
user_record =await get_user (user_id ,db_conn =db )
|
||||
default_lang =self .settings .DEFAULT_LANGUAGE
|
||||
return user_record ['language_code']if user_record and 'language_code'in user_record .keys ()and user_record ['language_code']else default_lang
|
||||
finally :
|
||||
await self ._release_db (db ,should_manage )
|
||||
|
||||
async def get_panel_user_uuid (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]:
|
||||
"""Fetches panel_user_uuid for a given Telegram user_id from local DB."""
|
||||
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
|
||||
try :
|
||||
user_record =await get_user (user_id ,db_conn =db_to_use )
|
||||
return user_record ['panel_user_uuid']if user_record and 'panel_user_uuid'in user_record .keys ()and user_record ['panel_user_uuid']else None
|
||||
finally :
|
||||
await self ._release_db (db_to_use ,should_manage_this_conn )
|
||||
|
||||
async def has_had_any_subscription (self ,user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
"""Checks if the user has any record in the subscriptions table."""
|
||||
db_to_use ,should_manage_this_conn =await self ._get_db (db_conn )
|
||||
try :
|
||||
return await db_has_had_any_subscription (user_id ,db_conn =db_to_use )
|
||||
finally :
|
||||
await self ._release_db (db_to_use ,should_manage_this_conn )
|
||||
|
||||
async def activate_trial_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
|
||||
"""
|
||||
Activates a trial subscription for an eligible user.
|
||||
Manages its own database connection and transaction.
|
||||
Returns dict with trial details or specific error dict on failure/ineligibility.
|
||||
"""
|
||||
if not self .settings .TRIAL_ENABLED or self .settings .TRIAL_DURATION_DAYS <=0 :
|
||||
logging .info (f"Trial subscription feature is disabled or duration invalid for user {user_id}.")
|
||||
return {"eligible":False ,"activated":False ,"message_key":"trial_feature_disabled"}
|
||||
|
||||
async with self .db_conn_provider ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
if await db_has_had_any_subscription (user_id ,db_conn =db ):
|
||||
logging .info (f"User {user_id} has prior subscriptions. Trial not applicable.")
|
||||
return {"eligible":False ,"activated":False ,"message_key":"trial_not_eligible_already_subscribed"}
|
||||
|
||||
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
|
||||
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None
|
||||
panel_user_interacted_now =False ;specific_inbounds =self .settings .parsed_default_panel_user_inbound_uuids
|
||||
panel_actual_subscription_url :Optional [str ]=None
|
||||
|
||||
if not panel_user_uuid :
|
||||
logging .info (f"No panel_user_uuid for TG user_id {user_id} for trial. Creating panel user.")
|
||||
panel_username_to_create =f"tg_{user_id}"
|
||||
creation_response =await self .panel_service .create_panel_user (
|
||||
username =panel_username_to_create ,telegram_id =user_id ,
|
||||
default_expire_days =self .settings .TRIAL_DURATION_DAYS ,
|
||||
default_traffic_limit_bytes =self .settings .trial_traffic_limit_bytes ,
|
||||
default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,
|
||||
specific_inbound_uuids =specific_inbounds ,
|
||||
activate_all_inbounds_default_flag =False if specific_inbounds else True
|
||||
)
|
||||
panel_user_obj_from_api =None
|
||||
if creation_response and not creation_response .get ("error"):panel_user_obj_from_api =creation_response .get ("response")
|
||||
elif creation_response and creation_response .get ("errorCode")=="A019":
|
||||
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
|
||||
if existing_users_list and len (existing_users_list )==1 :panel_user_obj_from_api =existing_users_list [0 ]
|
||||
|
||||
if panel_user_obj_from_api and panel_user_obj_from_api .get ('uuid'):
|
||||
panel_user_uuid =panel_user_obj_from_api ['uuid']
|
||||
panel_subscription_uuid_for_link =panel_user_obj_from_api .get ('subscriptionUuid')
|
||||
panel_short_uuid_for_link =panel_user_obj_from_api .get ('shortUuid')
|
||||
panel_actual_subscription_url =panel_user_obj_from_api .get ('subscriptionUrl')
|
||||
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ))
|
||||
panel_user_interacted_now =True
|
||||
if panel_user_obj_from_api .get ('telegramId')!=user_id :
|
||||
await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
|
||||
else :
|
||||
logging .error (f"Failed to create/link panel user for trial (TG_ID {user_id}). Resp: {creation_response if 'creation_response' in locals() else 'N/A'}")
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
else :
|
||||
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
|
||||
if panel_user_data :
|
||||
panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid')
|
||||
panel_short_uuid_for_link =panel_user_data .get ('shortUuid')
|
||||
panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
|
||||
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :
|
||||
panel_subscription_uuid_for_link =panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link :
|
||||
logging .error (f"Critical: panel_subscription_uuid for link is None for trial (panel_uuid {panel_user_uuid}).")
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
|
||||
start_date =datetime .now (timezone .utc )
|
||||
end_date =start_date +timedelta (days =self .settings .TRIAL_DURATION_DAYS )
|
||||
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
|
||||
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes) VALUES (?, ?, ?, ?, ?, 0, 1, 'TRIAL', ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = 0, is_active = 1, status_from_panel = 'TRIAL', traffic_limit_bytes = excluded.traffic_limit_bytes, last_notification_sent = NULL; """
|
||||
trial_traffic_val =self .settings .trial_traffic_limit_bytes
|
||||
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),end_date .isoformat (),trial_traffic_val )
|
||||
|
||||
upsert_cursor =await db .execute (upsert_sql ,params )
|
||||
trial_subscription_id =upsert_cursor .lastrowid
|
||||
if not trial_subscription_id or trial_subscription_id ==0 :
|
||||
id_fetch_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,))
|
||||
id_fetch_row =await id_fetch_cursor .fetchone ()
|
||||
if id_fetch_cursor :await id_fetch_cursor .close ()
|
||||
if id_fetch_row :trial_subscription_id =id_fetch_row ['subscription_id']
|
||||
logging .info (f"Local trial subscription (ID: {trial_subscription_id}) for user {user_id} prepared. Ends: {end_date.isoformat()}.")
|
||||
|
||||
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":trial_traffic_val ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,}
|
||||
if specific_inbounds :panel_update_payload ["activeUserInbounds"]=specific_inbounds
|
||||
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
|
||||
|
||||
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
|
||||
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for trial user {panel_user_uuid}.")
|
||||
else :
|
||||
logging .info (f"Panel user {panel_user_uuid} details updated for trial. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
|
||||
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
|
||||
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
|
||||
|
||||
await db .commit ()
|
||||
return {"eligible":True ,"activated":True ,"end_date":end_date ,"days":self .settings .TRIAL_DURATION_DAYS ,"traffic_gb":self .settings .TRIAL_TRAFFIC_LIMIT_GB ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
|
||||
except Exception as e :
|
||||
logging .error (f"Error activating trial for user {user_id}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
return {"eligible":True ,"activated":False ,"message_key":"trial_activation_failed"}
|
||||
|
||||
async def activate_subscription (
|
||||
self ,user_id :int ,months :int ,payment_amount :float ,
|
||||
payment_id_internal :int ,db_conn :aiosqlite .Connection ,
|
||||
promo_code_id :Optional [int ]=None
|
||||
)->Optional [Dict [str ,Any ]]:
|
||||
db =db_conn
|
||||
try :
|
||||
panel_user_uuid =await self .get_panel_user_uuid (user_id ,db_conn =db )
|
||||
panel_subscription_uuid_for_link =None ;panel_short_uuid_for_link =None ;panel_user_interacted_now =False
|
||||
panel_actual_subscription_url :Optional [str ]=None
|
||||
specific_inbounds_from_settings =self .settings .parsed_default_panel_user_inbound_uuids
|
||||
if not panel_user_uuid :
|
||||
panel_username_to_create =f"tg_{user_id}"
|
||||
creation_response =await self .panel_service .create_panel_user (username =panel_username_to_create ,telegram_id =user_id ,default_expire_days =self .settings .PANEL_USER_DEFAULT_EXPIRE_DAYS ,default_traffic_limit_bytes =self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,default_traffic_limit_strategy =self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY ,specific_inbound_uuids =specific_inbounds_from_settings ,activate_all_inbounds_default_flag =False if specific_inbounds_from_settings else True )
|
||||
panel_user_object_from_api =None
|
||||
if creation_response and not creation_response .get ("error"):panel_user_object_from_api =creation_response .get ("response")
|
||||
elif creation_response and creation_response .get ("errorCode")=="A019":
|
||||
existing_users_list =await self .panel_service .get_users_by_filter (username =panel_username_to_create )
|
||||
if existing_users_list and len (existing_users_list )==1 :panel_user_object_from_api =existing_users_list [0 ]
|
||||
if panel_user_object_from_api and panel_user_object_from_api .get ('uuid'):
|
||||
panel_user_uuid =panel_user_object_from_api ['uuid'];panel_subscription_uuid_for_link =panel_user_object_from_api .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_object_from_api .get ('shortUuid');panel_actual_subscription_url =panel_user_object_from_api .get ('subscriptionUrl')
|
||||
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_user_uuid ,user_id ));conflicting_tg_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
||||
if conflicting_tg_user :logging .error (f"CRITICAL CONFLICT: Panel UUID {panel_user_uuid} already linked to TG user {conflicting_tg_user['user_id']}.");return None
|
||||
await db .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));panel_user_interacted_now =True
|
||||
if panel_user_object_from_api .get ('telegramId')!=user_id :await self .panel_service .update_user_details_on_panel (panel_user_uuid ,{"telegramId":user_id })
|
||||
else :logging .error (f"Failed to create/link panel user for TG_ID {user_id}. Resp: {creation_response if 'creation_response' in locals() else 'N/A'}");return None
|
||||
else :
|
||||
panel_user_data =await self .panel_service .get_user_by_uuid (panel_user_uuid )
|
||||
if panel_user_data :panel_subscription_uuid_for_link =panel_user_data .get ('subscriptionUuid');panel_short_uuid_for_link =panel_user_data .get ('shortUuid');panel_actual_subscription_url =panel_user_data .get ('subscriptionUrl')
|
||||
if not panel_subscription_uuid_for_link and panel_short_uuid_for_link :panel_subscription_uuid_for_link =panel_short_uuid_for_link
|
||||
if not panel_subscription_uuid_for_link :logging .error (f"Critical: panel_subscription_uuid for link is None for panel_user_uuid {panel_user_uuid}.");return None
|
||||
|
||||
cursor =await db .execute ("SELECT subscription_id, end_date FROM subscriptions WHERE panel_user_uuid = ? AND is_active = 1 ORDER BY end_date DESC LIMIT 1",(panel_user_uuid ,));current_sub_row =await cursor .fetchone ();await cursor .close ()
|
||||
current_sub_end_date_str =current_sub_row ['end_date']if current_sub_row else None ;start_date =datetime .now (timezone .utc )
|
||||
if current_sub_end_date_str :
|
||||
try :
|
||||
parsed_current_end_date =datetime .fromisoformat (current_sub_end_date_str .replace ("Z","+00:00"))
|
||||
if parsed_current_end_date .tzinfo is None :parsed_current_end_date =parsed_current_end_date .replace (tzinfo =timezone .utc )
|
||||
if parsed_current_end_date >start_date :start_date =parsed_current_end_date
|
||||
except ValueError :logging .warning (f"Bad current_sub_end_date string: {current_sub_end_date_str} for panel_user {panel_user_uuid}.")
|
||||
final_end_date =start_date +timedelta (days =months *30 )
|
||||
if promo_code_id :
|
||||
promo_cursor =await db .execute ("SELECT bonus_days FROM promo_codes WHERE promo_code_id = ?",(promo_code_id ,));promo_row =await promo_cursor .fetchone ();await promo_cursor .close ()
|
||||
if promo_row :final_end_date +=timedelta (days =promo_row ['bonus_days']);await db .execute ("INSERT OR IGNORE INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id_internal ));await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,))
|
||||
|
||||
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE panel_user_uuid = ? AND is_active = 1",(panel_user_uuid ,))
|
||||
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel) VALUES (?, ?, ?, ?, ?, ?, 1, 'ACTIVE') ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = 1, status_from_panel = 'ACTIVE', last_notification_sent = NULL; """
|
||||
params =(user_id ,panel_user_uuid ,panel_subscription_uuid_for_link ,start_date .isoformat (),final_end_date .isoformat (),months )
|
||||
upsert_cursor =await db .execute (upsert_sql ,params );subscription_id_to_return =upsert_cursor .lastrowid
|
||||
if not subscription_id_to_return or subscription_id_to_return ==0 :
|
||||
id_cursor =await db .execute ("SELECT subscription_id FROM subscriptions WHERE panel_subscription_uuid = ?",(panel_subscription_uuid_for_link ,));id_row =await id_cursor .fetchone ();await id_cursor .close ()
|
||||
if id_row :subscription_id_to_return =id_row ['subscription_id']
|
||||
logging .info (f"Local subscription UPSERTED (ID: {subscription_id_to_return}) for user {user_id}. Ends: {final_end_date.isoformat()}.")
|
||||
|
||||
panel_update_payload :Dict [str ,Any ]={"uuid":panel_user_uuid ,"expireAt":final_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z'),"status":"ACTIVE","trafficLimitBytes":self .settings .PANEL_USER_DEFAULT_TRAFFIC_BYTES ,"trafficLimitStrategy":self .settings .PANEL_USER_DEFAULT_TRAFFIC_STRATEGY }
|
||||
if specific_inbounds_from_settings :panel_update_payload ["activeUserInbounds"]=specific_inbounds_from_settings
|
||||
elif panel_user_interacted_now :panel_update_payload ["activateAllInbounds"]=True
|
||||
if "activateAllInbounds"in panel_update_payload and not specific_inbounds_from_settings and not panel_user_interacted_now :del panel_update_payload ["activateAllInbounds"]
|
||||
logging .info (f"Attempting to update panel user {panel_user_uuid} for paid sub: {panel_update_payload}")
|
||||
updated_panel_user =await self .panel_service .update_user_details_on_panel (panel_user_uuid ,panel_update_payload )
|
||||
if not updated_panel_user :logging .warning (f"Panel user details update FAILED for {panel_user_uuid}.")
|
||||
else :
|
||||
logging .info (f"Panel user {panel_user_uuid} details updated. Panel ExpireAt: {updated_panel_user.get('expireAt')}")
|
||||
if updated_panel_user .get ('subscriptionUrl'):panel_actual_subscription_url =updated_panel_user .get ('subscriptionUrl')
|
||||
if updated_panel_user .get ('shortUuid'):panel_short_uuid_for_link =updated_panel_user .get ('shortUuid')
|
||||
return {"subscription_id":subscription_id_to_return ,"end_date":final_end_date ,"is_active":True ,"panel_user_uuid":panel_user_uuid ,"panel_short_uuid":panel_short_uuid_for_link ,"subscription_url":panel_actual_subscription_url }
|
||||
except Exception as e :logging .error (f"Error in activate_subscription (paid) for user {user_id}: {e}",exc_info =True );return None
|
||||
|
||||
async def extend_active_subscription_days (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,reason :str ="bonus")->Optional [datetime ]:
|
||||
db =db_conn
|
||||
try :
|
||||
user_cursor =await db .execute ("SELECT panel_user_uuid FROM users WHERE user_id = ?",(user_id ,))
|
||||
user_panel_data =await user_cursor .fetchone ()
|
||||
if user_cursor :await user_cursor .close ()
|
||||
panel_user_uuid_for_update =user_panel_data ['panel_user_uuid']if user_panel_data and 'panel_user_uuid'in user_panel_data .keys ()and user_panel_data ['panel_user_uuid']else None
|
||||
|
||||
sql_select_active_sub ="SELECT subscription_id, end_date FROM subscriptions WHERE user_id = ? AND is_active = 1 "
|
||||
params_select_active_sub :tuple =(user_id ,)
|
||||
if panel_user_uuid_for_update :
|
||||
sql_select_active_sub +="AND panel_user_uuid = ? "
|
||||
params_select_active_sub +=(panel_user_uuid_for_update ,)
|
||||
else :
|
||||
logging .warning (f"Extending subscription for user {user_id} without panel_user_uuid. This might be ambiguous if user has multiple panel accounts linked to one TG ID (not typical).")
|
||||
sql_select_active_sub +="ORDER BY end_date DESC LIMIT 1"
|
||||
|
||||
cursor =await db .execute (sql_select_active_sub ,params_select_active_sub )
|
||||
active_sub_row =await cursor .fetchone ()
|
||||
if cursor :await cursor .close ()
|
||||
|
||||
if not active_sub_row or not active_sub_row ['end_date']:
|
||||
logging .info (f"No active subscription found for user {user_id} (panel UUID: {panel_user_uuid_for_update}) to extend with {reason} bonus.")
|
||||
return None
|
||||
|
||||
current_end_date_str =active_sub_row ['end_date']
|
||||
try :
|
||||
current_end_date =datetime .fromisoformat (current_end_date_str .replace ("Z","+00:00"))if isinstance (current_end_date_str ,str )else current_end_date_str
|
||||
if not isinstance (current_end_date ,datetime ):
|
||||
raise ValueError ("current_end_date is not a datetime object after parsing")
|
||||
except ValueError as ve :
|
||||
logging .error (f"Error parsing current_end_date '{current_end_date_str}' for user {user_id}: {ve}")
|
||||
return None
|
||||
|
||||
if current_end_date .tzinfo is None :
|
||||
current_end_date =current_end_date .replace (tzinfo =timezone .utc )
|
||||
|
||||
now_utc =datetime .now (timezone .utc )
|
||||
start_point_for_bonus =current_end_date if current_end_date >now_utc else now_utc
|
||||
new_end_date =start_point_for_bonus +timedelta (days =bonus_days )
|
||||
|
||||
await db .execute ("UPDATE subscriptions SET end_date = ?, last_notification_sent = NULL WHERE subscription_id = ?",(new_end_date .isoformat (),active_sub_row ['subscription_id']))
|
||||
logging .info (f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date.isoformat()}")
|
||||
|
||||
if panel_user_uuid_for_update :
|
||||
panel_update_payload ={"uuid":panel_user_uuid_for_update ,"expireAt":new_end_date .isoformat (timespec ='milliseconds').replace ('+00:00','Z')}
|
||||
if not await self .panel_service .update_user_details_on_panel (panel_user_uuid_for_update ,panel_update_payload ):
|
||||
logging .warning (f"Failed to update panel expiry for {panel_user_uuid_for_update} after {reason} bonus.")
|
||||
return new_end_date
|
||||
except Exception as e :
|
||||
logging .error (f"Error extending subscription with {reason} bonus for user {user_id} (using provided db_conn): {e}",exc_info =True )
|
||||
return None
|
||||
|
||||
async def extend_subscription_for_referral (self ,user_id :int ,bonus_days :int ,db_conn :aiosqlite .Connection ,is_referee_bonus :bool =False )->Optional [datetime ]:
|
||||
reason ="referee bonus"if is_referee_bonus else "inviter referral bonus"
|
||||
return await self .extend_active_subscription_days (user_id ,bonus_days ,db_conn ,reason =reason )
|
||||
|
||||
async def get_active_subscription (self ,user_id :int )->Optional [Dict [str ,Any ]]:
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
now_iso_utc =datetime .now (timezone .utc ).isoformat ()
|
||||
cursor =await db .execute ("""SELECT s.subscription_id, s.panel_subscription_uuid, s.panel_user_uuid, s.start_date, s.end_date, s.duration_months, s.is_active, s.status_from_panel, s.traffic_limit_bytes, s.traffic_used_bytes, u.username as bot_username FROM subscriptions s LEFT JOIN users u ON s.user_id = u.user_id WHERE s.user_id = ? AND s.is_active = 1 AND s.end_date > ? ORDER BY s.end_date DESC LIMIT 1""",(user_id ,now_iso_utc ))
|
||||
sub_row =await cursor .fetchone ();await cursor .close ()
|
||||
if sub_row :
|
||||
sub_dict =dict (sub_row )
|
||||
for date_key in ['start_date','end_date']:
|
||||
if sub_dict .get (date_key )and isinstance (sub_dict [date_key ],str ):
|
||||
try :sub_dict [date_key ]=datetime .fromisoformat (sub_dict [date_key ].replace ("Z","+00:00"))
|
||||
except ValueError :logging .warning (f"Could not parse date string {sub_dict[date_key]} for key {date_key} in get_active_subscription")
|
||||
return sub_dict
|
||||
return None
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
|
||||
async def get_subscriptions_ending_soon (self ,days_threshold :int )->List [Dict [str ,Any ]]:
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
now_utc =datetime .now (timezone .utc )
|
||||
threshold_date =now_utc +timedelta (days =days_threshold )
|
||||
today_date_str =now_utc .strftime ('%Y-%m-%d')
|
||||
query =""" SELECT s.user_id, u.first_name, u.language_code, s.end_date as end_date_raw, strftime('%Y-%m-%d', s.end_date) as end_date_str FROM subscriptions s JOIN users u ON s.user_id = u.user_id WHERE s.is_active = 1 AND s.end_date BETWEEN ? AND ? AND (s.last_notification_sent IS NULL OR s.last_notification_sent < ?) ORDER BY s.end_date ASC """
|
||||
cursor =await db .execute (query ,(now_utc .isoformat (),threshold_date .isoformat (),today_date_str ));rows =await cursor .fetchall ();await cursor .close ()
|
||||
processed_rows =[]
|
||||
for row_data in rows :
|
||||
row_dict =dict (row_data );end_date_obj_for_calc =None
|
||||
if isinstance (row_dict ['end_date_raw'],str ):
|
||||
try :
|
||||
end_date_obj_for_calc =datetime .fromisoformat (row_dict ['end_date_raw'].replace ("Z","+00:00"))
|
||||
if end_date_obj_for_calc .tzinfo is None :end_date_obj_for_calc =end_date_obj_for_calc .replace (tzinfo =timezone .utc )
|
||||
row_dict ['days_left']=(end_date_obj_for_calc -now_utc ).total_seconds ()/(24 *3600 )
|
||||
except ValueError :row_dict ['days_left']=None
|
||||
else :row_dict ['days_left']=None
|
||||
processed_rows .append (row_dict )
|
||||
return processed_rows
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
|
||||
async def update_last_notification_sent (self ,user_id :int ,subscription_end_date_iso :str ):
|
||||
db ,should_manage =await self ._get_db ()
|
||||
try :
|
||||
today_iso_date_str =datetime .now (timezone .utc ).strftime ('%Y-%m-%d')
|
||||
await db .execute ("UPDATE subscriptions SET last_notification_sent = ? WHERE user_id = ? AND is_active = 1 AND end_date = ?",(today_iso_date_str ,user_id ,subscription_end_date_iso ))
|
||||
await db .commit ()
|
||||
except Exception as e :logging .error (f"Error updating last_notification_sent for {user_id} and end_date {subscription_end_date_iso}: {e}");await db .rollback ()
|
||||
finally :await self ._release_db (db ,should_manage )
|
||||
@@ -0,0 +1,11 @@
|
||||
from aiogram .fsm .state import State ,StatesGroup
|
||||
|
||||
class AdminStates (StatesGroup ):
|
||||
|
||||
waiting_for_broadcast_message =State ()
|
||||
confirming_broadcast =State ()
|
||||
waiting_for_promo_details =State ()
|
||||
waiting_for_user_id_to_ban =State ()
|
||||
waiting_for_user_id_to_unban =State ()
|
||||
|
||||
waiting_for_user_id_for_logs =State ()
|
||||
@@ -0,0 +1,4 @@
|
||||
from aiogram .fsm .state import State ,StatesGroup
|
||||
|
||||
class UserPromoStates (StatesGroup ):
|
||||
waiting_for_promo_code =State ()
|
||||
@@ -0,0 +1,146 @@
|
||||
import logging
|
||||
from pydantic_settings import BaseSettings ,SettingsConfigDict
|
||||
from pydantic import Field ,field_validator ,ValidationError
|
||||
from typing import Optional ,List ,Dict ,Any
|
||||
|
||||
class Settings (BaseSettings ):
|
||||
BOT_TOKEN :str
|
||||
ADMIN_IDS_STR :str =Field (default ="",alias ="ADMIN_IDS",description ="Comma-separated list of admin Telegram User IDs")
|
||||
|
||||
DB_NAME :str =Field (default ="bot_database.sqlite3")
|
||||
DEFAULT_LANGUAGE :str =Field (default ="ru")
|
||||
DEFAULT_CURRENCY_SYMBOL :str =Field (default ="RUB")
|
||||
|
||||
SUPPORT_LINK :Optional [str ]=Field (default =None ,description ="Link to support contact (e.g., t.me/your_support_contact)")
|
||||
SERVER_STATUS_URL :Optional [str ]=Field (default =None ,description ="Link to server status page (e.g., Uptime Kuma)")
|
||||
|
||||
|
||||
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 ,description ="Default email for YooKassa receipts (REQUIRED for 54-FZ)")
|
||||
YOOKASSA_VAT_CODE :int =Field (default =6 ,description ="YooKassa VAT code (e.g., 1=20%, 2=10%, 4=0%, 6=No VAT - check YooKassa docs!)")
|
||||
YOOKASSA_PAYMENT_MODE :str =Field (default ="full_prepayment",description ="YooKassa payment mode (e.g., full_prepayment, full_payment)")
|
||||
YOOKASSA_PAYMENT_SUBJECT :str =Field (default ="service",description ="YooKassa payment subject (e.g., service, commodity)")
|
||||
|
||||
|
||||
TELEGRAM_WEBHOOK_BASE_URL :Optional [str ]=None
|
||||
|
||||
|
||||
PRICE_1_MONTH :Optional [int ]=None
|
||||
PRICE_3_MONTHS :Optional [int ]=None
|
||||
PRICE_6_MONTHS :Optional [int ]=None
|
||||
PRICE_12_MONTHS :Optional [int ]=None
|
||||
|
||||
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS :int =Field (default =7 )
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC :int =Field (default =9 )
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC :int =Field (default =0 )
|
||||
|
||||
|
||||
REFERRAL_BONUS_DAYS_1_MONTH :Optional [int ]=3
|
||||
REFERRAL_BONUS_DAYS_3_MONTHS :Optional [int ]=7
|
||||
REFERRAL_BONUS_DAYS_6_MONTHS :Optional [int ]=15
|
||||
REFERRAL_BONUS_DAYS_12_MONTHS :Optional [int ]=30
|
||||
REFEREE_BONUS_DAYS_1_MONTH :Optional [int ]=1
|
||||
REFEREE_BONUS_DAYS_3_MONTHS :Optional [int ]=3
|
||||
REFEREE_BONUS_DAYS_6_MONTHS :Optional [int ]=7
|
||||
REFEREE_BONUS_DAYS_12_MONTHS :Optional [int ]=15
|
||||
|
||||
|
||||
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 (default =None )
|
||||
|
||||
|
||||
TRIAL_ENABLED :bool =Field (default =True )
|
||||
TRIAL_DURATION_DAYS :int =Field (default =3 )
|
||||
TRIAL_TRAFFIC_LIMIT_GB :Optional [float ]=Field (default =5.0 )
|
||||
|
||||
WEB_SERVER_HOST :str =Field (default ="0.0.0.0")
|
||||
WEB_SERVER_PORT :int =Field (default =8080 )
|
||||
LOGS_PAGE_SIZE :int =Field (default =10 )
|
||||
|
||||
_admin_ids_list :Optional [List [int ]]=None
|
||||
|
||||
@property
|
||||
def ADMIN_IDS (self )->List [int ]:
|
||||
if self ._admin_ids_list is None :
|
||||
if self .ADMIN_IDS_STR :
|
||||
try :
|
||||
self ._admin_ids_list =[int (admin_id .strip ())for admin_id in self .ADMIN_IDS_STR .split (',')if admin_id .strip ().isdigit ()]
|
||||
if not self ._admin_ids_list and self .ADMIN_IDS_STR :
|
||||
logging .error (f"ADMIN_IDS_STR ('{self.ADMIN_IDS_STR}') contains non-integer values or is malformed. No admin IDs loaded from string.")
|
||||
self ._admin_ids_list =[]
|
||||
except ValueError :
|
||||
logging .error (f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers.")
|
||||
self ._admin_ids_list =[]
|
||||
else :
|
||||
self ._admin_ids_list =[]
|
||||
return self ._admin_ids_list
|
||||
|
||||
@property
|
||||
def PRIMARY_ADMIN_ID (self )->Optional [int ]:
|
||||
admin_ids_list =self .ADMIN_IDS
|
||||
if admin_ids_list :
|
||||
return admin_ids_list [0 ]
|
||||
return None
|
||||
|
||||
@property
|
||||
def trial_traffic_limit_bytes (self )->int :
|
||||
if self .TRIAL_TRAFFIC_LIMIT_GB is None or self .TRIAL_TRAFFIC_LIMIT_GB <=0 :return 0
|
||||
return int (self .TRIAL_TRAFFIC_LIMIT_GB *(1024 **3 ))
|
||||
@property
|
||||
def parsed_default_panel_user_inbound_uuids (self )->Optional [List [str ]]:
|
||||
if self .PANEL_USER_DEFAULT_INBOUND_UUIDS :return [uuid .strip ()for uuid in self .PANEL_USER_DEFAULT_INBOUND_UUIDS .split (',')if uuid .strip ()]
|
||||
return None
|
||||
@property
|
||||
def yookassa_webhook_path (self )->str :return "/webhook/yookassa"
|
||||
@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}"
|
||||
return None
|
||||
@property
|
||||
def subscription_options (self )->Dict [int ,int ]:
|
||||
options :Dict [int ,int ]={};
|
||||
if self .PRICE_1_MONTH is not None :options [1 ]=self .PRICE_1_MONTH
|
||||
if self .PRICE_3_MONTHS is not None :options [3 ]=self .PRICE_3_MONTHS
|
||||
if self .PRICE_6_MONTHS is not None :options [6 ]=self .PRICE_6_MONTHS
|
||||
if self .PRICE_12_MONTHS is not None :options [12 ]=self .PRICE_12_MONTHS
|
||||
return options
|
||||
@property
|
||||
def referral_bonus_inviter (self )->Dict [int ,int ]:
|
||||
bonuses :Dict [int ,int ]={};
|
||||
if self .REFERRAL_BONUS_DAYS_1_MONTH is not None :bonuses [1 ]=self .REFERRAL_BONUS_DAYS_1_MONTH
|
||||
if self .REFERRAL_BONUS_DAYS_3_MONTHS is not None :bonuses [3 ]=self .REFERRAL_BONUS_DAYS_3_MONTHS
|
||||
if self .REFERRAL_BONUS_DAYS_6_MONTHS is not None :bonuses [6 ]=self .REFERRAL_BONUS_DAYS_6_MONTHS
|
||||
if self .REFERRAL_BONUS_DAYS_12_MONTHS is not None :bonuses [12 ]=self .REFERRAL_BONUS_DAYS_12_MONTHS
|
||||
return bonuses
|
||||
@property
|
||||
def referral_bonus_referee (self )->Dict [int ,int ]:
|
||||
bonuses :Dict [int ,int ]={};
|
||||
if self .REFEREE_BONUS_DAYS_1_MONTH is not None :bonuses [1 ]=self .REFEREE_BONUS_DAYS_1_MONTH
|
||||
if self .REFEREE_BONUS_DAYS_3_MONTHS is not None :bonuses [3 ]=self .REFEREE_BONUS_DAYS_3_MONTHS
|
||||
if self .REFEREE_BONUS_DAYS_6_MONTHS is not None :bonuses [6 ]=self .REFEREE_BONUS_DAYS_6_MONTHS
|
||||
if self .REFEREE_BONUS_DAYS_12_MONTHS is not None :bonuses [12 ]=self .REFEREE_BONUS_DAYS_12_MONTHS
|
||||
return bonuses
|
||||
|
||||
model_config =SettingsConfigDict (env_file ='.env',env_file_encoding ='utf-8',extra ='ignore',populate_by_name =True )
|
||||
|
||||
_settings_instance :Optional [Settings ]=None
|
||||
def get_settings ()->Settings :
|
||||
global _settings_instance
|
||||
if _settings_instance is None :
|
||||
try :
|
||||
_settings_instance =Settings ()
|
||||
if not _settings_instance .ADMIN_IDS :
|
||||
logging .warning ("CRITICAL: ADMIN_IDS not set or contains no valid integer IDs in .env. Admin functionality will be restricted.")
|
||||
except ValidationError as e :
|
||||
logging .critical (f"Pydantic validation error while loading settings: {e}")
|
||||
raise SystemExit (f"CRITICAL SETTINGS ERROR: {e}")
|
||||
return _settings_instance
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
import aiosqlite
|
||||
import logging
|
||||
from typing import Optional ,Dict ,Any ,List ,Tuple
|
||||
from datetime import datetime ,timezone
|
||||
|
||||
from config .settings import get_settings
|
||||
|
||||
|
||||
try :
|
||||
settings =get_settings ()
|
||||
DB_NAME =settings .DB_NAME
|
||||
except Exception as e :
|
||||
logging .critical (f"Could not load settings for database.py: {e}",exc_info =True )
|
||||
DB_NAME ="bot_database.sqlite3"
|
||||
|
||||
DB_BUSY_TIMEOUT_SECONDS =15.0
|
||||
|
||||
def get_db_connection_manager ():
|
||||
"""
|
||||
Возвращает awaitable/async context manager для соединения с SQLite.
|
||||
"""
|
||||
return aiosqlite .connect (DB_NAME ,timeout =DB_BUSY_TIMEOUT_SECONDS )
|
||||
|
||||
async def _setup_db_connection (db :aiosqlite .Connection ):
|
||||
"""Применяет необходимые PRAGMA и row_factory к установленному соединению."""
|
||||
try :
|
||||
await db .execute ("PRAGMA journal_mode=WAL;")
|
||||
await db .execute (f"PRAGMA busy_timeout = {int(DB_BUSY_TIMEOUT_SECONDS * 1000)};")
|
||||
db .row_factory =aiosqlite .Row
|
||||
except Exception as e :
|
||||
logging .error (f"Failed to set PRAGMAs or row_factory on connection: {e}",exc_info =True )
|
||||
|
||||
async def init_db ():
|
||||
"""Инициализирует схему БД."""
|
||||
try :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
language_code TEXT DEFAULT 'en',
|
||||
registration_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string, e.g. YYYY-MM-DD HH:MM:SS */
|
||||
is_banned INTEGER DEFAULT 0,
|
||||
panel_user_uuid TEXT UNIQUE,
|
||||
referred_by_id INTEGER,
|
||||
FOREIGN KEY (referred_by_id) REFERENCES users(user_id)
|
||||
)
|
||||
""")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_users_panel_user_uuid ON users (panel_user_uuid);")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
subscription_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
panel_user_uuid TEXT NOT NULL,
|
||||
panel_subscription_uuid TEXT UNIQUE,
|
||||
start_date TEXT, /* Storing as ISO TEXT */
|
||||
end_date TEXT NOT NULL, /* Storing as ISO TEXT */
|
||||
duration_months INTEGER,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
status_from_panel TEXT,
|
||||
traffic_limit_bytes INTEGER,
|
||||
traffic_used_bytes INTEGER,
|
||||
last_notification_sent TEXT /* Storing as TEXT YYYY-MM-DD */
|
||||
)""")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions (user_id);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_panel_user_uuid ON subscriptions (panel_user_uuid);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_end_date ON subscriptions (end_date);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_subscriptions_is_active ON subscriptions (is_active);")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
payment_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
yookassa_payment_id TEXT UNIQUE,
|
||||
idempotence_key TEXT UNIQUE,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
description TEXT,
|
||||
subscription_duration_months INTEGER,
|
||||
promo_code_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
updated_at TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id)
|
||||
)""")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments (user_id);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_payments_yookassa_payment_id ON payments (yookassa_payment_id);")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS promo_codes (
|
||||
promo_code_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
bonus_days INTEGER NOT NULL,
|
||||
max_activations INTEGER NOT NULL,
|
||||
current_activations INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_by_admin_id INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
valid_until TEXT NULL /* Storing as ISO TEXT YYYY-MM-DD HH:MM:SS */
|
||||
)""")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes (code);")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS promo_code_activations (
|
||||
activation_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
promo_code_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
payment_id INTEGER,
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(promo_code_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id),
|
||||
FOREIGN KEY (payment_id) REFERENCES payments(payment_id),
|
||||
UNIQUE (promo_code_id, user_id)
|
||||
)""")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS message_logs (
|
||||
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
telegram_username TEXT,
|
||||
telegram_first_name TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
content TEXT,
|
||||
raw_update_preview TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
is_admin_event INTEGER DEFAULT 0,
|
||||
target_user_id INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||
)
|
||||
""")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_user_id ON message_logs (user_id);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_event_type ON message_logs (event_type);")
|
||||
await db .execute ("CREATE INDEX IF NOT EXISTS idx_message_logs_timestamp ON message_logs (timestamp);")
|
||||
|
||||
|
||||
await db .execute ("""
|
||||
CREATE TABLE IF NOT EXISTS panel_sync_status (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
last_sync_time TIMESTAMP, /* TEXT as ISO8601 string */
|
||||
status TEXT,
|
||||
details TEXT,
|
||||
users_processed_from_panel INTEGER DEFAULT 0,
|
||||
subscriptions_synced INTEGER DEFAULT 0
|
||||
)""")
|
||||
await db .execute ("INSERT OR IGNORE INTO panel_sync_status (id, status, details) VALUES (1, 'never_run', 'System initialized')")
|
||||
|
||||
await db .commit ()
|
||||
logging .info ("Database initialized/checked successfully.")
|
||||
except Exception as e :
|
||||
logging .error (f"Failed to initialize database: {e}",exc_info =True );raise
|
||||
|
||||
async def add_user_if_not_exists (user_id :int ,username :Optional [str ],first_name :Optional [str ],last_name :Optional [str ],lang_code :str ='en',referred_by_id :Optional [int ]=None ,panel_user_uuid :Optional [str ]=None )->Tuple [bool ,bool ]:
|
||||
"""Adds user if not exists. Sets referred_by_id for new users. Returns (success, was_new_user_flag)."""
|
||||
was_new_user =False
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
cursor =await db .execute ("SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned FROM users WHERE user_id = ?",(user_id ,))
|
||||
existing_user =await cursor .fetchone ();await cursor .close ()
|
||||
if existing_user :
|
||||
update_fields ={}
|
||||
if username is not None and username !=existing_user ['username']:update_fields ['username']=username
|
||||
if first_name is not None and first_name !=existing_user ['first_name']:update_fields ['first_name']=first_name
|
||||
if last_name is not None and last_name !=existing_user ['last_name']:update_fields ['last_name']=last_name
|
||||
|
||||
if panel_user_uuid and existing_user ['panel_user_uuid']is None :update_fields ['panel_user_uuid']=panel_user_uuid
|
||||
|
||||
if lang_code !=existing_user ['language_code']:update_fields ['language_code']=lang_code
|
||||
if update_fields :
|
||||
set_clause =", ".join ([f"{field} = ?"for field in update_fields .keys ()]);params =list (update_fields .values ())+[user_id ]
|
||||
await db .execute (f"UPDATE users SET {set_clause} WHERE user_id = ?",tuple (params ))
|
||||
else :
|
||||
await db .execute ("""INSERT INTO users (user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned) VALUES (?, ?, ?, ?, ?, ?, ?, 0)""",(user_id ,username ,first_name ,last_name ,lang_code ,referred_by_id ,panel_user_uuid ))
|
||||
logging .info (f"New user {user_id} added. Referred by: {referred_by_id or 'N/A'}.")
|
||||
was_new_user =True
|
||||
await db .commit ();return True ,was_new_user
|
||||
except aiosqlite .IntegrityError as e :
|
||||
if "UNIQUE constraint failed: users.panel_user_uuid"in str (e )and panel_user_uuid :
|
||||
res =await db .execute ("UPDATE users SET user_id = ?, username = ?, first_name = ?, last_name = ?, language_code = COALESCE(?, language_code) WHERE panel_user_uuid = ? AND user_id IS NULL",(user_id ,username ,first_name ,last_name ,lang_code ,panel_user_uuid ))
|
||||
if res .rowcount >0 :await db .commit ();logging .info (f"Linked panel user {panel_user_uuid} to TG {user_id}.");return True ,False
|
||||
else :logging .error (f"Conflict: Panel UUID {panel_user_uuid} exists.");await db .rollback ();return False ,False
|
||||
else :logging .error (f"DB integrity error for user {user_id}: {e}");await db .rollback ();return False ,False
|
||||
except Exception as e :logging .error (f"DB error in add_user_if_not_exists for user {user_id}: {e}",exc_info =True );await db .rollback ();return False ,False
|
||||
|
||||
async def get_user (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
||||
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE user_id = ?"
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
|
||||
async def get_user_by_telegram_username (username :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
||||
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE LOWER(username) = LOWER(?)"
|
||||
clean_username =username .lstrip ('@')
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(clean_username ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute (sql ,(clean_username ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
|
||||
async def get_user_by_panel_uuid (panel_user_uuid :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
||||
sql ="SELECT user_id, username, first_name, last_name, language_code, referred_by_id, panel_user_uuid, is_banned, strftime('%Y-%m-%d %H:%M:%S', registration_date) as registration_date_str FROM users WHERE panel_user_uuid = ?"
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(panel_user_uuid ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute (sql ,(panel_user_uuid ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
|
||||
async def get_banned_users_list_paginated (limit :int ,offset :int )->Tuple [List [aiosqlite .Row ],int ]:
|
||||
sql_users ="SELECT user_id, username, first_name, last_name FROM users WHERE is_banned = 1 ORDER BY registration_date DESC LIMIT ? OFFSET ?"
|
||||
sql_count ="SELECT COUNT(*) as total_banned FROM users WHERE is_banned = 1"
|
||||
users_list :List [aiosqlite .Row ]=[]
|
||||
total_banned =0
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
cursor_users =await db .execute (sql_users ,(limit ,offset ));users_list =await cursor_users .fetchall ();await cursor_users .close ()
|
||||
cursor_count =await db .execute (sql_count );count_row =await cursor_count .fetchone ();await cursor_count .close ()
|
||||
if count_row :total_banned =count_row ['total_banned']
|
||||
return users_list ,total_banned
|
||||
|
||||
async def get_user_active_subscription_end_date (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [str ]:
|
||||
sql ="SELECT strftime('%Y-%m-%d', end_date) as end_date_str FROM subscriptions WHERE user_id = ? AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime') ORDER BY end_date DESC LIMIT 1"
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row ['end_date_str']if row else None
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute (sql ,(user_id ,));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row ['end_date_str']if row else None
|
||||
|
||||
async def update_user_language_code (user_id :int ,lang_code :str ):
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );await db .execute ("UPDATE users SET language_code = ? WHERE user_id = ?",(lang_code ,user_id ));await db .commit ()
|
||||
|
||||
async def set_user_ban_status_db (user_id :int ,is_banned :bool )->bool :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );
|
||||
try :await db .execute ("UPDATE users SET is_banned = ? WHERE user_id = ?",(1 if is_banned else 0 ,user_id ));await db .commit ();return True
|
||||
except Exception as e :logging .error (f"Error setting ban status for {user_id}: {e}");await db .rollback ();return False
|
||||
|
||||
async def update_user_panel_uuid (user_id :int ,panel_user_uuid :str ,db_conn :Optional [aiosqlite .Connection ]=None ):
|
||||
async def _operation (db_op :aiosqlite .Connection ):
|
||||
try :await db_op .execute ("UPDATE users SET panel_user_uuid = ? WHERE user_id = ?",(panel_user_uuid ,user_id ));await db_op .commit ()
|
||||
except aiosqlite .IntegrityError :logging .error (f"Failed to update panel_uuid for user {user_id} (UNIQUE constraint).");await db_op .rollback ()
|
||||
except Exception as e :logging .error (f"Error in update_user_panel_uuid for {user_id}: {e}");await db_op .rollback ()
|
||||
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;await _operation (db_conn )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await _operation (db )
|
||||
|
||||
async def update_or_create_user_from_panel (panel_data :Dict [str ,Any ],db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
panel_uuid =panel_data .get ('uuid');username =panel_data .get ('username');
|
||||
if not panel_uuid :logging .warning ("update_or_create_user_from_panel: panel_uuid missing.");return False
|
||||
async def _operation (db_op :aiosqlite .Connection ):
|
||||
existing_user =await get_user_by_panel_uuid (panel_uuid ,db_conn =db_op )
|
||||
if not existing_user :
|
||||
reg_date_str =panel_data .get ('createdAt',datetime .utcnow ().isoformat ())
|
||||
if isinstance (reg_date_str ,datetime ):reg_date_str =reg_date_str .isoformat ()
|
||||
await db_op .execute ("INSERT INTO users (panel_user_uuid, username, registration_date, is_banned) VALUES (?, ?, ?, 0)",(panel_uuid ,username ,reg_date_str ))
|
||||
await db_op .commit ();return True
|
||||
try :
|
||||
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;return await _operation (db_conn )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );return await _operation (db )
|
||||
except Exception as e :logging .error (f"Error in update_or_create_user_from_panel panel_uuid {panel_uuid}: {e}",exc_info =True );return False
|
||||
|
||||
async def update_or_create_subscription_from_panel (panel_user_uuid :str ,sub_data :Dict [str ,Any ],db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
async def _operation (db_op :aiosqlite .Connection ):
|
||||
cursor =await db_op .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ?",(panel_user_uuid ,));user_row =await cursor .fetchone ();await cursor .close ()
|
||||
bot_user_id =user_row ['user_id']if user_row else None
|
||||
panel_sub_link_uuid =sub_data .get ('subscriptionUuid')or sub_data .get ('shortUuid');
|
||||
if not panel_sub_link_uuid :logging .warning (f"No panel_subscription_uuid or shortUuid for panel user {panel_user_uuid}");return False
|
||||
end_date_str =sub_data .get ('expireAt');end_date =None
|
||||
if end_date_str :
|
||||
try :end_date =datetime .fromisoformat (end_date_str .replace ("Z","+00:00"))
|
||||
except ValueError :logging .warning (f"Bad expireAt: {end_date_str} for {panel_user_uuid}");return False
|
||||
if not end_date :logging .warning (f"No end_date for {panel_user_uuid}");return False
|
||||
status_panel =sub_data .get ('status','UNKNOWN').upper ();is_active_panel =1 if status_panel =='ACTIVE'else 0
|
||||
traffic_limit =sub_data .get ('trafficLimitBytes');traffic_used =sub_data .get ('usedTrafficBytes',sub_data .get ('lifetimeUsedTrafficBytes'))
|
||||
start_date_iso =sub_data .get ('createdAt',datetime .utcnow ().isoformat ());
|
||||
if isinstance (start_date_iso ,datetime ):start_date_iso =start_date_iso .isoformat ()
|
||||
duration_months_val =sub_data .get ('duration_months')
|
||||
upsert_sql =""" INSERT INTO subscriptions (user_id, panel_user_uuid, panel_subscription_uuid, start_date, end_date, duration_months, is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(panel_subscription_uuid) DO UPDATE SET user_id = excluded.user_id, panel_user_uuid = excluded.panel_user_uuid, start_date = excluded.start_date, end_date = excluded.end_date, duration_months = excluded.duration_months, is_active = excluded.is_active, status_from_panel = excluded.status_from_panel, traffic_limit_bytes = excluded.traffic_limit_bytes, traffic_used_bytes = excluded.traffic_used_bytes, last_notification_sent = NULL; """
|
||||
params =(bot_user_id ,panel_user_uuid ,panel_sub_link_uuid ,start_date_iso ,end_date .isoformat (),duration_months_val ,is_active_panel ,status_panel ,traffic_limit ,traffic_used )
|
||||
await db_op .execute (upsert_sql ,params );await db_op .commit ();return True
|
||||
try :
|
||||
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;return await _operation (db_conn )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );return await _operation (db )
|
||||
except Exception as e :logging .error (f"Error in update_or_create_subscription_from_panel for {panel_user_uuid}: {e}",exc_info =True );return False
|
||||
|
||||
async def update_sync_status (status :str ,details :str ,users_processed :int =0 ,subs_synced :int =0 ):
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );await db .execute ("UPDATE panel_sync_status SET last_sync_time=CURRENT_TIMESTAMP, status=?, details=?, users_processed_from_panel=?, subscriptions_synced=? WHERE id=1",(status ,details ,users_processed ,subs_synced ));await db .commit ()
|
||||
|
||||
async def get_last_sync_status ()->Optional [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("SELECT * FROM panel_sync_status WHERE id = 1");row =await cursor .fetchone ();await cursor .close ();return row
|
||||
|
||||
async def get_all_users_for_broadcast ()->List [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("SELECT user_id FROM users WHERE user_id IS NOT NULL AND is_banned = 0");rows =await cursor .fetchall ();await cursor .close ();return rows
|
||||
|
||||
async def get_user_count_stats ()->Dict [str ,int ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL");total_users_c_row =await cursor .fetchone ();await cursor .close ()
|
||||
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM users WHERE user_id IS NOT NULL AND is_banned = 1");banned_users_c_row =await cursor .fetchone ();await cursor .close ()
|
||||
cursor =await db .execute ("SELECT COUNT(DISTINCT user_id) as count FROM subscriptions WHERE user_id IS NOT NULL AND is_active = 1 AND DATETIME(end_date) > DATETIME('now', 'localtime')");active_subs_c_row =await cursor .fetchone ();await cursor .close ()
|
||||
return {"total_users":total_users_c_row ['count']if total_users_c_row else 0 ,"banned_users":banned_users_c_row ['count']if banned_users_c_row else 0 ,"users_with_active_subscriptions":active_subs_c_row ['count']if active_subs_c_row else 0 }
|
||||
|
||||
async def get_payment_logs (limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("""SELECT p.payment_id, p.user_id, u.username, p.amount, p.currency, p.status, p.description, strftime('%Y-%m-%d %H:%M:%S', p.created_at) as created_at FROM payments p LEFT JOIN users u ON p.user_id = u.user_id ORDER BY p.created_at DESC LIMIT ? OFFSET ?""",(limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
||||
|
||||
async def log_user_action (user_id :Optional [int ],event_type :str ,content :Optional [str ]=None ,telegram_username :Optional [str ]=None ,telegram_first_name :Optional [str ]=None ,is_admin_event :bool =False ,target_user_id :Optional [int ]=None ,raw_update_preview :Optional [str ]=None ):
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );
|
||||
try :
|
||||
await db .execute ("""INSERT INTO message_logs (user_id, telegram_username, telegram_first_name, event_type, content, raw_update_preview, is_admin_event, target_user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",(user_id ,telegram_username ,telegram_first_name ,event_type ,content ,raw_update_preview ,1 if is_admin_event else 0 ,target_user_id ))
|
||||
await db .commit ()
|
||||
except Exception as e :logging .error (f"Failed to log action for user {user_id}, type {event_type}: {e}",exc_info =True )
|
||||
|
||||
async def get_message_logs_db (limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("""SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str, is_admin_event FROM message_logs ORDER BY timestamp DESC LIMIT ? OFFSET ?""",(limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
||||
|
||||
async def count_all_message_logs ()->int :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("SELECT COUNT(*) as count FROM message_logs");row =await cursor .fetchone ();await cursor .close ();return row ['count']if row else 0
|
||||
|
||||
async def get_user_message_logs_paginated (user_id_to_search :int ,limit :int ,offset :int )->List [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("""SELECT log_id, user_id, telegram_username, telegram_first_name, event_type, content, strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str FROM message_logs WHERE user_id = ? OR target_user_id = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?""",(user_id_to_search ,user_id_to_search ,limit ,offset ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
||||
|
||||
async def count_user_message_logs (user_id_to_search :int )->int :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("SELECT COUNT(*) as count FROM message_logs WHERE user_id = ? OR target_user_id = ?",(user_id_to_search ,user_id_to_search ));row =await cursor .fetchone ();await cursor .close ();return row ['count']if row else 0
|
||||
|
||||
async def add_payment_record (user_id :int ,yookassa_payment_id :Optional [str ],idempotence_key :Optional [str ],amount :float ,currency :str ,status :str ,description :str ,sub_months :int ,promo_id :Optional [int ]=None )->Optional [int ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );
|
||||
try :
|
||||
cursor =await db .execute ("""INSERT INTO payments (user_id, yookassa_payment_id, idempotence_key, amount, currency, status, description, subscription_duration_months, promo_code_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",(user_id ,yookassa_payment_id ,idempotence_key ,amount ,currency ,status ,description ,sub_months ,promo_id ))
|
||||
await db .commit ();return cursor .lastrowid
|
||||
except Exception as e :logging .error (f"Failed to add payment record user {user_id}, yk_id {yookassa_payment_id}: {e}");await db .rollback ();return None
|
||||
|
||||
async def update_payment_status (payment_db_id :Optional [int ]=None ,yookassa_payment_id :Optional [str ]=None ,new_status :Optional [str ]=None ,db_conn :Optional [aiosqlite .Connection ]=None ):
|
||||
async def _operation (db_op :aiosqlite .Connection ):
|
||||
if new_status is None :return
|
||||
if payment_db_id :await db_op .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ?",(new_status ,payment_db_id ))
|
||||
elif yookassa_payment_id :await db_op .execute ("UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE yookassa_payment_id = ?",(new_status ,yookassa_payment_id ))
|
||||
else :logging .warning ("update_payment_status called without payment_id or yookassa_payment_id");return
|
||||
await db_op .commit ()
|
||||
if db_conn :await _setup_db_connection (db_conn )if not db_conn .row_factory else None ;await _operation (db_conn )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :await _setup_db_connection (db );await _operation (db )
|
||||
|
||||
async def get_payment_by_yookassa_id (yookassa_payment_id :str )->Optional [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute ("SELECT * FROM payments WHERE yookassa_payment_id = ?",(yookassa_payment_id ,));row =await cursor .fetchone ();await cursor .close ();return row
|
||||
|
||||
async def create_promo_code_db (code :str ,bonus_days :int ,max_activations :int ,admin_id :int ,valid_until_dt :Optional [datetime ]=None )->Optional [int ]:
|
||||
valid_until_iso :Optional [str ]=valid_until_dt .isoformat ()if valid_until_dt else None
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );
|
||||
try :
|
||||
cursor =await db .execute ("INSERT INTO promo_codes (code, bonus_days, max_activations, created_by_admin_id, valid_until) VALUES (?, ?, ?, ?, ?)",(code .upper (),bonus_days ,max_activations ,admin_id ,valid_until_iso ))
|
||||
await db .commit ();return cursor .lastrowid
|
||||
except aiosqlite .IntegrityError :logging .warning (f"Promo code {code.upper()} exists.");await db .rollback ();return None
|
||||
except Exception as e :logging .error (f"Error creating promo {code.upper()}: {e}");await db .rollback ();return None
|
||||
|
||||
async def get_promo_codes_db (is_active_only :bool =True ,limit :int =20 ,offset :int =0 )->List [aiosqlite .Row ]:
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );
|
||||
query ="SELECT promo_code_id, code, bonus_days, max_activations, current_activations, is_active, created_by_admin_id, created_at, valid_until FROM promo_codes";params =[]
|
||||
conditions =[]
|
||||
if is_active_only :conditions .append ("is_active = 1")
|
||||
conditions .append ("(valid_until IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))")
|
||||
if conditions :query +=" WHERE "+" AND ".join (conditions )
|
||||
query +=" ORDER BY created_at DESC LIMIT ? OFFSET ?";params .extend ([limit ,offset ])
|
||||
cursor =await db .execute (query ,tuple (params ));rows =await cursor .fetchall ();await cursor .close ();return rows
|
||||
|
||||
async def get_promo_code_by_code (code :str ,db_conn :Optional [aiosqlite .Connection ]=None )->Optional [aiosqlite .Row ]:
|
||||
sql ="SELECT * FROM promo_codes WHERE code = ? AND is_active = 1 AND (DATETIME(valid_until) IS NULL OR DATETIME(valid_until) > DATETIME('now', 'localtime'))"
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(code .upper (),));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db );cursor =await db .execute (sql ,(code .upper (),));row =await cursor .fetchone ();
|
||||
if cursor :await cursor .close ();return row
|
||||
|
||||
async def increment_promo_activation (promo_code_id :int ,user_id :int ,db_conn :aiosqlite .Connection ,payment_id :Optional [int ]=None )->bool :
|
||||
|
||||
db =db_conn
|
||||
try :
|
||||
cursor =await db .execute ("SELECT 1 FROM promo_code_activations WHERE promo_code_id = ? AND user_id = ?",(promo_code_id ,user_id ));existing =await cursor .fetchone ();await cursor .close ()
|
||||
if existing :logging .info (f"User {user_id} already used promo {promo_code_id}.");return False
|
||||
await db .execute ("INSERT INTO promo_code_activations (promo_code_id, user_id, payment_id) VALUES (?, ?, ?)",(promo_code_id ,user_id ,payment_id ))
|
||||
await db .execute ("UPDATE promo_codes SET current_activations = current_activations + 1 WHERE promo_code_id = ?",(promo_code_id ,))
|
||||
return True
|
||||
except aiosqlite .IntegrityError :logging .warning (f"IntegrityError on promo activation p:{promo_code_id} u:{user_id}.");return False
|
||||
except Exception as e :logging .error (f"Error promo activation p:{promo_code_id} u:{user_id}: {e}",exc_info =True );return False
|
||||
|
||||
async def get_all_message_logs_paginated (limit :int ,offset :int )->List [aiosqlite .Row ]:
|
||||
"""
|
||||
Fetches a paginated list of all message logs, ordered by the newest first.
|
||||
"""
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
|
||||
|
||||
sql ="""
|
||||
SELECT
|
||||
log_id,
|
||||
user_id,
|
||||
telegram_username,
|
||||
telegram_first_name,
|
||||
event_type,
|
||||
content,
|
||||
strftime('%Y-%m-%d %H:%M:%S', timestamp) as timestamp_str,
|
||||
is_admin_event,
|
||||
target_user_id
|
||||
FROM message_logs
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
try :
|
||||
cursor =await db .execute (sql ,(limit ,offset ))
|
||||
rows =await cursor .fetchall ()
|
||||
await cursor .close ()
|
||||
return rows
|
||||
except Exception as e :
|
||||
logging .error (f"Error fetching all message logs: {e}",exc_info =True )
|
||||
return []
|
||||
|
||||
async def has_had_any_subscription (user_id :int ,db_conn :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
"""Checks if a user has ever had any subscription (trial or paid)."""
|
||||
sql ="SELECT 1 FROM subscriptions WHERE user_id = ? LIMIT 1"
|
||||
if db_conn :
|
||||
cursor =await db_conn .execute (sql ,(user_id ,))
|
||||
row =await cursor .fetchone ();await cursor .close ()
|
||||
return row is not None
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
cursor =await db .execute (sql ,(user_id ,))
|
||||
row =await cursor .fetchone ();await cursor .close ()
|
||||
return row is not None
|
||||
|
||||
async def sync_panel_user_data (panel_user_dict :Dict [str ,Any ],db_conn_passed :Optional [aiosqlite .Connection ]=None )->bool :
|
||||
"""
|
||||
Synchronizes a single panel user's data (user info and subscription) with the local bot database.
|
||||
Prioritizes matching/creating bot user by panel_user_dict['telegramId'].
|
||||
Manages its own transaction if db_conn_passed is None.
|
||||
"""
|
||||
panel_uuid =panel_user_dict .get ('uuid')
|
||||
telegram_id_from_panel =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: {panel_user_dict}")
|
||||
return False
|
||||
if not telegram_id_from_panel :
|
||||
logging .info (f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no 'telegramId'. Skipping TG ID based sync for this user.")
|
||||
|
||||
|
||||
|
||||
return False
|
||||
|
||||
async def _operation (db :aiosqlite .Connection ):
|
||||
|
||||
bot_user_id =int (telegram_id_from_panel )
|
||||
|
||||
existing_bot_user =await get_user (bot_user_id ,db_conn =db )
|
||||
|
||||
if existing_bot_user :
|
||||
|
||||
if existing_bot_user ['panel_user_uuid']!=panel_uuid :
|
||||
if existing_bot_user ['panel_user_uuid']is not None :
|
||||
logging .warning (f"Sync: TG User {bot_user_id} already linked to panel_uuid {existing_bot_user['panel_user_uuid']}, but panel now provides {panel_uuid} for this TG ID. Updating to new panel_uuid.")
|
||||
|
||||
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ? AND user_id != ?",(panel_uuid ,bot_user_id ))
|
||||
conflicting_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
||||
if conflicting_user :
|
||||
logging .error (f"Sync: CRITICAL CONFLICT! New panel_uuid {panel_uuid} (for TG ID {bot_user_id}) is already linked to different TG User {conflicting_user['user_id']}. Skipping user update for panel_uuid.")
|
||||
else :
|
||||
await db .execute ("UPDATE users SET panel_user_uuid = ?, username = ? WHERE user_id = ?",(panel_uuid ,panel_username ,bot_user_id ))
|
||||
logging .info (f"Sync: Updated panel_uuid for existing TG user {bot_user_id} to {panel_uuid}.")
|
||||
else :
|
||||
|
||||
if panel_username and existing_bot_user ['username']!=panel_username :
|
||||
await db .execute ("UPDATE users SET username = ? WHERE user_id = ?",(panel_username ,bot_user_id ))
|
||||
logging .info (f"Sync: Updated username for TG user {bot_user_id} from panel username {panel_username}.")
|
||||
else :
|
||||
|
||||
|
||||
cursor_conflict =await db .execute ("SELECT user_id FROM users WHERE panel_user_uuid = ?",(panel_uuid ,))
|
||||
conflicting_user =await cursor_conflict .fetchone ();await cursor_conflict .close ()
|
||||
if conflicting_user :
|
||||
logging .error (f"Sync: CRITICAL CONFLICT! Panel UUID {panel_uuid} (for new TG ID {bot_user_id}) is already linked to existing TG User {conflicting_user['user_id']}. Skipping new user creation.")
|
||||
return False
|
||||
|
||||
reg_date_str =panel_user_dict .get ('createdAt',datetime .now (timezone .utc ).isoformat ())
|
||||
if isinstance (reg_date_str ,datetime ):reg_date_str =reg_date_str .isoformat ()
|
||||
await db .execute (
|
||||
"""INSERT INTO users (user_id, username, panel_user_uuid, registration_date, is_banned, language_code)
|
||||
VALUES (?, ?, ?, ?, 0, ?)""",
|
||||
(bot_user_id ,panel_username ,panel_uuid ,reg_date_str ,settings .DEFAULT_LANGUAGE )
|
||||
)
|
||||
logging .info (f"Sync: New user created in bot DB from panel data: TG ID {bot_user_id}, Panel UUID {panel_uuid}, Panel Username {panel_username}")
|
||||
|
||||
|
||||
panel_sub_link_uuid =panel_user_dict .get ('subscriptionUuid')or panel_user_dict .get ('shortUuid')
|
||||
if not panel_sub_link_uuid :
|
||||
logging .warning (f"Sync: Panel user {panel_uuid} (TG ID: {bot_user_id}) has no 'subscriptionUuid' or 'shortUuid'. Cannot sync subscription link.")
|
||||
|
||||
await db .commit ()
|
||||
return True
|
||||
|
||||
end_date_str =panel_user_dict .get ('expireAt')
|
||||
end_date_obj =None
|
||||
if end_date_str :
|
||||
try :end_date_obj =datetime .fromisoformat (end_date_str .replace ("Z","+00:00"))
|
||||
except ValueError :logging .warning (f"Sync: Bad expireAt '{end_date_str}' for panel user {panel_uuid}. Skipping subscription update.");return True
|
||||
|
||||
if not end_date_obj :
|
||||
logging .warning (f"Sync: No valid end_date for panel user {panel_uuid}. Skipping subscription update.");return True
|
||||
|
||||
status_panel =panel_user_dict .get ('status','UNKNOWN').upper ()
|
||||
is_active_panel =1 if status_panel =='ACTIVE'else 0
|
||||
traffic_limit =panel_user_dict .get ('trafficLimitBytes')
|
||||
traffic_used =panel_user_dict .get ('usedTrafficBytes',panel_user_dict .get ('lifetimeUsedTrafficBytes'))
|
||||
|
||||
start_date_iso =panel_user_dict .get ('createdAt',datetime .now (timezone .utc ).isoformat ())
|
||||
if isinstance (start_date_iso ,datetime ):start_date_iso =start_date_iso .isoformat ()
|
||||
|
||||
|
||||
|
||||
duration_months_val =None
|
||||
|
||||
|
||||
await db .execute ("UPDATE subscriptions SET is_active = 0 WHERE user_id = ? AND panel_user_uuid = ? AND is_active = 1 AND panel_subscription_uuid != ?",
|
||||
(bot_user_id ,panel_uuid ,panel_sub_link_uuid ))
|
||||
|
||||
upsert_sub_sql ="""
|
||||
INSERT INTO subscriptions (
|
||||
user_id, panel_user_uuid, panel_subscription_uuid,
|
||||
start_date, end_date, duration_months,
|
||||
is_active, status_from_panel, traffic_limit_bytes, traffic_used_bytes,
|
||||
last_notification_sent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(panel_subscription_uuid) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
panel_user_uuid = excluded.panel_user_uuid,
|
||||
start_date = excluded.start_date,
|
||||
end_date = excluded.end_date,
|
||||
duration_months = excluded.duration_months,
|
||||
is_active = excluded.is_active,
|
||||
status_from_panel = excluded.status_from_panel,
|
||||
traffic_limit_bytes = excluded.traffic_limit_bytes,
|
||||
traffic_used_bytes = excluded.traffic_used_bytes,
|
||||
last_notification_sent = NULL;
|
||||
"""
|
||||
sub_params =(
|
||||
bot_user_id ,panel_uuid ,panel_sub_link_uuid ,
|
||||
start_date_iso ,end_date_obj .isoformat (),duration_months_val ,
|
||||
is_active_panel ,status_panel ,traffic_limit ,traffic_used
|
||||
)
|
||||
await db .execute (upsert_sub_sql ,sub_params )
|
||||
logging .info (f"Sync: Subscription upserted for TG ID {bot_user_id}, Panel UUID {panel_uuid}, Link ID {panel_sub_link_uuid}")
|
||||
|
||||
await db .commit ()
|
||||
return True
|
||||
|
||||
|
||||
if db_conn_passed :
|
||||
return await _operation (db_conn_passed )
|
||||
else :
|
||||
async with get_db_connection_manager ()as db :
|
||||
await _setup_db_connection (db )
|
||||
try :
|
||||
return await _operation (db )
|
||||
|
||||
except Exception as e :
|
||||
logging .error (f"Sync: General DB error during sync_panel_user_data for panel UUID {panel_uuid}: {e}",exc_info =True )
|
||||
await db .rollback ()
|
||||
return False
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
vpn-shop:
|
||||
build: .
|
||||
container_name: vpn-shop
|
||||
hostname: vpn-shop
|
||||
networks:
|
||||
- remnawave-network
|
||||
volumes:
|
||||
- ./bot_database.sqlite3:/app/bot_database.sqlite3
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"welcome": "Welcome, {user_name}!",
|
||||
"choose_language": "Please choose your language:",
|
||||
"language_set": "Language set to <b>English</b>.",
|
||||
"language_set_alert": "Language updated!",
|
||||
"main_menu_greeting": "Hello, {user_name}!\nWhat would you like to do?",
|
||||
"error_service_unavailable": "⚠️ Service unavailable. Please try again later.",
|
||||
"error_occurred_processing_request": "⚠️ An error occurred. Please try again.",
|
||||
"unknown_command_private": "Sorry, command not understood. Use /start.",
|
||||
"error_try_again": "An error occurred. Please try again.",
|
||||
|
||||
"menu_subscribe_inline": "🚀 Buy",
|
||||
"menu_my_subscription_inline": "📄 My Sub/Config",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"menu_apply_promo_button": "🎁 Promo Code",
|
||||
"menu_activate_trial_button": "🆓 Activate Trial",
|
||||
"menu_language_settings_inline": "🌐 Language",
|
||||
"menu_server_status_button": "📊 Status",
|
||||
"menu_support_button": "💬 Support",
|
||||
"back_to_main_menu_button": "⬅️ Main Menu",
|
||||
|
||||
"no_subscription_options_available": "Subscription options are currently unavailable.",
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"subscribe_for_months_button": "{months}mo - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Subscribe for <b>{months} months</b> for <b>{price} {currency_symbol}</b>?\n\nConfirm payment?",
|
||||
"confirm_payment_button": "✅ Confirm ({price} {currency_symbol})",
|
||||
"cancel_button": "❌ Cancel",
|
||||
"payment_service_unavailable": "⚠️ Payment service unavailable. Try later.",
|
||||
"error_creating_payment_record": "⚠️ Error preparing payment. Try again.",
|
||||
"payment_link_message": "To complete your {months} months subscription, please click the button below:",
|
||||
"pay_button": "💳 Pay Now",
|
||||
"error_payment_gateway": "⚠️ Payment gateway error. Try again or contact support.",
|
||||
"payment_successful": "✅ Payment successful! Your <b>{months} months</b> subscription is active until <b>{end_date}</b>.",
|
||||
"payment_failed": "❌ Payment failed/cancelled. If debited, contact support.",
|
||||
"payment_description_subscription": "{months}-Month Subscription",
|
||||
"my_subscription_details": "ℹ️ <b>Your Subscription</b>\n\n🗓️ Ends: <b>{end_date}</b> (Days left: {days_left})\n🔗 Link: <code>{config_link}</code>\n📊 Traffic: {traffic_used} / {traffic_limit}\n💡 Panel: {status}",
|
||||
"subscription_not_active": "No active subscription. Subscribe now!",
|
||||
"config_link_not_available": "N/A (Try /sync or contact support)",
|
||||
"traffic_unlimited": "Unlimited",
|
||||
"traffic_na": "N/A",
|
||||
"status_active": "Active",
|
||||
"status_expired": "Expired",
|
||||
"status_disabled": "Disabled",
|
||||
"status_limited": "Limited",
|
||||
"status_unknown": "Unknown",
|
||||
"subscription_ending_soon_notification": "Dear {user_name},\nYour subscription ends on <b>{end_date}</b> ({days_left} days left).\nRenew to continue service!",
|
||||
"payment_successful_with_referral_bonus": "✅ Payment for <b>{months} months</b> successful! Initial end: {base_end_date}.\n\n🎉 Invited by {inviter_name}, you got <b>{bonus_days} bonus days</b>!\nFinal end date: <b>{final_end_date}</b>.",
|
||||
"payment_successful_error_details": "Payment successful, but error displaying details. Check 'My Subscription'.",
|
||||
"error_processing_your_payment": "Error processing your payment. Please contact support.",
|
||||
|
||||
"trial_confirm_prompt": "Activate a <b>{days}-day free trial</b> with <b>{traffic_gb} GB</b> traffic?\n\n<i>This can only be done once and is not available if you've had a subscription before.</i>",
|
||||
"trial_confirm_activate_button": "✅ Activate!",
|
||||
"trial_already_had_subscription_or_trial": "A trial period is only available if you haven't had any subscription (including a trial) before.",
|
||||
"trial_activation_failed": "⚠️ Sorry, we couldn't activate your free trial at this time. Please contact support.",
|
||||
"trial_feature_disabled": "Trial periods are currently not available.",
|
||||
"trial_cancelled": "Trial activation cancelled.",
|
||||
"trial_activated_alert": "✅ Free trial activated!",
|
||||
"trial_activated_details_message": "🎉 Your <b>{days}-day free trial</b> is now active!\nIt's valid until: <b>{end_date}</b>.\nConfig link: <code>{config_link}</code>\nTraffic: <b>{traffic_gb}</b>\nEnjoy!",
|
||||
|
||||
"referral_program_info_new": "Invite friends, get bonus days!\n\nYour referral link:\n<code>{referral_link}</code>\n\n<b>Bonuses:</b>{bonus_details}",
|
||||
"referral_bonus_per_period": "\n\n🎁 Friend's {months}-mo sub:\n ➢ You get: <b>{inviter_bonus_days} days</b>\n ➢ Friend gets: <b>{referee_bonus_days} days</b>",
|
||||
"no_bonus_days": "0",
|
||||
"referral_no_bonuses_configured": "\nReferral bonus details not set.",
|
||||
"referral_link_for_copying_reminder": "Link above. Tap & hold to copy!",
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Hooray, {referee_name} (referred by you) has subscribed!\nYou've received <b>{days} bonus days</b> to your subscription.\nYour subscription now ends on: <b>{new_end_date}</b>.",
|
||||
"friend_placeholder": "a friend",
|
||||
|
||||
"error_user_not_synced_for_config": "Account not fully synced. Try /sync or contact support.",
|
||||
"error_fetching_config_link_details": "Error fetching config link details. Try again or contact support.",
|
||||
"error_fetching_config_link_details_no_uuid": "Config link ID not found. Contact support.",
|
||||
"error_generating_config_link": "Could not generate config link. Try later.",
|
||||
|
||||
"promo_code_prompt": "Enter your promo code:",
|
||||
"promo_code_invalid_or_expired": "❌ Invalid, expired, or already used promo code.",
|
||||
"promo_code_no_active_subscription": "❌ Active subscription required to apply this promo code.",
|
||||
"promo_code_applied_success": "✅ Promo '<code>{code}</code>' applied! <b>{bonus_days} bonus days</b> added.\nNew end date: <b>{new_end_date}</b>.",
|
||||
"promo_code_max_activations_reached": "❌ Promo '<code>{code}</code>' max activations reached.",
|
||||
"promo_code_not_found": "❌ Promo code '<code>{code}</code>' not found.",
|
||||
"error_applying_promo_bonus": "⚠️ Error applying promo bonus. Try again or contact support.",
|
||||
"promo_input_cancelled_no_message": "Promo code input cancelled.",
|
||||
|
||||
"user_is_banned": "⚠️ Access restricted. Contact support if this is an error.",
|
||||
"contact_support_button": "💬 Support",
|
||||
"suspicious_input_ban_message": "⚠️ Suspicious input detected. For security reasons, your access to this bot has been restricted. Please contact support.",
|
||||
|
||||
"admin_panel_title": "👑 Admin Panel",
|
||||
"admin_panel_button_reply": "👑 Admin Panel",
|
||||
"back_to_admin_panel_button": "⬅️ Admin Panel",
|
||||
"admin_unknown_action": "Unknown admin action.",
|
||||
"admin_action_cancelled_default": "Action cancelled.",
|
||||
"prev_page_button": "Prev",
|
||||
"next_page_button": "Next",
|
||||
"yes_button": "✅ Yes",
|
||||
"no_button": "❌ No",
|
||||
|
||||
"admin_ban_user_button": "🚫 Ban",
|
||||
"admin_unban_user_button": "✅ Unban",
|
||||
"admin_view_banned_users_button": "📜 Banned List",
|
||||
"admin_ban_user_prompt": "Enter User ID or @username to ban:",
|
||||
"admin_unban_user_prompt": "Enter User ID or @username to unban:",
|
||||
"admin_invalid_user_id_format": "Invalid User ID. Must be a number.",
|
||||
"admin_invalid_username_format": "Invalid username. Format: @username (5-32 chars: letters, numbers, underscores).",
|
||||
"admin_user_not_found_by_username": "User with username @{username} not found in bot records.",
|
||||
"admin_cannot_ban_self_or_admin": "Cannot ban yourself or the main admin.",
|
||||
"admin_user_not_found_in_bot_db": "User ID {user_id} not found in bot.",
|
||||
"admin_user_banned_success_panel_too": "✅ User {user_id_or_username} banned (local & panel).",
|
||||
"admin_user_banned_local_panel_fail": "⚠️ User {user_id_or_username} banned locally, panel update failed.",
|
||||
"admin_user_banned_local_no_panel_uuid": "✅ User {user_id_or_username} banned locally (no panel ID found).",
|
||||
"admin_user_unbanned_success_panel_too": "✅ User {user_id_or_username} unbanned (local & panel).",
|
||||
"admin_user_unbanned_local_panel_fail": "⚠️ User {user_id_or_username} unbanned locally, panel update failed.",
|
||||
"admin_user_unbanned_local_no_panel_uuid": "✅ User {user_id_or_username} unbanned locally (no panel ID found).",
|
||||
"admin_user_already_banned": "User {user_id_or_username} is already banned.",
|
||||
"admin_user_not_banned": "User {user_id_or_username} is not currently banned.",
|
||||
"admin_confirm_unban_prompt": "Are you sure you want to unban user {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_confirm_ban_prompt": "Are you sure you want to ban user {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_user_unbanned_from_card": "✅ User {user_display} (ID: {user_id}) has been unbanned.",
|
||||
"admin_user_banned_from_card": "✅ User {user_display} (ID: {user_id}) has been banned.",
|
||||
"admin_banned_list_title": "🚫 Banned Users List (Page {current_page}/{total_pages})",
|
||||
"admin_no_banned_users": "No users are currently banned.",
|
||||
"admin_user_card_title": "👤 User Card: {user_display}",
|
||||
"user_card_info": "<b>TG ID:</b> <code>{user_id}</code>\n<b>Username:</b> @{username}\n<b>Name:</b> {first_name} {last_name}\n<b>Language:</b> {language_code}\n<b>Panel UUID:</b> <code>{panel_user_uuid}</code>\n<b>Status:</b> {ban_status}\n<b>Registered:</b> {reg_date}\n<b>Subscription Ends:</b> {sub_end_date}",
|
||||
"user_card_banned": "🔴 BANNED",
|
||||
"user_card_active": "🟢 Active",
|
||||
"user_card_sub_na": "N/A",
|
||||
"user_card_unban_button": "✅ Unban This User",
|
||||
"user_card_ban_button": "🚫 Ban This User",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Banned List",
|
||||
|
||||
"admin_broadcast_button": "📢 Broadcast",
|
||||
"admin_broadcast_enter_message": "Enter broadcast message (HTML supported):",
|
||||
"admin_broadcast_confirm_prompt": "Preview:\n\n---\n{message_preview}\n---\n\nSend to all users?",
|
||||
"confirm_broadcast_send_button": "✅ Send",
|
||||
"cancel_broadcast_button": "❌ Cancel",
|
||||
"admin_broadcast_sending_started": "Broadcast started...",
|
||||
"admin_broadcast_error_no_message": "Error: No message to broadcast.",
|
||||
"admin_broadcast_finished_stats": "📢 Broadcast finished.\nSent: {sent_count}\nFailed: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Broadcast cancelled.",
|
||||
|
||||
"admin_create_promo_button": "🎁 New Promo",
|
||||
"admin_view_promos_button": "📜 Promos",
|
||||
"admin_promo_create_prompt": "Promo: <code>CODE DAYS MAX_USES [VALID_DAYS]</code>\nEx: <code>SALE24 7 100 30</code> (30d validity)\nEx: <code>NEWBIE 5 500</code> (no expiry)",
|
||||
"admin_promo_invalid_format": "Invalid format. Use: <code>CODE DAYS USES [VALIDITY]</code>",
|
||||
"admin_promo_invalid_values": "Invalid values. Days/uses must be positive. Error: {error}",
|
||||
"admin_promo_created_success": "✅ Promo '<code>{code}</code>': {bonus_days}d, {max_activations} uses, {valid_until_str}.",
|
||||
"admin_promo_valid_until_display": "Valid until: {date}",
|
||||
"admin_promo_valid_indefinitely": "No expiry",
|
||||
"admin_promo_creation_failed": "❌ Promo creation failed (exists or DB error).",
|
||||
"admin_no_active_promos": "No active promo codes.",
|
||||
"admin_active_promos_list_header": "📜 Active Promos:",
|
||||
"admin_promo_list_item": "- <code>{code}</code>: {bonus} days, {current}/{max} used, Valid: {valid_until}",
|
||||
|
||||
"admin_stats_button": "📊 Stats",
|
||||
"admin_stats_header": "📊 <b>Bot Statistics:</b>",
|
||||
"admin_stats_users": "\n👥 <b>Users:</b>\n Total: {total_users}\n Banned: {banned_users}\n Active Subs (Bot): {active_subs}",
|
||||
"admin_stats_recent_payments_header": "\n💳 <b>Recent Payments (Bot DB):</b>",
|
||||
"admin_stats_payment_item": " {status_emoji} {amount} {currency} by {user_info} ({p_status}) on {p_date}",
|
||||
"admin_stats_no_payments_found": "\nNo payment records in bot DB.",
|
||||
"admin_stats_last_sync_header": "\n🔄 <b>Last Panel Sync:</b>",
|
||||
"admin_stats_sync_time": "Time",
|
||||
"admin_stats_sync_status": "Status",
|
||||
"admin_stats_sync_users_processed": "Panel Users Processed",
|
||||
"admin_stats_sync_subs_synced": "Subs Synced",
|
||||
"admin_stats_sync_details_label": "Details",
|
||||
"admin_sync_status_never_run": "No sync performed yet.",
|
||||
|
||||
"admin_view_logs_menu_button": "📜 View Logs",
|
||||
"admin_logs_menu_title": "📜 User Activity Logs",
|
||||
"admin_view_all_logs_button": "All Logs",
|
||||
"admin_view_user_logs_prompt_button": "Logs by User",
|
||||
"admin_prompt_for_user_id_or_username_logs": "Enter User ID or @username for logs:",
|
||||
"admin_all_logs_title": "📜 All Activity (Page {current_page}/{total_pages})",
|
||||
"admin_user_logs_title": "📜 Activity for {user_display} (Page {current_page}/{total_pages})",
|
||||
"admin_no_logs_found": "No logs found matching your criteria.",
|
||||
"admin_log_entry_format": "[{timestamp_str}] <b>User:</b> {user_display} (ID: {user_id})\n<b>Type:</b> {event_type}\n<b>Content:</b> <code>{content_preview}</code>",
|
||||
"admin_log_user_not_found": "User '{input}' not found for log viewing.",
|
||||
"system_or_unknown_user": "System/Unknown",
|
||||
|
||||
"admin_sync_panel_button": "🔄 Sync Panel",
|
||||
"sync_started": "Panel synchronization started...",
|
||||
"sync_completed": "Panel synchronization completed.\nStatus: {status}\nDetails: {details}",
|
||||
"sync_failed": "Panel synchronization failed.\nDetails: {details}",
|
||||
"admin_sync_initiated_from_panel": "Panel sync initiated!",
|
||||
"error_cannot_initiate_sync_no_message": "Error: Cannot initiate sync without message context.",
|
||||
|
||||
"admin_suspicious_promo_attempt_notification": "⚠️ <b>Suspicious Promo Code Input!</b> ⚠️\n\n<b>User:</b> {user_first_name} (@{user_username}, ID: <code>{user_id}</code>)\n<b>Input:</b> <pre>{promo_code_input}</pre>",
|
||||
"admin_suspicious_promo_attempt_notification_no_username": "⚠️ <b>Suspicious Promo Code Input!</b> ⚠️\n\n<b>User:</b> {user_first_name} (ID: <code>{user_id}</code>)\n<b>Input:</b> <pre>{promo_code_input}</pre>"
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать, {user_name}!",
|
||||
"choose_language": "Пожалуйста, выберите ваш язык:",
|
||||
"language_set": "Язык установлен на <b>Русский</b>.",
|
||||
"language_set_alert": "Язык обновлен!",
|
||||
"main_menu_greeting": "Привет, {user_name}!\nЧто бы вы хотели сделать?",
|
||||
"error_service_unavailable": "⚠️ Сервис временно недоступен. Попробуйте позже.",
|
||||
"error_occurred_processing_request": "⚠️ Произошла ошибка. Попробуйте еще раз.",
|
||||
"unknown_command_private": "Неизвестная команда. Используйте /start.",
|
||||
"error_try_again": "Произошла ошибка. Попробуйте снова.",
|
||||
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "📄 Моя подписка",
|
||||
"menu_referral_inline": "🎁 Рефералы",
|
||||
"menu_apply_promo_button": "🎁 Промокод",
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_language_settings_inline": "🌐 Язык",
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
|
||||
"no_subscription_options_available": "Варианты подписки сейчас недоступны.",
|
||||
"select_subscription_period": "Выберите период подписки:",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Подписка на <b>{months} мес.</b> за <b>{price} {currency_symbol}</b>.\n\nПерейти к оплате?",
|
||||
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_service_unavailable": "⚠️ Платежный сервис недоступен. Попробуйте позже.",
|
||||
"error_creating_payment_record": "⚠️ Ошибка подготовки платежа. Попробуйте снова.",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"error_payment_gateway": "⚠️ Ошибка платежного шлюза. Попробуйте или напишите в поддержку.",
|
||||
"payment_successful": "✅ Оплата успешна! Ваша подписка на <b>{months} мес.</b> активна до <b>{end_date}</b>.",
|
||||
"payment_failed": "❌ Оплата не удалась/отменена. Если деньги списаны, напишите в поддержку.",
|
||||
"payment_description_subscription": "Подписка на {months} мес.",
|
||||
"my_subscription_details": "ℹ️ <b>Ваша подписка</b>\n\n🗓️ До: <b>{end_date}</b> (Осталось: {days_left} дн.)\n🔗 Ссылка: <code>{config_link}</code>\n📊 Трафик: {traffic_used} / {traffic_limit}\n💡 Статус: {status}",
|
||||
"subscription_not_active": "Активной подписки нет. Оформите сейчас!",
|
||||
"config_link_not_available": "Н/Д (Попробуйте /sync или поддержка)",
|
||||
"traffic_unlimited": "Безлимит",
|
||||
"traffic_na": "Н/Д",
|
||||
"status_active": "Активна",
|
||||
"status_expired": "Истекла",
|
||||
"status_disabled": "Отключена",
|
||||
"status_limited": "Ограничена",
|
||||
"status_unknown": "Неизвестен",
|
||||
"subscription_ending_soon_notification": "{user_name},\nВаша подписка истекает <b>{end_date}</b> (через {days_left} дн.).\nНе забудьте продлить!",
|
||||
"payment_successful_with_referral_bonus": "✅ Оплата на <b>{months} мес.</b> успешна! Базовая дата: {base_end_date}.\n\n🎉 Вас пригласил(а) {inviter_name}, вы получили дополнительно <b>{bonus_days} бонусных дней</b>!\nИтоговая дата окончания: <b>{final_end_date}</b>.",
|
||||
"payment_successful_error_details": "Оплата прошла успешно, но возникла ошибка отображения деталей подписки. Пожалуйста, проверьте раздел 'Моя подписка' или свяжитесь с поддержкой.",
|
||||
"error_processing_your_payment": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.",
|
||||
|
||||
"trial_confirm_prompt": "Активировать бесплатный пробный период на <b>{days} дней</b> с лимитом трафика <b>{traffic_gb} ГБ</b>?\n\n<i>Это можно сделать только один раз, если у вас ранее не было подписок.</i>",
|
||||
"trial_confirm_activate_button": "✅ Активировать!",
|
||||
"trial_already_had_subscription_or_trial": "Пробный период доступен только если у вас ранее не было никаких подписок (включая пробные).",
|
||||
"trial_activation_failed": "⚠️ К сожалению, не удалось активировать ваш пробный период. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"trial_feature_disabled": "Пробные периоды в данный момент недоступны.",
|
||||
"trial_cancelled": "Активация пробного периода отменена.",
|
||||
"trial_activated_alert": "✅ Пробный период активирован!",
|
||||
"trial_activated_details_message": "🎉 Ваш бесплатный пробный период на <b>{days} дней</b> активирован!\nДействителен до: <b>{end_date}</b>.\nСсылка на конфигурацию: <code>{config_link}</code>\nТрафик: <b>{traffic_gb}</b>\nПриятного использования!",
|
||||
|
||||
"referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n<code>{referral_link}</code>\n\n<b>Бонусы:</b>{bonus_details}",
|
||||
"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_bonus_inviter_notification_extended": "🎉 Ура, {referee_name} (ваш реферал) подписался!\nВам начислено <b>{days} бонусных дней</b>.\nНовая дата окончания: <b>{new_end_date}</b>.",
|
||||
"friend_placeholder": "друг",
|
||||
|
||||
"error_user_not_synced_for_config": "Аккаунт не синхронизирован. Попробуйте /sync или поддержка.",
|
||||
"error_fetching_config_link_details": "Ошибка получения данных для ссылки. Попробуйте или поддержка.",
|
||||
"error_fetching_config_link_details_no_uuid": "ID для ссылки не найден. Поддержка.",
|
||||
"error_generating_config_link": "Не удалось создать ссылку. Попробуйте позже.",
|
||||
|
||||
"promo_code_prompt": "Введите ваш промокод:",
|
||||
"promo_code_invalid_or_expired": "❌ Неверный, истекший или уже использованный промокод.",
|
||||
"promo_code_no_active_subscription": "❌ Для применения этого промокода у вас должна быть активная подписка.",
|
||||
"promo_code_applied_success": "✅ Промокод '<code>{code}</code>' применен! <b>{bonus_days} бонусных дней</b> добавлено к вашей подписке.\nТеперь ваша подписка активна до: <b>{new_end_date}</b>.",
|
||||
"promo_code_max_activations_reached": "❌ К сожалению, промокод '<code>{code}</code>' достиг максимального числа активаций.",
|
||||
"promo_code_not_found": "❌ Промокод '<code>{code}</code>' не найден.",
|
||||
"error_applying_promo_bonus": "⚠️ Произошла ошибка при применении промо-бонуса. Попробуйте или поддержка.",
|
||||
"promo_input_cancelled_no_message": "Ввод промокода отменен.",
|
||||
|
||||
"user_is_banned": "⚠️ Ваш доступ к боту заблокирован.\nЕсли это ошибка, обратитесь в поддержку.",
|
||||
"contact_support_button": "💬 Поддержка",
|
||||
"suspicious_input_ban_message": "⚠️ Обнаружен подозрительный ввод. В целях безопасности ваш доступ к боту ограничен. Обратитесь в поддержку, если это ошибка.",
|
||||
|
||||
"admin_panel_title": "👑 Админ-панель",
|
||||
"admin_panel_button_reply": "👑 Админ-панель",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_unknown_action": "Неизвестное действие.",
|
||||
"admin_action_cancelled_default": "Действие отменено.",
|
||||
"prev_page_button": "Пред.",
|
||||
"next_page_button": "След.",
|
||||
"yes_button": "✅ Да",
|
||||
"no_button": "❌ Нет",
|
||||
|
||||
"admin_ban_user_button": "🚫 Бан",
|
||||
"admin_unban_user_button": "✅ Разбан",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
"admin_ban_user_prompt": "ID или @username для бана:",
|
||||
"admin_unban_user_prompt": "ID или @username для разбана:",
|
||||
"admin_invalid_user_id_format": "Неверный ID. Нужно число.",
|
||||
"admin_invalid_username_format": "Неверный username. Формат: @юзернейм (5-32 симв.).",
|
||||
"admin_user_not_found_by_username": "Юзер @{username} не найден в боте.",
|
||||
"admin_cannot_ban_self_or_admin": "Нельзя банить себя или админа.",
|
||||
"admin_user_not_found_in_bot_db": "User ID {user_id} не найден.",
|
||||
"admin_user_banned_success_panel_too": "✅ Юзер {user_id_or_username} забанен (БД и панель).",
|
||||
"admin_user_banned_local_panel_fail": "⚠️ Юзер {user_id_or_username} забанен в БД, ошибка панели.",
|
||||
"admin_user_banned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} забанен в БД (нет ID панели).",
|
||||
"admin_user_unbanned_success_panel_too": "✅ Юзер {user_id_or_username} разбанен (БД и панель).",
|
||||
"admin_user_unbanned_local_panel_fail": "⚠️ Юзер {user_id_or_username} разбанен в БД, ошибка панели.",
|
||||
"admin_user_unbanned_local_no_panel_uuid": "✅ Юзер {user_id_or_username} разбанен в БД (нет ID панели).",
|
||||
"admin_user_already_banned": "Пользователь {user_id_or_username} уже забанен.",
|
||||
"admin_user_not_banned": "Пользователь {user_id_or_username} не забанен.",
|
||||
"admin_confirm_unban_prompt": "Вы уверены, что хотите разбанить пользователя {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_confirm_ban_prompt": "Вы уверены, что хотите забанить пользователя {user_display} (ID: <code>{user_id}</code>)?",
|
||||
"admin_user_unbanned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был разбанен.",
|
||||
"admin_user_banned_from_card": "✅ Пользователь {user_display} (ID: {user_id}) был забанен.",
|
||||
"admin_banned_list_title": "🚫 Список забаненных (Стр. {current_page}/{total_pages})",
|
||||
"admin_no_banned_users": "В настоящее время нет забаненных пользователей.",
|
||||
"admin_user_card_title": "👤 Карточка: {user_display}",
|
||||
"user_card_info": "<b>TG ID:</b> <code>{user_id}</code>\n<b>Username:</b> @{username}\n<b>Имя:</b> {first_name} {last_name}\n<b>Язык:</b> {language_code}\n<b>Panel UUID:</b> <code>{panel_user_uuid}</code>\n<b>Статус:</b> {ban_status}\n<b>Регистрация:</b> {reg_date}\n<b>Подписка до:</b> {sub_end_date}",
|
||||
"user_card_banned": "🔴 ЗАБАНЕН",
|
||||
"user_card_active": "🟢 Активен",
|
||||
"user_card_sub_na": "Н/Д",
|
||||
"user_card_unban_button": "✅ Разбанить",
|
||||
"user_card_ban_button": "🚫 Забанить",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку",
|
||||
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_broadcast_enter_message": "Сообщение для рассылки (HTML):",
|
||||
"admin_broadcast_confirm_prompt": "Превью:\n\n---\n{message_preview}\n---\n\nОтправить всем?",
|
||||
"confirm_broadcast_send_button": "✅ Отправить",
|
||||
"cancel_broadcast_button": "❌ Отмена",
|
||||
"admin_broadcast_sending_started": "Рассылка запущена...",
|
||||
"admin_broadcast_error_no_message": "Ошибка: нет сообщения для рассылки.",
|
||||
"admin_broadcast_finished_stats": "📢 Рассылка завершена.\nОтправлено: {sent_count}\nОшибок: {failed_count}",
|
||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||
|
||||
"admin_create_promo_button": "🎁 Новое промо",
|
||||
"admin_view_promos_button": "📜 Промокоды",
|
||||
"admin_promo_create_prompt": "Промо: <code>КОД ДНИ МАКС [СРОК_ДН]</code>\nНапример: <code>LETO24 7 100 30</code> (действ. 30дн)\nНапример: <code>NEWBIE 5 500</code> (бессроч.)",
|
||||
"admin_promo_invalid_format": "Формат: <code>КОД ДНИ ИСП [СРОК_ДН]</code>",
|
||||
"admin_promo_invalid_values": "Неверные значения. Дни/исп. > 0. Ошибка: {error}",
|
||||
"admin_promo_created_success": "✅ Промо '<code>{code}</code>': {bonus_days}дн, {max_activations} исп, {valid_until_str}.",
|
||||
"admin_promo_valid_until_display": "До: {date}",
|
||||
"admin_promo_valid_indefinitely": "Бессрочно",
|
||||
"admin_promo_creation_failed": "❌ Ошибка создания промо (существует или БД).",
|
||||
"admin_no_active_promos": "Активных промокодов нет.",
|
||||
"admin_active_promos_list_header": "📜 Активные промокоды:",
|
||||
"admin_promo_list_item": "- <code>{code}</code>: {bonus}дн, {current}/{max} исп, До: {valid_until}",
|
||||
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_stats_header": "📊 <b>Статистика бота:</b>",
|
||||
"admin_stats_users": "\n👥 <b>Пользователи:</b>\n Всего: {total_users}\n Бан: {banned_users}\n Активные подписки (БД): {active_subs}",
|
||||
"admin_stats_recent_payments_header": "\n💳 <b>Недавние платежи (БД бота):</b>",
|
||||
"admin_stats_payment_item": " {status_emoji} {amount} {currency} от {user_info} ({p_status}) {p_date}",
|
||||
"admin_stats_no_payments_found": "\nПлатежей в БД бота нет.",
|
||||
"admin_stats_last_sync_header": "\n🔄 <b>Синхр. с панелью:</b>",
|
||||
"admin_stats_sync_time": "Время",
|
||||
"admin_stats_sync_status": "Статус",
|
||||
"admin_stats_sync_users_processed": "Обработано с панели",
|
||||
"admin_stats_sync_subs_synced": "Подписок синхр.",
|
||||
"admin_stats_sync_details_label": "Детали",
|
||||
"admin_sync_status_never_run": "Синхронизация не проводилась.",
|
||||
|
||||
"admin_view_logs_menu_button": "📜 Логи",
|
||||
"admin_logs_menu_title": "📜 Логи Активности",
|
||||
"admin_view_all_logs_button": "Все логи",
|
||||
"admin_view_user_logs_prompt_button": "Логи юзера",
|
||||
"admin_prompt_for_user_id_or_username_logs": "ID или @username для логов:",
|
||||
"admin_all_logs_title": "📜 Активность (Стр. {current_page}/{total_pages})",
|
||||
"admin_user_logs_title": "📜 Активность {user_display} (Стр. {current_page}/{total_pages})",
|
||||
"admin_no_logs_found": "Логи не найдены.",
|
||||
"admin_log_entry_format": "[{timestamp_str}] <b>Юзер:</b> {user_display} (ID: {user_id})\n<b>Тип:</b> {event_type}\n<b>Инфо:</b> <code>{content_preview}</code>",
|
||||
"admin_log_user_not_found": "Юзер '{input}' не найден для логов.",
|
||||
"system_or_unknown_user": "Система/Неизв.",
|
||||
|
||||
"admin_sync_panel_button": "🔄 Синхр.",
|
||||
"sync_started": "Синхронизация с панелью начата...",
|
||||
"sync_completed": "Синхронизация с панелью завершена.\nСтатус: {status}\nДетали: {details}",
|
||||
"sync_failed": "Синхронизация с панелью не удалась.\nДетали: {details}",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация с панелью запущена!",
|
||||
"error_cannot_initiate_sync_no_message": "Ошибка: не могу начать синхронизацию без контекста сообщения.",
|
||||
|
||||
"admin_suspicious_promo_attempt_notification": "⚠️ <b>Подозрительный ввод промокода!</b> ⚠️\n\n<b>Пользователь:</b> {user_first_name} (@{user_username}, ID: <code>{user_id}</code>)\n<b>Ввод:</b> <pre>{promo_code_input}</pre>",
|
||||
"admin_suspicious_promo_attempt_notification_no_username": "⚠️ <b>Подозрительный ввод промокода!</b> ⚠️\n\n<b>Пользователь:</b> {user_first_name} (ID: <code>{user_id}</code>)\n<b>Ввод:</b> <pre>{promo_code_input}</pre>"
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from aiogram import Bot ,Dispatcher
|
||||
from aiogram .enums import ParseMode
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from bot .main_bot import run_bot
|
||||
from config .settings import get_settings ,Settings
|
||||
from db .database import init_db
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def main ():
|
||||
load_dotenv ()
|
||||
settings =get_settings ()
|
||||
|
||||
|
||||
await init_db ()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await run_bot (settings )
|
||||
|
||||
|
||||
if __name__ =="__main__":
|
||||
logging .basicConfig (level =logging .INFO ,stream =sys .stdout ,
|
||||
format ='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
try :
|
||||
asyncio .run (main ())
|
||||
except (KeyboardInterrupt ,SystemExit ):
|
||||
logging .info ("Bot stopped manually")
|
||||
@@ -0,0 +1,10 @@
|
||||
aiogram==3.7.0
|
||||
python-dotenv==1.0.1
|
||||
aiosqlite==0.20.0
|
||||
aiohttp==3.9.5
|
||||
pydantic==2.7.1
|
||||
# Babel==2.15.0 # Using simple JSON i18n for now, uncomment if switching
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
apscheduler==3.10.4
|
||||
pydantic_settings
|
||||
Reference in New Issue
Block a user