Initial commit

This commit is contained in:
machka
2025-05-14 08:11:59 +00:00
commit 77584b5b51
41 changed files with 5205 additions and 0 deletions
+74
View File
@@ -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
View File
@@ -0,0 +1,16 @@
# Игнорировать базу данных
bot_database.sqlite3
# Игнорировать файлы окружения
.env
# Игнорировать кэш Python
__pycache__/
# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode)
.idea/
.vscode/
# Игнорировать Docker артефакты (если вдруг)
*.log
*.pid
+11
View File
@@ -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"]
+220
View File
@@ -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).
+14
View File
@@ -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
+23
View File
@@ -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 )
+172
View File
@@ -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 ()
+96
View File
@@ -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 )
+233
View File
@@ -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 )
+157
View File
@@ -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 ()
+101
View File
@@ -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.")
+126
View File
@@ -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")
+220
View File
@@ -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 ()
+21
View File
@@ -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 )
+166
View File
@@ -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")
+127
View File
@@ -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 )
+81
View File
@@ -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 )
+165
View File
@@ -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 )
+207
View File
@@ -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 )
+113
View File
@@ -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 )
+82
View File
@@ -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 ()
+119
View File
@@ -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
View File
@@ -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 )
+132
View File
@@ -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 )
+113
View File
@@ -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 )
+97
View File
@@ -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.")
+183
View File
@@ -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}"
+144
View File
@@ -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
+88
View File
@@ -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")
+96
View File
@@ -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}"
+336
View File
@@ -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 )
+11
View File
@@ -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 ()
+4
View File
@@ -0,0 +1,4 @@
from aiogram .fsm .state import State ,StatesGroup
class UserPromoStates (StatesGroup ):
waiting_for_promo_code =State ()
+146
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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>"
}
+40
View File
@@ -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")
+10
View File
@@ -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