From 77584b5b51e912f765b1bc08743704caa91e1526 Mon Sep 17 00:00:00 2001 From: machka Date: Wed, 14 May 2025 08:11:59 +0000 Subject: [PATCH] Initial commit --- .env.sample | 74 +++ .gitignore | 16 + Dockerfile | 11 + README.md | 220 +++++++ bot/filters/admin_filter.py | 14 + bot/handlers/admin/__init__.py | 23 + bot/handlers/admin/broadcast.py | 172 ++++++ bot/handlers/admin/common.py | 96 ++++ bot/handlers/admin/logs_admin.py | 233 ++++++++ bot/handlers/admin/promo_codes.py | 157 +++++ bot/handlers/admin/statistics.py | 101 ++++ bot/handlers/admin/sync_admin.py | 126 ++++ bot/handlers/admin/user_management.py | 220 +++++++ bot/handlers/user/__init__.py | 21 + bot/handlers/user/payment.py | 166 ++++++ bot/handlers/user/promo_user.py | 127 ++++ bot/handlers/user/referral.py | 81 +++ bot/handlers/user/start.py | 165 ++++++ bot/handlers/user/subscription.py | 207 +++++++ bot/handlers/user/trial_handler.py | 113 ++++ bot/keyboards/inline/admin_keyboards.py | 82 +++ bot/keyboards/inline/user_keyboards.py | 119 ++++ bot/main_bot.py | 171 ++++++ bot/middlewares/action_logger_middleware.py | 94 +++ bot/middlewares/ban_check_middleware.py | 132 +++++ bot/middlewares/i18n.py | 113 ++++ bot/services/notification_service.py | 97 ++++ bot/services/panel_api_service.py | 183 ++++++ bot/services/payment_service.py | 144 +++++ bot/services/promo_code_service.py | 88 +++ bot/services/referral_service.py | 96 ++++ bot/services/subscription_service.py | 336 +++++++++++ bot/states/admin_states.py | 11 + bot/states/user_states.py | 4 + config/settings.py | 146 +++++ db/database.py | 607 ++++++++++++++++++++ docker-compose.yml | 15 + locales/en.json | 187 ++++++ locales/ru.json | 187 ++++++ main.py | 40 ++ requirements.txt | 10 + 41 files changed, 5205 insertions(+) create mode 100644 .env.sample create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bot/filters/admin_filter.py create mode 100644 bot/handlers/admin/__init__.py create mode 100644 bot/handlers/admin/broadcast.py create mode 100644 bot/handlers/admin/common.py create mode 100644 bot/handlers/admin/logs_admin.py create mode 100644 bot/handlers/admin/promo_codes.py create mode 100644 bot/handlers/admin/statistics.py create mode 100644 bot/handlers/admin/sync_admin.py create mode 100644 bot/handlers/admin/user_management.py create mode 100644 bot/handlers/user/__init__.py create mode 100644 bot/handlers/user/payment.py create mode 100644 bot/handlers/user/promo_user.py create mode 100644 bot/handlers/user/referral.py create mode 100644 bot/handlers/user/start.py create mode 100644 bot/handlers/user/subscription.py create mode 100644 bot/handlers/user/trial_handler.py create mode 100644 bot/keyboards/inline/admin_keyboards.py create mode 100644 bot/keyboards/inline/user_keyboards.py create mode 100644 bot/main_bot.py create mode 100644 bot/middlewares/action_logger_middleware.py create mode 100644 bot/middlewares/ban_check_middleware.py create mode 100644 bot/middlewares/i18n.py create mode 100644 bot/services/notification_service.py create mode 100644 bot/services/panel_api_service.py create mode 100644 bot/services/payment_service.py create mode 100644 bot/services/promo_code_service.py create mode 100644 bot/services/referral_service.py create mode 100644 bot/services/subscription_service.py create mode 100644 bot/states/admin_states.py create mode 100644 bot/states/user_states.py create mode 100644 config/settings.py create mode 100644 db/database.py create mode 100644 docker-compose.yml create mode 100644 locales/en.json create mode 100644 locales/ru.json create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..44ade0d --- /dev/null +++ b/.env.sample @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2ff5f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Игнорировать базу данных +bot_database.sqlite3 + +# Игнорировать файлы окружения +.env + +# Игнорировать кэш Python +__pycache__/ + +# Игнорировать настройки IDE (если используешь, например, PyCharm или VSCode) +.idea/ +.vscode/ + +# Игнорировать Docker артефакты (если вдруг) +*.log +*.pid diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..726bf1d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ada3536 --- /dev/null +++ b/README.md @@ -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 + cd + ``` + +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}/` to `http://vpn-shop:{WEB_SERVER_PORT}/`. + * **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). diff --git a/bot/filters/admin_filter.py b/bot/filters/admin_filter.py new file mode 100644 index 0000000..b170ff6 --- /dev/null +++ b/bot/filters/admin_filter.py @@ -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 \ No newline at end of file diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py new file mode 100644 index 0000000..c149232 --- /dev/null +++ b/bot/handlers/admin/__init__.py @@ -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 ) + diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py new file mode 100644 index 0000000..6c0ba65 --- /dev/null +++ b/bot/handlers/admin/broadcast.py @@ -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 () \ No newline at end of file diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py new file mode 100644 index 0000000..b01cf81 --- /dev/null +++ b/bot/handlers/admin/common.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py new file mode 100644 index 0000000..c9cea02 --- /dev/null +++ b/bot/handlers/admin/logs_admin.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/admin/promo_codes.py b/bot/handlers/admin/promo_codes.py new file mode 100644 index 0000000..b3d329e --- /dev/null +++ b/bot/handlers/admin/promo_codes.py @@ -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"{_('admin_active_promos_list_header')}\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 () + + + + diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py new file mode 100644 index 0000000..8b3b0e9 --- /dev/null +++ b/bot/handlers/admin/statistics.py @@ -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"{_('admin_stats_header')}"] + + + 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{_('admin_stats_recent_payments_header')}") + 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{_('admin_stats_last_sync_header')}") + + 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.") \ No newline at end of file diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py new file mode 100644 index 0000000..a88e28b --- /dev/null +++ b/bot/handlers/admin/sync_admin.py @@ -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"{_('admin_stats_last_sync_header')}\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") \ No newline at end of file diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py new file mode 100644 index 0000000..b8dfba5 --- /dev/null +++ b/bot/handlers/admin/user_management.py @@ -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 () \ No newline at end of file diff --git a/bot/handlers/user/__init__.py b/bot/handlers/user/__init__.py new file mode 100644 index 0000000..0852840 --- /dev/null +++ b/bot/handlers/user/__init__.py @@ -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 ) + diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py new file mode 100644 index 0000000..b6ab6c2 --- /dev/null +++ b/bot/handlers/user/payment.py @@ -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") \ No newline at end of file diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py new file mode 100644 index 0000000..bc8699a --- /dev/null +++ b/bot/handlers/user/promo_user.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py new file mode 100644 index 0000000..f2e9331 --- /dev/null +++ b/bot/handlers/user/referral.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py new file mode 100644 index 0000000..735a440 --- /dev/null +++ b/bot/handlers/user/start.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py new file mode 100644 index 0000000..6fccdf3 --- /dev/null +++ b/bot/handlers/user/subscription.py @@ -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 ) \ No newline at end of file diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py new file mode 100644 index 0000000..b3b46bf --- /dev/null +++ b/bot/handlers/user/trial_handler.py @@ -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 ) \ No newline at end of file diff --git a/bot/keyboards/inline/admin_keyboards.py b/bot/keyboards/inline/admin_keyboards.py new file mode 100644 index 0000000..16f4cde --- /dev/null +++ b/bot/keyboards/inline/admin_keyboards.py @@ -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 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 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 () \ No newline at end of file diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py new file mode 100644 index 0000000..56b9d1e --- /dev/null +++ b/bot/keyboards/inline/user_keyboards.py @@ -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 () \ No newline at end of file diff --git a/bot/main_bot.py b/bot/main_bot.py new file mode 100644 index 0000000..9641ae9 --- /dev/null +++ b/bot/main_bot.py @@ -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.") \ No newline at end of file diff --git a/bot/middlewares/action_logger_middleware.py b/bot/middlewares/action_logger_middleware.py new file mode 100644 index 0000000..28d255e --- /dev/null +++ b/bot/middlewares/action_logger_middleware.py @@ -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 ) \ No newline at end of file diff --git a/bot/middlewares/ban_check_middleware.py b/bot/middlewares/ban_check_middleware.py new file mode 100644 index 0000000..1bdc530 --- /dev/null +++ b/bot/middlewares/ban_check_middleware.py @@ -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 ) \ No newline at end of file diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py new file mode 100644 index 0000000..587672d --- /dev/null +++ b/bot/middlewares/i18n.py @@ -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 ) \ No newline at end of file diff --git a/bot/services/notification_service.py b/bot/services/notification_service.py new file mode 100644 index 0000000..73e6941 --- /dev/null +++ b/bot/services/notification_service.py @@ -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.") \ No newline at end of file diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py new file mode 100644 index 0000000..c7b0393 --- /dev/null +++ b/bot/services/panel_api_service.py @@ -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 )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}" \ No newline at end of file diff --git a/bot/services/payment_service.py b/bot/services/payment_service.py new file mode 100644 index 0000000..f60ac8a --- /dev/null +++ b/bot/services/payment_service.py @@ -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 \ No newline at end of file diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py new file mode 100644 index 0000000..c9f2c73 --- /dev/null +++ b/bot/services/promo_code_service.py @@ -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") \ No newline at end of file diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py new file mode 100644 index 0000000..47986cf --- /dev/null +++ b/bot/services/referral_service.py @@ -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}" \ No newline at end of file diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py new file mode 100644 index 0000000..eae3f50 --- /dev/null +++ b/bot/services/subscription_service.py @@ -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 ) \ No newline at end of file diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py new file mode 100644 index 0000000..4a91043 --- /dev/null +++ b/bot/states/admin_states.py @@ -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 () \ No newline at end of file diff --git a/bot/states/user_states.py b/bot/states/user_states.py new file mode 100644 index 0000000..5976225 --- /dev/null +++ b/bot/states/user_states.py @@ -0,0 +1,4 @@ +from aiogram .fsm .state import State ,StatesGroup + +class UserPromoStates (StatesGroup ): + waiting_for_promo_code =State () \ No newline at end of file diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..94e95b7 --- /dev/null +++ b/config/settings.py @@ -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 \ No newline at end of file diff --git a/db/database.py b/db/database.py new file mode 100644 index 0000000..0923a8c --- /dev/null +++ b/db/database.py @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ed183ec --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..5e653e4 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,187 @@ +{ + "welcome": "Welcome, {user_name}!", + "choose_language": "Please choose your language:", + "language_set": "Language set to English.", + "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 {months} months for {price} {currency_symbol}?\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 {months} months subscription is active until {end_date}.", + "payment_failed": "❌ Payment failed/cancelled. If debited, contact support.", + "payment_description_subscription": "{months}-Month Subscription", + "my_subscription_details": "ℹ️ Your Subscription\n\n🗓️ Ends: {end_date} (Days left: {days_left})\n🔗 Link: {config_link}\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 {end_date} ({days_left} days left).\nRenew to continue service!", + "payment_successful_with_referral_bonus": "✅ Payment for {months} months successful! Initial end: {base_end_date}.\n\n🎉 Invited by {inviter_name}, you got {bonus_days} bonus days!\nFinal end date: {final_end_date}.", + "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 {days}-day free trial with {traffic_gb} GB traffic?\n\nThis can only be done once and is not available if you've had a subscription before.", + "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 {days}-day free trial is now active!\nIt's valid until: {end_date}.\nConfig link: {config_link}\nTraffic: {traffic_gb}\nEnjoy!", + + "referral_program_info_new": "Invite friends, get bonus days!\n\nYour referral link:\n{referral_link}\n\nBonuses:{bonus_details}", + "referral_bonus_per_period": "\n\n🎁 Friend's {months}-mo sub:\n ➢ You get: {inviter_bonus_days} days\n ➢ Friend gets: {referee_bonus_days} days", + "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 {days} bonus days to your subscription.\nYour subscription now ends on: {new_end_date}.", + "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}' applied! {bonus_days} bonus days added.\nNew end date: {new_end_date}.", + "promo_code_max_activations_reached": "❌ Promo '{code}' max activations reached.", + "promo_code_not_found": "❌ Promo 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: {user_id})?", + "admin_confirm_ban_prompt": "Are you sure you want to ban user {user_display} (ID: {user_id})?", + "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": "TG ID: {user_id}\nUsername: @{username}\nName: {first_name} {last_name}\nLanguage: {language_code}\nPanel UUID: {panel_user_uuid}\nStatus: {ban_status}\nRegistered: {reg_date}\nSubscription Ends: {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 DAYS MAX_USES [VALID_DAYS]\nEx: SALE24 7 100 30 (30d validity)\nEx: NEWBIE 5 500 (no expiry)", + "admin_promo_invalid_format": "Invalid format. Use: CODE DAYS USES [VALIDITY]", + "admin_promo_invalid_values": "Invalid values. Days/uses must be positive. Error: {error}", + "admin_promo_created_success": "✅ Promo '{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}: {bonus} days, {current}/{max} used, Valid: {valid_until}", + + "admin_stats_button": "📊 Stats", + "admin_stats_header": "📊 Bot Statistics:", + "admin_stats_users": "\n👥 Users:\n Total: {total_users}\n Banned: {banned_users}\n Active Subs (Bot): {active_subs}", + "admin_stats_recent_payments_header": "\n💳 Recent Payments (Bot DB):", + "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🔄 Last Panel Sync:", + "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}] User: {user_display} (ID: {user_id})\nType: {event_type}\nContent: {content_preview}", + "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": "⚠️ Suspicious Promo Code Input! ⚠️\n\nUser: {user_first_name} (@{user_username}, ID: {user_id})\nInput:
{promo_code_input}
", + "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Suspicious Promo Code Input! ⚠️\n\nUser: {user_first_name} (ID: {user_id})\nInput:
{promo_code_input}
" +} diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..09a7f5b --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,187 @@ +{ + "welcome": "Добро пожаловать, {user_name}!", + "choose_language": "Пожалуйста, выберите ваш язык:", + "language_set": "Язык установлен на Русский.", + "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": "Подписка на {months} мес. за {price} {currency_symbol}.\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": "✅ Оплата успешна! Ваша подписка на {months} мес. активна до {end_date}.", + "payment_failed": "❌ Оплата не удалась/отменена. Если деньги списаны, напишите в поддержку.", + "payment_description_subscription": "Подписка на {months} мес.", + "my_subscription_details": "ℹ️ Ваша подписка\n\n🗓️ До: {end_date} (Осталось: {days_left} дн.)\n🔗 Ссылка: {config_link}\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Ваша подписка истекает {end_date} (через {days_left} дн.).\nНе забудьте продлить!", + "payment_successful_with_referral_bonus": "✅ Оплата на {months} мес. успешна! Базовая дата: {base_end_date}.\n\n🎉 Вас пригласил(а) {inviter_name}, вы получили дополнительно {bonus_days} бонусных дней!\nИтоговая дата окончания: {final_end_date}.", + "payment_successful_error_details": "Оплата прошла успешно, но возникла ошибка отображения деталей подписки. Пожалуйста, проверьте раздел 'Моя подписка' или свяжитесь с поддержкой.", + "error_processing_your_payment": "Ошибка обработки платежа. Обратитесь в поддержку, если проблема не решится.", + + "trial_confirm_prompt": "Активировать бесплатный пробный период на {days} дней с лимитом трафика {traffic_gb} ГБ?\n\nЭто можно сделать только один раз, если у вас ранее не было подписок.", + "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": "🎉 Ваш бесплатный пробный период на {days} дней активирован!\nДействителен до: {end_date}.\nСсылка на конфигурацию: {config_link}\nТрафик: {traffic_gb}\nПриятного использования!", + + "referral_program_info_new": "Приглашайте друзей и получайте бонусы!\n\nВаша реф. ссылка:\n{referral_link}\n\nБонусы:{bonus_details}", + "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: {inviter_bonus_days} дн.\n ➢ Друг: {referee_bonus_days} дн.", + "no_bonus_days": "0", + "referral_no_bonuses_configured": "\nРеферальные бонусы не настроены.", + "referral_link_for_copying_reminder": "Ссылка выше. Нажмите и удерживайте для копирования.", + "referral_bonus_inviter_notification_extended": "🎉 Ура, {referee_name} (ваш реферал) подписался!\nВам начислено {days} бонусных дней.\nНовая дата окончания: {new_end_date}.", + "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}' применен! {bonus_days} бонусных дней добавлено к вашей подписке.\nТеперь ваша подписка активна до: {new_end_date}.", + "promo_code_max_activations_reached": "❌ К сожалению, промокод '{code}' достиг максимального числа активаций.", + "promo_code_not_found": "❌ Промокод '{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: {user_id})?", + "admin_confirm_ban_prompt": "Вы уверены, что хотите забанить пользователя {user_display} (ID: {user_id})?", + "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": "TG ID: {user_id}\nUsername: @{username}\nИмя: {first_name} {last_name}\nЯзык: {language_code}\nPanel UUID: {panel_user_uuid}\nСтатус: {ban_status}\nРегистрация: {reg_date}\nПодписка до: {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": "Промо: КОД ДНИ МАКС [СРОК_ДН]\nНапример: LETO24 7 100 30 (действ. 30дн)\nНапример: NEWBIE 5 500 (бессроч.)", + "admin_promo_invalid_format": "Формат: КОД ДНИ ИСП [СРОК_ДН]", + "admin_promo_invalid_values": "Неверные значения. Дни/исп. > 0. Ошибка: {error}", + "admin_promo_created_success": "✅ Промо '{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}: {bonus}дн, {current}/{max} исп, До: {valid_until}", + + "admin_stats_button": "📊 Статистика", + "admin_stats_header": "📊 Статистика бота:", + "admin_stats_users": "\n👥 Пользователи:\n Всего: {total_users}\n Бан: {banned_users}\n Активные подписки (БД): {active_subs}", + "admin_stats_recent_payments_header": "\n💳 Недавние платежи (БД бота):", + "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🔄 Синхр. с панелью:", + "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}] Юзер: {user_display} (ID: {user_id})\nТип: {event_type}\nИнфо: {content_preview}", + "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": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (@{user_username}, ID: {user_id})\nВвод:
{promo_code_input}
", + "admin_suspicious_promo_attempt_notification_no_username": "⚠️ Подозрительный ввод промокода! ⚠️\n\nПользователь: {user_first_name} (ID: {user_id})\nВвод:
{promo_code_input}
" +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..c190122 --- /dev/null +++ b/main.py @@ -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") \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47548ca --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file