Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60ea6fff0d | ||
|
|
3cee4b243a | ||
|
|
a42f80160b | ||
|
|
74548527f1 | ||
|
|
f8e3bee52a | ||
|
|
649528e165 | ||
|
|
859263dc2d | ||
|
|
a126c05365 | ||
|
|
d0c09f9e06 | ||
|
|
9f7171a5c3 | ||
|
|
990b08cfdc | ||
|
|
359a3c46a4 | ||
|
|
00ffec13d8 | ||
|
|
5c8099168d | ||
|
|
4675141974 | ||
|
|
67f31d62e4 | ||
|
|
c969d52ce1 | ||
|
|
3bd29511e1 |
@@ -18,6 +18,7 @@ SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos
|
||||
SUBSCRIPTION_MINI_APP_URL=
|
||||
START_COMMAND_DESCRIPTION=
|
||||
DISABLE_WELCOME_MESSAGE=
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
@@ -1,245 +1,183 @@
|
||||
# Telegram-бот для продажи подписок Remnawave
|
||||
|
||||
# Remnawave Subscription Sales Telegram Bot
|
||||
Этот Telegram-бот предназначен для автоматизации продажи и управления подписками для панели **Remnawave**. Он интегрируется с API Remnawave для управления пользователями и подписками, а также использует различные платежные системы для приема платежей.
|
||||
|
||||
This Telegram bot is designed to automate the sale and management of subscriptions for a **Remnawave panel**. It integrates with the Remnawave API for user and subscription management and uses YooKassa for processing payments.
|
||||
## ✨ Ключевые возможности
|
||||
|
||||
## ✨ Features
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
||||
|
||||
* **User Interaction:**
|
||||
* User registration with language selection (English/Russian).
|
||||
* Display of main menu with available actions via inline keyboards.
|
||||
* Ability for users to view their current subscription status, expiration date, and configuration link.
|
||||
* Trial subscription system for new users (configurable, manual activation via button).
|
||||
* Promo code system for users to apply discounts or bonuses.
|
||||
* Referral program for users to earn bonus subscription days.
|
||||
* **Subscription Management:**
|
||||
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
|
||||
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
|
||||
* Supports **Crypto Pay** for payments with fiat currency (RUB by default).
|
||||
* Automatic subscription activation/extension upon successful payment.
|
||||
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
|
||||
* Updates user status, expiration dates, traffic limits, and internal squads on the Remnawave panel.
|
||||
* **Admin Panel:**
|
||||
* Protected by `ADMIN_IDS` (supports multiple administrators).
|
||||
* **Statistics:** View bot usage (total users, banned, active subscriptions), recent payments, and panel sync status.
|
||||
* **User Management:**
|
||||
* Ban/Unban users by Telegram ID or @username (updates local DB and panel).
|
||||
* View a paginated list of banned users.
|
||||
* View a "user card" with detailed information and unban option.
|
||||
* **Broadcast:** Send messages to all users, users with active subscriptions, or users with expired subscriptions.
|
||||
* **Promo Codes:** Create and view promo codes (bonus days, activation limits, validity).
|
||||
* **Panel Sync:** Manually trigger synchronization of users and subscriptions from the Remnawave panel to the bot's database, matching by Telegram ID.
|
||||
* **Activity Logs:** View a paginated list of all user actions (messages, commands, callbacks) or logs for a specific user.
|
||||
* **Notifications:**
|
||||
* Automated daily notifications to users about expiring subscriptions (via APScheduler).
|
||||
* Notifications to users and inviters upon successful referral bonus application.
|
||||
* Notifications to admin(s) about suspicious promo code input attempts.
|
||||
* **Security & Technical:**
|
||||
* Uses parameterized queries to prevent SQL injection.
|
||||
* Proactive check for suspicious input in promo code field (notifies admin).
|
||||
* Middleware for checking banned users on every interaction.
|
||||
* Middleware for logging user actions.
|
||||
* Webhook support for Telegram and YooKassa for efficient updates.
|
||||
* Configurable via `.env` file.
|
||||
* Dockerized for easy deployment.
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
- **Статистика:** Просмотр статистики использования бота (общее количество пользователей, забаненные, активные подписки), недавние платежи и статус синхронизации с панелью.
|
||||
- **Управление пользователями:** Блокировка/разблокировка пользователей, просмотр списка забаненных и детальной информации о пользователе.
|
||||
- **Рассылка:** Отправка сообщений всем пользователям, пользователям с активной или истекшей подпиской.
|
||||
- **Управление промокодами:** Создание и просмотр промокодов.
|
||||
- **Синхронизация с панелью:** Ручной запуск синхронизации пользователей и подписок с панелью Remnawave.
|
||||
- **Логи действий:** Просмотр логов всех действий пользователей.
|
||||
|
||||
## 🚀 Technologies Used
|
||||
## 🚀 Технологии
|
||||
|
||||
* **Python 3.11**
|
||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||
* **aiohttp:** For running the webhook server
|
||||
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||
* **YooKassa SDK:** For payment processing
|
||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||
* **Pydantic:** For settings management (loading from `.env`)
|
||||
* **Docker & Docker Compose:** For containerization and deployment
|
||||
- **Python 3.11**
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
|
||||
- **APScheduler:** Для выполнения отложенных задач (например, уведомления об окончании подписки).
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
## ⚙️ Setup and Configuration
|
||||
## ⚙️ Установка и запуск
|
||||
|
||||
### Prerequisites
|
||||
### Предварительные требования
|
||||
|
||||
* Docker and Docker Compose installed.
|
||||
* A running instance of a Remnawave panel.
|
||||
* A Telegram Bot Token.
|
||||
* A YooKassa Shop ID and Secret Key.
|
||||
- Установленные Docker и Docker Compose.
|
||||
- Рабочая панель Remnawave.
|
||||
- Токен Telegram-бота.
|
||||
- Данные для подключения к платежным системам (YooKassa, CryptoPay и т.д.).
|
||||
|
||||
### Configuration Steps
|
||||
### Шаги установки
|
||||
|
||||
1. **Clone the Repository:**
|
||||
1. **Клонируйте репозиторий:**
|
||||
```bash
|
||||
git clone https://github.com/machka-pasla/remnawave-tg-shop
|
||||
cd remnawave-tg-shop
|
||||
```
|
||||
|
||||
2. **Create an `.env` File:**
|
||||
Copy the `env.example` file to `.env` and fill in your specific values:
|
||||
2. **Создайте и настройте файл `.env`:**
|
||||
Скопируйте `env.example` в `.env` и заполните своими данными.
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
Key variables to configure in `.env`:
|
||||
* `BOT_TOKEN`: Your Telegram Bot Token from BotFather.
|
||||
* `ADMIN_IDS`: Comma-separated list of your Telegram User IDs for admin access (e.g., `12345678,98765432`). **Crucial for bot management.**
|
||||
* `DEFAULT_LANGUAGE`: Default language for new users (e.g., `ru` or `en`).
|
||||
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
|
||||
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
|
||||
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
|
||||
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
|
||||
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
|
||||
* **YooKassa Settings:**
|
||||
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
|
||||
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
|
||||
* `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`.
|
||||
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
|
||||
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
|
||||
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
|
||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
||||
* **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`.
|
||||
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`.
|
||||
* **Subscription Options:** For each duration you can use variables like
|
||||
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
|
||||
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
|
||||
* **Panel API Settings:**
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
|
||||
* `PANEL_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel.
|
||||
* `USER_SQUAD_UUIDS`: (Optional) Comma-separated list of internal squad UUIDs from your panel to assign to users during creation.
|
||||
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
|
||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
|
||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||
Ниже перечислены ключевые переменные.
|
||||
|
||||
3. **Locales:**
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
|
||||
<details>
|
||||
<summary><b>Основные настройки</b></summary>
|
||||
|
||||
4. **Run with Docker Compose:**
|
||||
| Переменная | Описание | Пример |
|
||||
| --- | --- | --- |
|
||||
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
|
||||
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки платежей и вебхуков</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
|
||||
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
|
||||
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
|
||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки подписок</b></summary>
|
||||
|
||||
Для каждого периода (1, 3, 6, 12 месяцев) можно настроить доступность и цены:
|
||||
- `1_MONTH_ENABLED`: `true` или `false`
|
||||
- `RUB_PRICE_1_MONTH`: Цена в рублях
|
||||
- `STARS_PRICE_1_MONTH`: Цена в Telegram Stars
|
||||
- `TRIBUTE_LINK_1_MONTH`: Ссылка для оплаты через Tribute
|
||||
Аналогичные переменные есть для `3_MONTHS`, `6_MONTHS`, `12_MONTHS`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки панели Remnawave</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `PANEL_API_URL` | URL API вашей панели Remnawave. |
|
||||
| `PANEL_API_KEY` | API ключ для доступа к панели. |
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
</gidetails>
|
||||
|
||||
<details>
|
||||
<summary><b>Настройки пробного периода</b></summary>
|
||||
|
||||
| Переменная | Описание |
|
||||
| --- | --- |
|
||||
| `TRIAL_ENABLED` | Включить/выключить пробный период (`true`/`false`). |
|
||||
| `TRIAL_DURATION_DAYS`| Длительность пробного периода в днях. |
|
||||
| `TRIAL_TRAFFIC_LIMIT_GB`| Лимит трафика для пробного периода в ГБ. |
|
||||
</details>
|
||||
|
||||
3. **Запустите контейнеры:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
5. **Webhook Setup (Important if using webhooks):**
|
||||
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`.
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`.
|
||||
* If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
|
||||
6. **Database:**
|
||||
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
- **Для Telegram:** Бот автоматически установит вебхук, если в `.env` указан `WEBHOOK_BASE_URL`. Путь будет `https://<ваш_домен>/<BOT_TOKEN>`.
|
||||
|
||||
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
|
||||
|
||||
5. **Просмотр логов:**
|
||||
```bash
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
## 🐳 Docker Setup
|
||||
## 🐳 Docker
|
||||
|
||||
### `Dockerfile`
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
# Consider adding build arguments for proxy if needed in your environment
|
||||
# ARG HTTP_PROXY
|
||||
# ARG HTTPS_PROXY
|
||||
# ENV http_proxy=$HTTP_PROXY
|
||||
# ENV https_proxy=$HTTPS_PROXY
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# Ensure main.py is executable if needed, though python command handles it
|
||||
# RUN chmod +x main.py
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
```
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
```
|
||||
|
||||
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
|
||||
|
||||
## 🛠️ Project Structure (Overview)
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── bot/
|
||||
│ ├── filters/ # Custom Aiogram filters (e.g., AdminFilter)
|
||||
│ ├── handlers/ # Message and callback query handlers (admin and user)
|
||||
│ ├── keyboards/ # Inline and reply keyboard generators
|
||||
│ ├── middlewares/ # Custom Aiogram middlewares (i18n, ban check, logger)
|
||||
│ ├── services/ # Business logic (payments, subscriptions, panel API interaction)
|
||||
│ ├── states/ # FSM states
|
||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||
│ ├── filters/ # Пользовательские фильтры Aiogram
|
||||
│ ├── handlers/ # Обработчики сообщений и колбэков
|
||||
│ ├── keyboards/ # Клавиатуры
|
||||
│ ├── middlewares/ # Промежуточные слои (i18n, проверка бана)
|
||||
│ ├── services/ # Бизнес-логика (платежи, API панели)
|
||||
│ ├── states/ # Состояния FSM
|
||||
│ └── main_bot.py # Основная логика бота
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings and config parser
|
||||
│ └── settings.py # Настройки Pydantic
|
||||
├── db/
|
||||
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||
│ ├── database_setup.py # DB connection/init setup
|
||||
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||
├── locales/ # Localization files
|
||||
│ ├── en.json # English locale
|
||||
│ └── ru.json # Russian locale
|
||||
├── .env.example # Example environment variables for local setup
|
||||
├── .env # Actual environment variables (ignored by Git)
|
||||
├── Dockerfile # Docker image build instructions
|
||||
├── docker-compose.yml # Docker Compose orchestration config
|
||||
├── requirements.txt # List of Python dependencies
|
||||
├── README.md # Project documentation
|
||||
└── main.py # Entry point to launch the bot
|
||||
│ ├── dal/ # Слой доступа к данным (DAL)
|
||||
│ ├── database_setup.py # Настройка БД
|
||||
│ └── models.py # Модели SQLAlchemy
|
||||
├── locales/ # Файлы локализации (ru, en)
|
||||
├── .env.example # Пример файла с переменными окружения
|
||||
├── Dockerfile # Инструкции для сборки Docker-образа
|
||||
├── docker-compose.yml # Файл для оркестрации контейнеров
|
||||
├── requirements.txt # Зависимости Python
|
||||
└── main.py # Точка входа в приложение
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
## 🔮 Планы на будущее
|
||||
|
||||
Contributions are welcome!
|
||||
- Расширенные типы промокодов (например, скидки в процентах).
|
||||
|
||||
## 🔮 Future Enhancements
|
||||
|
||||
- More detailed analytics for admin.
|
||||
- Support for different payment methods.
|
||||
- Advanced promo code types (e.g., percentage discounts).
|
||||
|
||||
## Donations (pls)
|
||||
- Russian and international cards [LINK](https://t.me/tribute/app?startapp=dqdg)
|
||||
## ❤️ Поддержка
|
||||
- Карты РФ и зарубежные: [Tribute](https://t.me/tribute/app?startapp=dqdg)
|
||||
- Crypto: `USDT TRC-20 TT3SqBbfU4vYm6SUwUVNZsy278m2xbM4GE`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional
|
||||
@@ -17,6 +18,7 @@ from bot.keyboards.inline.admin_keyboards import (
|
||||
get_admin_panel_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
@@ -82,8 +84,7 @@ async def process_broadcast_message_handler(
|
||||
broadcast_entities=entities,
|
||||
)
|
||||
|
||||
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=text)
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
@@ -171,22 +172,30 @@ async def confirm_broadcast_callback_handler(
|
||||
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
|
||||
)
|
||||
|
||||
# Get message queue manager
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
await callback.message.edit_text("❌ Ошибка: система очередей не инициализирована", reply_markup=None)
|
||||
return
|
||||
|
||||
# Queue all messages for sending
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await bot.send_message(
|
||||
await queue_manager.send_message(
|
||||
chat_id=uid,
|
||||
text=text,
|
||||
entities=entities,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
|
||||
# Log successful queuing
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"event_type": "admin_broadcast_queued",
|
||||
"content": f"To user {uid}: {text[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
@@ -195,7 +204,7 @@ async def confirm_broadcast_callback_handler(
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
f"Failed to queue broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
@@ -209,7 +218,6 @@ async def confirm_broadcast_callback_handler(
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
@@ -217,7 +225,18 @@ async def confirm_broadcast_callback_handler(
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||
# Get queue stats for detailed report
|
||||
queue_stats = queue_manager.get_queue_stats()
|
||||
|
||||
result_message = f"""🚀 Рассылка поставлена в очередь!
|
||||
📤 В очередь добавлено: {sent_count}
|
||||
❌ Ошибок: {failed_count}
|
||||
|
||||
📊 Статус очередей:
|
||||
👥 Очередь пользователей: {queue_stats['user_queue_size']} сообщений
|
||||
📢 Очередь групп: {queue_stats['group_queue_size']} сообщений
|
||||
|
||||
ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."""
|
||||
await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
|
||||
@@ -14,6 +14,7 @@ from bot.keyboards.inline.admin_keyboards import (
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
|
||||
from . import broadcast as admin_broadcast_handlers
|
||||
from .promo import create as admin_promo_create_handlers
|
||||
@@ -120,6 +121,8 @@ async def admin_panel_actions_callback_handler(
|
||||
panel_service=panel_service,
|
||||
session=session)
|
||||
await callback.answer(_("admin_sync_initiated_from_panel"))
|
||||
elif action == "queue_status":
|
||||
await show_queue_status_handler(callback, i18n_data)
|
||||
elif action == "main":
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
@@ -192,3 +195,52 @@ async def admin_section_handler(callback: types.CallbackQuery, state: FSMContext
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def show_queue_status_handler(callback: types.CallbackQuery, i18n_data: dict):
|
||||
"""Show message queue status to admin"""
|
||||
current_lang = i18n_data.get("current_language", "ru")
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
await callback.message.edit_text(
|
||||
"❌ Система очередей не инициализирована",
|
||||
reply_markup=InlineKeyboardBuilder().button(
|
||||
text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"
|
||||
).as_markup()
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
stats = queue_manager.get_queue_stats()
|
||||
|
||||
message_text = _(
|
||||
"admin_queue_status_info",
|
||||
user_queue_size=stats['user_queue_size'],
|
||||
user_processing="✅ Да" if stats['user_queue_processing'] else "❌ Нет",
|
||||
user_recent=stats['user_recent_sends'],
|
||||
group_queue_size=stats['group_queue_size'],
|
||||
group_processing="✅ Да" if stats['group_queue_processing'] else "❌ Нет",
|
||||
group_recent=stats['group_recent_sends']
|
||||
)
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
|
||||
await callback.message.edit_text(
|
||||
message_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting queue status: {e}")
|
||||
await callback.answer("❌ Ошибка получения статуса очередей", show_alert=True)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import csv
|
||||
import io
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -421,15 +423,63 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
)
|
||||
)
|
||||
|
||||
# Create CSV file with promo codes if any were created
|
||||
csv_file = None
|
||||
if created_codes:
|
||||
success_lines.append("\n🎟 <b>Созданные коды:</b>")
|
||||
# Show first 20 codes, then indicate if there are more
|
||||
codes_to_show = created_codes[:20]
|
||||
for code in codes_to_show:
|
||||
success_lines.append(f"<code>{code}</code>")
|
||||
success_lines.append(f"\n🎟 <b>Создано {len(created_codes)} промокодов</b>")
|
||||
success_lines.append("📄 CSV файл с промокодами отправлен отдельным сообщением")
|
||||
|
||||
if len(created_codes) > 20:
|
||||
success_lines.append(f"... и еще {len(created_codes) - 20} кодов")
|
||||
# Create CSV file
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# CSV headers
|
||||
writer.writerow([
|
||||
"Промокод", "Бонусные дни", "Макс. активации", "Действителен до",
|
||||
"Команда для старта", "Ссылка для активации"
|
||||
])
|
||||
|
||||
# Get real bot username
|
||||
bot_username = 'your_bot' # fallback
|
||||
try:
|
||||
if hasattr(callback_or_message, 'message'):
|
||||
bot = callback_or_message.message.bot
|
||||
else:
|
||||
bot = callback_or_message.bot
|
||||
|
||||
bot_info = await bot.get_me()
|
||||
bot_username = bot_info.username or 'your_bot'
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to get bot username for CSV links: {e}")
|
||||
bot_username = 'your_bot'
|
||||
|
||||
for code in created_codes:
|
||||
# Determine validity info
|
||||
if data.get("validity_days"):
|
||||
valid_until = (datetime.now(timezone.utc) + timedelta(days=data["validity_days"])).strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
valid_until = "Без ограничений"
|
||||
|
||||
start_command = f"/start promo_{code}"
|
||||
telegram_link = f"https://t.me/{bot_username}?start=promo_{code}"
|
||||
|
||||
writer.writerow([
|
||||
code,
|
||||
data["bonus_days"],
|
||||
data["max_activations"],
|
||||
valid_until,
|
||||
start_command,
|
||||
telegram_link
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Create file for sending
|
||||
filename = f"bulk_promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
csv_file = types.BufferedInputFile(
|
||||
output.getvalue().encode('utf-8-sig'), # BOM for correct Excel display
|
||||
filename=filename
|
||||
)
|
||||
|
||||
if failed_codes:
|
||||
success_lines.append(f"\n❌ <b>Ошибки ({len(failed_codes)}):</b>")
|
||||
@@ -447,19 +497,26 @@ async def create_bulk_promo_codes_final(callback_or_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
message_obj = callback_or_message.message
|
||||
except Exception:
|
||||
await callback_or_message.message.answer(
|
||||
message_obj = await callback_or_message.message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback_or_message.answer()
|
||||
else: # Message
|
||||
await callback_or_message.answer(
|
||||
message_obj = await callback_or_message.answer(
|
||||
success_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Send CSV file if created
|
||||
if csv_file:
|
||||
csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый"
|
||||
await message_obj.answer_document(csv_file, caption=csv_caption)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -345,18 +345,22 @@ async def create_promo_code_final(callback_or_message,
|
||||
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
|
||||
await session.commit()
|
||||
|
||||
# Log successful creation
|
||||
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}")
|
||||
|
||||
# Success message
|
||||
valid_until_str = _("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
success_text = _(
|
||||
"admin_promo_created_success",
|
||||
default="✅ <b>Промокод успешно создан!</b>\n\n"
|
||||
"🎟 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Макс. активаций: <b>{max_activations}</b>\n"
|
||||
"⏰ Срок действия: <b>{validity}</b>",
|
||||
"⏰ Срок действия: <b>{valid_until_str}</b>",
|
||||
code=data["promo_code"],
|
||||
bonus_days=data["bonus_days"],
|
||||
max_activations=data["max_activations"],
|
||||
validity=_("admin_promo_unlimited", default="Без ограничений") if not data.get("validity_days") else f"{data['validity_days']} дней"
|
||||
valid_until_str=valid_until_str
|
||||
)
|
||||
|
||||
if hasattr(callback_or_message, 'message'): # CallbackQuery
|
||||
|
||||
+349
-332
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
from aiogram import Router, F, types
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -6,7 +8,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from config.settings import Settings, get_settings
|
||||
from db.dal import promo_code_dal
|
||||
from db.models import PromoCode, PromoCodeActivation
|
||||
from bot.states.admin_states import AdminStates
|
||||
@@ -17,10 +19,55 @@ from bot.middlewares.i18n import JsonI18n
|
||||
router = Router(name="promo_manage_router")
|
||||
|
||||
|
||||
async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""View all active promo codes"""
|
||||
def get_promo_status_emoji_and_text(promo: PromoCode, i18n: JsonI18n, current_lang: str):
|
||||
"""Determine promo code status and return emoji + text"""
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
|
||||
return "⏰", _("admin_promo_status_expired")
|
||||
elif promo.current_activations >= promo.max_activations:
|
||||
return "🔄", _("admin_promo_status_used_up")
|
||||
elif promo.is_active:
|
||||
return "✅", _("admin_promo_status_active")
|
||||
else:
|
||||
return "🚫", _("admin_promo_status_inactive")
|
||||
|
||||
|
||||
async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSession, i18n: JsonI18n, current_lang: str):
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None, None
|
||||
|
||||
status_emoji, status = get_promo_status_emoji_and_text(promo, i18n, current_lang)
|
||||
|
||||
validity = _("admin_promo_valid_indefinitely")
|
||||
if promo.valid_until:
|
||||
validity = promo.valid_until.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
|
||||
|
||||
text = "\n".join([
|
||||
_("admin_promo_card_title", code=promo.code),
|
||||
_("admin_promo_card_bonus_days", days=promo.bonus_days),
|
||||
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
|
||||
_("admin_promo_card_validity", validity=validity),
|
||||
_("admin_promo_card_status", status=status),
|
||||
_("admin_promo_card_created", created=created),
|
||||
_("admin_promo_card_created_by", creator=promo.created_by_admin_id)
|
||||
])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_button"), callback_data=f"promo_edit_select:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_toggle_status_button"), callback_data=f"promo_toggle:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_view_activations_button"), callback_data=f"promo_activations:{promo_id}:0"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_delete_button"), callback_data=f"promo_delete:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_list_button"), callback_data="admin_action:promo_management"))
|
||||
|
||||
return text, builder.as_markup()
|
||||
|
||||
|
||||
async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
@@ -28,395 +75,365 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
|
||||
limit=20,
|
||||
offset=0)
|
||||
|
||||
if not promo_models:
|
||||
text = _(
|
||||
"admin_no_promo_codes",
|
||||
default="📋 <b>Активные промокоды</b>\n\n❌ Нет активных промокодов"
|
||||
)
|
||||
else:
|
||||
lines = [_("admin_active_promo_codes_title",
|
||||
default="📋 <b>Активные промокоды</b>\n")]
|
||||
|
||||
for promo in promo_models:
|
||||
validity_str = _("admin_promo_unlimited", default="Без ограничений")
|
||||
if promo.valid_until:
|
||||
validity_str = promo.valid_until.strftime("%d.%m.%Y")
|
||||
|
||||
lines.append(
|
||||
f"🎟 <code>{promo.code}</code> | "
|
||||
f"🎁 {promo.bonus_days}д | "
|
||||
f"📊 {promo.current_activations}/{promo.max_activations} | "
|
||||
f"⏰ {validity_str}"
|
||||
)
|
||||
|
||||
text = "\n".join(lines)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error viewing promo codes: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
|
||||
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join(
|
||||
[_("admin_active_promos_list_header"), ""] + [
|
||||
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}"
|
||||
for p in promo_models
|
||||
]
|
||||
)
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def promo_management_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""Main promo management interface"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
async def promo_management_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession, page: int = 0):
|
||||
current_lang = i18n_data.get("current_language", "ru")
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0)
|
||||
|
||||
if not promo_models:
|
||||
text = _(
|
||||
"admin_no_promo_codes_to_manage",
|
||||
default="🛠 <b>Управление промокодами</b>\n\n❌ Нет промокодов для управления"
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
text_lines = [_("admin_promo_management_title", default="🛠 <b>Управление промокодами</b>\n")]
|
||||
|
||||
for promo in promo_models:
|
||||
status_emoji = "✅" if promo.is_active else "❌"
|
||||
validity_str = _("admin_promo_unlimited", default="∞")
|
||||
if promo.valid_until:
|
||||
validity_str = promo.valid_until.strftime("%d.%m")
|
||||
|
||||
text_lines.append(
|
||||
f"{status_emoji} <code>{promo.code}</code> | "
|
||||
f"🎁{promo.bonus_days}д | "
|
||||
f"📊{promo.current_activations}/{promo.max_activations} | "
|
||||
f"⏰{validity_str}"
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=f"📝 {promo.code}",
|
||||
callback_data=f"promo_detail:{promo.promo_code_id}"
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_panel", default="🔙 В админ панель"),
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
)
|
||||
|
||||
text = "\n".join(text_lines)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
page_size = 10 # Количество промокодов на странице
|
||||
offset = page * page_size
|
||||
|
||||
# Получаем общее количество промокодов
|
||||
total_count = await promo_code_dal.get_promo_codes_count(session)
|
||||
total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 1
|
||||
|
||||
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=page_size, offset=offset)
|
||||
if not promo_models and page == 0:
|
||||
await callback.message.edit_text(_("admin_promo_management_empty"), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
|
||||
button_text = f"{status_emoji} {promo.code} ({promo.current_activations}/{promo.max_activations})"
|
||||
builder.row(InlineKeyboardButton(text=button_text, callback_data=f"promo_detail:{promo.promo_code_id}"))
|
||||
|
||||
# Добавляем кнопки пагинации если есть больше одной страницы
|
||||
if total_pages > 1:
|
||||
pagination_buttons = []
|
||||
if page > 0:
|
||||
pagination_buttons.append(InlineKeyboardButton(text=_("prev_page_button"), callback_data=f"promo_management:{page-1}"))
|
||||
if page < total_pages - 1:
|
||||
pagination_buttons.append(InlineKeyboardButton(text=_("next_page_button"), callback_data=f"promo_management:{page+1}"))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in promo management: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Добавляем кнопки экспорта и возврата
|
||||
builder.row(InlineKeyboardButton(text="📄 Экспорт CSV", callback_data="promo_export_all"))
|
||||
builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main"))
|
||||
|
||||
# Формируем заголовок с информацией о страницах
|
||||
title = _("admin_promo_management_title")
|
||||
if total_pages > 1:
|
||||
title += f"\n{_('admin_promo_list_page_info', current=page+1, total=total_pages, count=total_count)}"
|
||||
|
||||
await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_management:"))
|
||||
async def promo_management_pagination_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
try:
|
||||
page = int(callback.data.split(":")[1])
|
||||
await promo_management_handler(callback, i18n_data, settings, session, page)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer("Error processing pagination.", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_detail:"))
|
||||
async def promo_detail_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""Show detailed promo code information with management options"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
async def promo_detail_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
|
||||
if not promo:
|
||||
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True)
|
||||
return
|
||||
|
||||
# Format promo details
|
||||
status_text = _("admin_promo_active", default="✅ Активен") if promo.is_active else _("admin_promo_inactive", default="❌ Неактивен")
|
||||
validity_text = _("admin_promo_unlimited", default="Без ограничений")
|
||||
if promo.valid_until:
|
||||
if promo.valid_until > datetime.now(timezone.utc):
|
||||
validity_text = promo.valid_until.strftime("%d.%m.%Y %H:%M")
|
||||
else:
|
||||
validity_text = f"{promo.valid_until.strftime('%d.%m.%Y %H:%M')} (истек)"
|
||||
|
||||
created_at = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
|
||||
|
||||
text = _(
|
||||
"admin_promo_detail_info",
|
||||
default="🎟 <b>Детали промокода</b>\n\n"
|
||||
"📝 Код: <code>{code}</code>\n"
|
||||
"🎁 Бонусные дни: <b>{bonus_days}</b>\n"
|
||||
"📊 Использований: <b>{current}/{max}</b>\n"
|
||||
"📋 Статус: {status}\n"
|
||||
"⏰ Действует до: <b>{validity}</b>\n"
|
||||
"👤 Создал: <code>{admin_id}</code>\n"
|
||||
"🕐 Создан: <b>{created}</b>",
|
||||
code=promo.code,
|
||||
bonus_days=promo.bonus_days,
|
||||
current=promo.current_activations,
|
||||
max=promo.max_activations,
|
||||
status=status_text,
|
||||
validity=validity_text,
|
||||
admin_id=promo.created_by_admin_id,
|
||||
created=created_at
|
||||
)
|
||||
|
||||
# Create management buttons
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Edit button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_edit", default="✏️ Редактировать"),
|
||||
callback_data=f"promo_edit:{promo_id}"
|
||||
)
|
||||
)
|
||||
|
||||
# Toggle active/inactive
|
||||
toggle_text = _("admin_promo_deactivate", default="❌ Деактивировать") if promo.is_active else _("admin_promo_activate", default="✅ Активировать")
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=toggle_text,
|
||||
callback_data=f"promo_toggle:{promo_id}"
|
||||
)
|
||||
)
|
||||
|
||||
# View activations
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_view_activations", default="📋 Активации"),
|
||||
callback_data=f"promo_activations:{promo_id}"
|
||||
)
|
||||
)
|
||||
|
||||
# Delete button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_delete", default="🗑 Удалить"),
|
||||
callback_data=f"promo_delete:{promo_id}"
|
||||
)
|
||||
)
|
||||
|
||||
# Back button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_management", default="🔙 К управлению"),
|
||||
callback_data="admin_action:promo_management"
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except ValueError:
|
||||
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Error in promo detail: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
if text:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(i18n.gettext(current_lang, "admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_toggle:"))
|
||||
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Toggle promo code active status"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
async def promo_toggle_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Language service error.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
|
||||
if not promo:
|
||||
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True)
|
||||
return
|
||||
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
new_status = not promo.is_active
|
||||
update_data = {"is_active": new_status}
|
||||
|
||||
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if updated:
|
||||
if await promo_code_dal.update_promo_code(session, promo_id, {"is_active": new_status}):
|
||||
await session.commit()
|
||||
status_text = _("admin_promo_activated", default="активирован") if new_status else _("admin_promo_deactivated", default="деактивирован")
|
||||
await callback.answer(
|
||||
_("admin_promo_status_changed", default="✅ Промокод {status}", status=status_text)
|
||||
)
|
||||
# Refresh the detail view
|
||||
callback.data = f"promo_detail:{promo_id}"
|
||||
await promo_detail_handler(callback, i18n_data, settings, session)
|
||||
status_text = _("admin_promo_status_activated") if new_status else _("admin_promo_status_deactivated")
|
||||
await callback.answer(_("admin_promo_toggle_success", code=promo.code, status=status_text))
|
||||
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
if text:
|
||||
await callback.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
|
||||
except ValueError:
|
||||
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Error toggling promo: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_activations:"))
|
||||
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Show promo code activations"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
async def promo_activations_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing request.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":")
|
||||
promo_id = int(parts[1])
|
||||
page = int(parts[2])
|
||||
page_size = settings.LOGS_PAGE_SIZE
|
||||
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
total_activations = await promo_code_dal.count_promo_activations_by_code_id(session, promo_id)
|
||||
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id, limit=page_size, offset=page * page_size)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
if not activations:
|
||||
text = _("admin_promo_no_activations", code=promo.code)
|
||||
else:
|
||||
text = _("admin_promo_activations_header", code=promo.code) + "\n\n"
|
||||
text += "\n".join([_("admin_promo_activation_item", user_id=a.user_id, date=a.activated_at.strftime("%d.%m.%Y %H:%M")) for a in activations])
|
||||
|
||||
nav_buttons = []
|
||||
if page > 0:
|
||||
nav_buttons.append(InlineKeyboardButton(text="⬅️", callback_data=f"promo_activations:{promo_id}:{page-1}"))
|
||||
if (page + 1) * page_size < total_activations:
|
||||
nav_buttons.append(InlineKeyboardButton(text="➡️", callback_data=f"promo_activations:{promo_id}:{page+1}"))
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_export_csv_button"), callback_data=f"promo_export:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_export:"))
|
||||
async def promo_export_activations_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
|
||||
if not promo:
|
||||
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True)
|
||||
return
|
||||
|
||||
return await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
activations = await promo_code_dal.get_promo_activations_by_code_id(session, promo_id)
|
||||
|
||||
text_lines = [
|
||||
_("admin_promo_activations_title",
|
||||
default="📋 <b>Активации промокода {code}</b>\n",
|
||||
code=promo.code)
|
||||
]
|
||||
|
||||
if not activations:
|
||||
text_lines.append(_("admin_no_activations", default="❌ Нет активаций"))
|
||||
else:
|
||||
for activation in activations[:10]: # Show last 10
|
||||
activated_at = activation.activated_at.strftime("%d.%m.%Y %H:%M") if activation.activated_at else "N/A"
|
||||
text_lines.append(
|
||||
f"👤 <code>{activation.user_id}</code> | 🕐 {activated_at}"
|
||||
)
|
||||
return await callback.answer(_("admin_promo_no_activations", code=promo.code), show_alert=True)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["User ID", "Activation Date"])
|
||||
for act in activations:
|
||||
writer.writerow([act.user_id, act.activated_at.strftime("%Y-%m-%d %H:%M:%S")])
|
||||
|
||||
output.seek(0)
|
||||
file = types.BufferedInputFile(output.getvalue().encode('utf-8'), filename=f"promo_{promo.code}_activations.csv")
|
||||
await callback.message.answer_document(file, caption=_("admin_promo_export_caption", code=promo.code))
|
||||
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "promo_export_all")
|
||||
async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Error processing request.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
await callback.answer("📄 Создаю CSV файл...", show_alert=True)
|
||||
|
||||
# Получаем все промокоды
|
||||
all_promos = await promo_code_dal.get_all_promo_codes_with_details(session, limit=10000, offset=0)
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Заголовки CSV
|
||||
writer.writerow([
|
||||
"Код", "Бонусные дни", "Максимальные активации", "Текущие активации",
|
||||
"Статус", "Активен", "Действителен до", "Создан", "Создал (Admin ID)"
|
||||
])
|
||||
|
||||
for promo in all_promos:
|
||||
# Определяем статус
|
||||
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, current_lang)
|
||||
|
||||
if len(activations) > 10:
|
||||
text_lines.append(f"\n... и еще {len(activations) - 10}")
|
||||
# Формируем данные для CSV
|
||||
row = [
|
||||
promo.code,
|
||||
promo.bonus_days,
|
||||
promo.max_activations,
|
||||
promo.current_activations,
|
||||
status_text,
|
||||
"Да" if promo.is_active else "Нет",
|
||||
promo.valid_until.strftime("%Y-%m-%d %H:%M:%S") if promo.valid_until else "Без ограничений",
|
||||
promo.created_at.strftime("%Y-%m-%d %H:%M:%S") if promo.created_at else "N/A",
|
||||
promo.created_by_admin_id or "N/A"
|
||||
]
|
||||
writer.writerow(row)
|
||||
|
||||
text = "\n".join(text_lines)
|
||||
output.seek(0)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_promo", default="🔙 К промокоду"),
|
||||
callback_data=f"promo_detail:{promo_id}"
|
||||
)
|
||||
# Создаем файл для отправки
|
||||
filename = f"promo_codes_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
file = types.BufferedInputFile(
|
||||
output.getvalue().encode('utf-8-sig'), # BOM для корректного отображения в Excel
|
||||
filename=filename
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(
|
||||
text,
|
||||
reply_markup=builder.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
caption = f"📄 Экспорт всех промокодов\n📊 Всего: {len(all_promos)} промокодов"
|
||||
await callback.message.answer_document(file, caption=caption)
|
||||
|
||||
except ValueError:
|
||||
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Error viewing activations: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
await callback.answer(f"❌ Ошибка экспорта: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_delete:"))
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Delete promo code"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict, session: AsyncSession):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error.", show_alert=True)
|
||||
return
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return await callback.answer("Language service error.", show_alert=True)
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
|
||||
if promo:
|
||||
await session.commit()
|
||||
await callback.answer(
|
||||
_("admin_promo_deleted", default="✅ Промокод удален", code=promo.code)
|
||||
)
|
||||
# Go back to management
|
||||
callback.data = "admin_action:promo_management"
|
||||
await promo_management_handler(callback, i18n_data, settings, session)
|
||||
await callback.answer(_("admin_promo_deleted_success", code=promo.code), show_alert=True)
|
||||
await promo_management_handler(callback, i18n_data, get_settings(), session, 0)
|
||||
else:
|
||||
await callback.answer(_("admin_promo_not_found", default="❌ Промокод не найден"), show_alert=True)
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
except (ValueError, IndexError):
|
||||
await callback.answer(_("admin_promo_not_found"), show_alert=True)
|
||||
|
||||
|
||||
# --- Promo Edit Handlers ---
|
||||
@router.callback_query(F.data.startswith("promo_edit_select:"))
|
||||
async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: dict):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang:
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
|
||||
|
||||
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_edit_field:"))
|
||||
async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMContext, i18n_data: dict):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not callback.message or not current_lang: return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
_, field, promo_id_str = callback.data.split(":")
|
||||
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
|
||||
|
||||
prompts = {
|
||||
"bonus_days": "admin_promo_prompt_bonus_days",
|
||||
"max_activations": "admin_promo_prompt_max_activations",
|
||||
"valid_until": "admin_promo_prompt_validity_days"
|
||||
}
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again")))
|
||||
await callback.answer()
|
||||
|
||||
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
|
||||
async def process_promo_edit_details(message: types.Message, state: FSMContext, session: AsyncSession, i18n_data: dict):
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
current_lang = i18n_data.get("current_language")
|
||||
if not i18n or not message or not current_lang: return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
promo_id = data.get("promo_id")
|
||||
field = data.get("field_to_edit")
|
||||
|
||||
try:
|
||||
value = message.text
|
||||
update_data = {}
|
||||
|
||||
if field == "bonus_days":
|
||||
update_data["bonus_days"] = int(value)
|
||||
elif field == "max_activations":
|
||||
update_data["max_activations"] = int(value)
|
||||
elif field == "valid_until":
|
||||
if value.lower() in ['0', 'вечно', 'бессрочно', 'indefinite']:
|
||||
update_data["valid_until"] = None
|
||||
else:
|
||||
days = int(value)
|
||||
update_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
|
||||
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
|
||||
await session.commit()
|
||||
await message.answer(_("admin_promo_edit_success"))
|
||||
|
||||
except ValueError:
|
||||
await callback.answer(_("admin_invalid_promo_id", default="❌ Неверный ID промокода"), show_alert=True)
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting promo: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
# Reset state and show updated details
|
||||
await state.clear()
|
||||
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
|
||||
if text:
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
else:
|
||||
await message.answer(_("error_occurred_try_again"))
|
||||
await state.clear()
|
||||
|
||||
except (ValueError, TypeError):
|
||||
await message.answer(_("admin_promo_invalid_input"))
|
||||
# Don't clear state, let them try again
|
||||
|
||||
|
||||
# Legacy handlers that redirect to new system
|
||||
async def manage_promo_codes_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
"""Redirect to new unified handler"""
|
||||
await promo_management_handler(callback, i18n_data, settings, session)
|
||||
async def manage_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
|
||||
await promo_management_handler(callback, i18n_data, settings, session)
|
||||
|
||||
@@ -197,9 +197,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
'%Y-%m-%d %H:%M:%S UTC') if sync_time_val else "N/A"
|
||||
|
||||
details_val = sync_status_model.details
|
||||
details_str = (details_val[:100] +
|
||||
"...") if details_val and len(details_val) > 100 else (
|
||||
details_val or "N/A")
|
||||
details_str = details_val or "N/A"
|
||||
|
||||
stats_text_parts.append(
|
||||
f" {_('admin_stats_sync_time')}: {sync_time_str}")
|
||||
|
||||
+266
-261
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.notification_service import notify_admin_panel_sync
|
||||
|
||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||
|
||||
@@ -15,6 +16,222 @@ from bot.middlewares.i18n import JsonI18n
|
||||
router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
||||
"""
|
||||
Perform panel synchronization and return results
|
||||
Returns dict with status, details, and sync statistics
|
||||
"""
|
||||
panel_records_checked = 0
|
||||
users_found_in_db = 0
|
||||
users_updated = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
# Additional counters for detailed logging
|
||||
users_without_telegram_id = 0
|
||||
users_not_found_in_db = 0
|
||||
users_uuid_updated = 0
|
||||
subscriptions_created = 0
|
||||
subscriptions_updated = 0
|
||||
|
||||
try:
|
||||
panel_users_data = await panel_service.get_all_panel_users()
|
||||
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
return {"status": "failed", "details": error_msg, "errors": sync_errors}
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
try:
|
||||
panel_records_checked += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||
|
||||
if not panel_uuid:
|
||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
||||
continue
|
||||
|
||||
# Track users without telegram ID
|
||||
if not telegram_id_from_panel:
|
||||
users_without_telegram_id += 1
|
||||
|
||||
# Try to find existing user in local DB
|
||||
existing_user = None
|
||||
|
||||
# First, try to find by telegram ID if available
|
||||
if telegram_id_from_panel:
|
||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
if existing_user:
|
||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
||||
|
||||
# If not found by telegram ID, try to find by panel UUID
|
||||
if not existing_user:
|
||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
||||
if existing_user:
|
||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
||||
# Update telegram ID if it was missing in panel data but we have local user
|
||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
||||
|
||||
if not existing_user:
|
||||
users_not_found_in_db += 1
|
||||
if telegram_id_from_panel:
|
||||
logging.debug(f"Panel user with telegramId {telegram_id_from_panel} and UUID {panel_uuid} not found in local DB")
|
||||
else:
|
||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB")
|
||||
continue
|
||||
|
||||
# User found in local DB
|
||||
users_found_in_db += 1
|
||||
user_was_updated = False
|
||||
|
||||
# Get the actual user_id for subscription operations
|
||||
actual_user_id = existing_user.user_id
|
||||
|
||||
# Update panel UUID if different
|
||||
if existing_user.panel_user_uuid != panel_uuid:
|
||||
existing_user.panel_user_uuid = panel_uuid
|
||||
user_was_updated = True
|
||||
users_uuid_updated += 1
|
||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
||||
|
||||
# Sync subscription data
|
||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||
|
||||
if panel_expire_at_iso:
|
||||
try:
|
||||
panel_expire_at = datetime.fromisoformat(
|
||||
panel_expire_at_iso.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
# Update or create subscription
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, actual_user_id, panel_uuid
|
||||
)
|
||||
|
||||
if active_sub:
|
||||
# Check if subscription needs update
|
||||
if (active_sub.end_date != panel_expire_at or
|
||||
active_sub.status_from_panel != panel_status or
|
||||
active_sub.is_active != (panel_status == "ACTIVE")):
|
||||
|
||||
await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, panel_expire_at
|
||||
)
|
||||
# Update status fields
|
||||
active_sub.status_from_panel = panel_status
|
||||
active_sub.is_active = (panel_status == "ACTIVE")
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_updated += 1
|
||||
user_was_updated = True
|
||||
logging.info(f"Updated subscription for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}")
|
||||
else:
|
||||
# Create new subscription record
|
||||
subscription_uuid_to_use = panel_subscription_uuid or panel_uuid
|
||||
|
||||
logging.info(f"Creating new subscription for user {actual_user_id} with UUID {subscription_uuid_to_use}")
|
||||
|
||||
sub_payload = {
|
||||
"user_id": actual_user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": subscription_uuid_to_use,
|
||||
"start_date": datetime.now(timezone.utc),
|
||||
"end_date": panel_expire_at,
|
||||
"duration_months": 1, # Default
|
||||
"is_active": panel_status == "ACTIVE",
|
||||
"status_from_panel": panel_status,
|
||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||
}
|
||||
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
subscriptions_created += 1
|
||||
user_was_updated = True
|
||||
|
||||
except Exception as e:
|
||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
||||
|
||||
if user_was_updated:
|
||||
users_updated += 1
|
||||
|
||||
except Exception as e_user:
|
||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
||||
logging.error(f"Error syncing user: {e_user}")
|
||||
|
||||
# Update sync status
|
||||
status = "completed_with_errors" if sync_errors else "completed"
|
||||
details = (f"📊 Статистика синхронизации:\n"
|
||||
f"🔍 Проверено записей панели: {panel_records_checked}\n"
|
||||
f"👥 Найдено пользователей в БД: {users_found_in_db}\n"
|
||||
f"🔄 Пользователей обновлено: {users_updated}\n"
|
||||
f"📋 Подписок синхронизировано: {subscriptions_synced_count}\n"
|
||||
f" ├── Создано новых: {subscriptions_created}\n"
|
||||
f" └── Обновлено существующих: {subscriptions_updated}")
|
||||
|
||||
if users_without_telegram_id > 0:
|
||||
details += f"\n⚠️ Записей без telegramId: {users_without_telegram_id}"
|
||||
if users_not_found_in_db > 0:
|
||||
details += f"\n❌ Не найдено в БД: {users_not_found_in_db}"
|
||||
if sync_errors:
|
||||
details += f"\n🚫 Ошибок: {len(sync_errors)}"
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Detailed logging summary
|
||||
logging.info(f"Sync completed - Summary:")
|
||||
logging.info(f" Panel records checked: {panel_records_checked}")
|
||||
logging.info(f" Users without telegramId: {users_without_telegram_id}")
|
||||
logging.info(f" Users not found in local DB: {users_not_found_in_db}")
|
||||
logging.info(f" Users found in local DB: {users_found_in_db}")
|
||||
logging.info(f" Users with UUID updated: {users_uuid_updated}")
|
||||
logging.info(f" Users updated overall: {users_updated}")
|
||||
logging.info(f" Subscriptions total synced: {subscriptions_synced_count}")
|
||||
logging.info(f" Subscriptions created: {subscriptions_created}")
|
||||
logging.info(f" Subscriptions updated: {subscriptions_updated}")
|
||||
logging.info(f" Sync errors: {len(sync_errors)}")
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"details": details,
|
||||
"users_processed": panel_records_checked,
|
||||
"users_synced": users_found_in_db,
|
||||
"subs_synced": subscriptions_synced_count,
|
||||
"errors": sync_errors
|
||||
}
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
||||
)
|
||||
|
||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def sync_command_handler(
|
||||
message_event: Union[types.Message, types.CallbackQuery],
|
||||
@@ -52,265 +269,57 @@ async def sync_command_handler(
|
||||
|
||||
logging.info(f"Admin ({message_event.from_user.id}) triggered panel sync.")
|
||||
|
||||
users_processed_count = 0
|
||||
users_synced_successfully = 0
|
||||
subscriptions_synced_count = 0
|
||||
sync_errors = []
|
||||
|
||||
# Use the extracted perform_sync function
|
||||
try:
|
||||
panel_users_data = await panel_service.get_all_panel_users()
|
||||
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
|
||||
return
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0
|
||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||
|
||||
status = sync_result.get("status")
|
||||
details = sync_result.get("details", "No details available")
|
||||
errors = sync_result.get("errors", [])
|
||||
|
||||
if status == "failed":
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=details))
|
||||
elif status == "completed_with_errors":
|
||||
error_preview = "; ".join(errors[:3]) # Show first 3 errors
|
||||
final_message = _(
|
||||
"sync_completed_with_errors_details",
|
||||
total_checked=sync_result.get("users_processed", 0),
|
||||
users_synced=sync_result.get("users_synced", 0),
|
||||
subs_synced=sync_result.get("subs_synced", 0),
|
||||
errors_count=len(errors),
|
||||
error_details_preview=error_preview
|
||||
)
|
||||
await session.commit()
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_completed", status="Success", details=status_msg),
|
||||
)
|
||||
return
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
users_processed_count += 1
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
|
||||
panel_username = panel_user_dict.get("username")
|
||||
|
||||
if not panel_uuid:
|
||||
logging.warning(
|
||||
f"Sync: Panel user data missing 'uuid'. Data: {str(panel_user_dict)[:200]}. Skipping."
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Panel user data (username: {panel_username or 'N/A'}) missing UUID."
|
||||
)
|
||||
continue
|
||||
|
||||
telegram_id_from_panel: Optional[int] = None
|
||||
if telegram_id_from_panel_str:
|
||||
try:
|
||||
telegram_id_from_panel = int(telegram_id_from_panel_str)
|
||||
except ValueError:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has invalid 'telegramId': {telegram_id_from_panel_str}. Skipping TG ID based sync."
|
||||
)
|
||||
|
||||
if not telegram_id_from_panel:
|
||||
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (username: {panel_username}) has no valid 'telegramId'. Skipping full sync for this user."
|
||||
)
|
||||
|
||||
continue
|
||||
|
||||
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
if not bot_user:
|
||||
user_data_to_create = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"username": panel_username,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"language_code": settings.DEFAULT_LANGUAGE,
|
||||
"registration_date": (
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict["createdAt"].replace("Z", "+00:00")
|
||||
)
|
||||
if panel_user_dict.get("createdAt")
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
}
|
||||
bot_user = await user_dal.create_user(session, user_data_to_create)
|
||||
logging.info(
|
||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
||||
)
|
||||
else:
|
||||
if bot_user.panel_user_uuid != panel_uuid:
|
||||
if bot_user.panel_user_uuid is not None:
|
||||
logging.warning(
|
||||
f"Sync: Local user {telegram_id_from_panel} was linked to {bot_user.panel_user_uuid}, panel now gives {panel_uuid}. Updating."
|
||||
)
|
||||
|
||||
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid
|
||||
)
|
||||
if (
|
||||
conflicting_user
|
||||
and conflicting_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
sync_errors.append(
|
||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
||||
)
|
||||
logging.error(sync_errors[-1])
|
||||
continue
|
||||
|
||||
await user_dal.update_user(
|
||||
session,
|
||||
telegram_id_from_panel,
|
||||
{"panel_user_uuid": panel_uuid, "username": panel_username},
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_dict.get(
|
||||
"subscriptionUuid"
|
||||
) or panel_user_dict.get("shortUuid")
|
||||
if panel_sub_link_id:
|
||||
end_date_str = panel_user_dict.get("expireAt")
|
||||
start_date_str = panel_user_dict.get("createdAt")
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
start_date_obj = (
|
||||
datetime.fromisoformat(
|
||||
start_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
if start_date_str
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
status_from_panel = panel_user_dict.get(
|
||||
"status", "UNKNOWN"
|
||||
).upper()
|
||||
is_active_flag = (
|
||||
1
|
||||
if status_from_panel == "ACTIVE"
|
||||
and end_date_obj > datetime.now(timezone.utc)
|
||||
else 0
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": telegram_id_from_panel,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date_obj,
|
||||
"end_date": end_date_obj,
|
||||
"is_active": is_active_flag,
|
||||
"status_from_panel": status_from_panel,
|
||||
"traffic_limit_bytes": panel_user_dict.get(
|
||||
"trafficLimitBytes"
|
||||
),
|
||||
"traffic_used_bytes": panel_user_dict.get(
|
||||
"usedTrafficBytes"
|
||||
),
|
||||
}
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_link_id
|
||||
)
|
||||
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
users_synced_successfully += 1
|
||||
except ValueError as e_date:
|
||||
logging.warning(
|
||||
f"Sync: Bad date format for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}). Sub data: {str(panel_user_dict)[:100]}. Error: {e_date}"
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Bad date for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
except Exception as e_sub_sync:
|
||||
logging.error(
|
||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
||||
exc_info=True,
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has sub link but no expireAt date. Skipping subscription sync."
|
||||
)
|
||||
else:
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
|
||||
)
|
||||
users_synced_successfully += 1
|
||||
|
||||
if users_processed_count % 20 == 0:
|
||||
logging.info(
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
|
||||
)
|
||||
|
||||
panel_uuid_set = {u.get("uuid") for u in panel_users_data if u.get("uuid")}
|
||||
local_users_with_uuid = await user_dal.get_all_users_with_panel_uuid(session)
|
||||
for local_user in local_users_with_uuid:
|
||||
if local_user.panel_user_uuid not in panel_uuid_set:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, local_user.panel_user_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Local user {local_user.user_id} with panel UUID {local_user.panel_user_uuid} not found on panel. Deactivated local subs."
|
||||
)
|
||||
|
||||
status_msg_key = "sync_completed_details"
|
||||
final_status_type = "success"
|
||||
|
||||
if sync_errors:
|
||||
final_status_type = "partial_success"
|
||||
status_msg_key = "sync_completed_with_errors_details"
|
||||
error_preview = "\n".join(sync_errors[:3])
|
||||
details_for_db = f"Users processed: {users_processed_count}. Subs synced: {subscriptions_synced_count}. Errors: {len(sync_errors)}. First few: {error_preview}"
|
||||
await bot.send_message(target_chat_id, final_message)
|
||||
else:
|
||||
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session,
|
||||
final_status_type,
|
||||
details_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
final_user_message = _(
|
||||
status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=(
|
||||
error_preview if sync_errors else _("no_errors_placeholder")
|
||||
),
|
||||
)
|
||||
await bot.send_message(target_chat_id, final_user_message)
|
||||
|
||||
final_message = _(
|
||||
"sync_completed_details",
|
||||
total_checked=sync_result.get("users_processed", 0),
|
||||
users_synced=sync_result.get("users_synced", 0),
|
||||
subs_synced=sync_result.get("subs_synced", 0)
|
||||
)
|
||||
await bot.send_message(target_chat_id, _("sync_completed", status="Success", details=final_message))
|
||||
|
||||
# Send notification to log channel with proper thread handling
|
||||
try:
|
||||
await notify_admin_panel_sync(
|
||||
bot, settings, i18n, status, details,
|
||||
sync_result.get("users_processed", 0),
|
||||
sync_result.get("subs_synced", 0)
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
error_detail_for_db = (
|
||||
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session,
|
||||
"failed",
|
||||
error_detail_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
target_chat_id, _("sync_failed", details=error_detail_for_db)
|
||||
)
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=str(e_sync_global)))
|
||||
|
||||
# Send notification to log channel about failure
|
||||
try:
|
||||
await notify_admin_panel_sync(
|
||||
bot, settings, i18n, "failed", str(e_sync_global), 0, 0
|
||||
)
|
||||
except Exception as e_notification:
|
||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
@@ -333,11 +342,7 @@ async def sync_status_command_handler(
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
details_str = (
|
||||
(details_val[:200] + "...")
|
||||
if details_val and len(details_val) > 200
|
||||
else (details_val or "N/A")
|
||||
)
|
||||
details_str = details_val or "N/A"
|
||||
|
||||
response_text = (
|
||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
@@ -350,4 +355,4 @@ async def sync_status_command_handler(
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
await message.answer(response_text, parse_mode="HTML")
|
||||
@@ -216,13 +216,15 @@ async def start_command_handler(message: types.Message,
|
||||
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||
exc_info=True)
|
||||
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
promo_code_service = PromoCodeService()
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
|
||||
@@ -105,10 +105,12 @@ def get_system_functions_keyboard(i18n_instance, lang: str) -> InlineKeyboardMar
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_sync_panel_button"),
|
||||
callback_data="admin_action:sync_panel")
|
||||
builder.button(text=_(key="admin_queue_status_button"),
|
||||
callback_data="admin_action:queue_status")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
from bot.handlers.admin.sync_admin import perform_sync
|
||||
from bot.utils.message_queue import init_queue_manager
|
||||
|
||||
|
||||
class DBSessionMiddleware(BaseMiddleware):
|
||||
@@ -191,6 +193,34 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
|
||||
|
||||
# Initialize message queue manager
|
||||
try:
|
||||
queue_manager = init_queue_manager(bot)
|
||||
dispatcher["queue_manager"] = queue_manager
|
||||
logging.info("STARTUP: Message queue manager initialized")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to initialize message queue manager: {e}", exc_info=True)
|
||||
|
||||
# Automatic sync on startup
|
||||
try:
|
||||
logging.info("STARTUP: Running automatic panel sync...")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
sync_result = await perform_sync(
|
||||
panel_service=panel_service,
|
||||
session=session,
|
||||
settings=settings,
|
||||
i18n_instance=i18n_instance
|
||||
)
|
||||
|
||||
if sync_result.get("status") == "completed":
|
||||
logging.info(f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}")
|
||||
else:
|
||||
logging.warning(f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to run automatic sync: {e}", exc_info=True)
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.exceptions import TelegramRetryAfter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Union, Dict, Any
|
||||
|
||||
from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -19,16 +21,30 @@ class NotificationService:
|
||||
self.i18n = i18n
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
"""Send message to configured log channel/group"""
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
if not self.settings.LOG_CHAT_ID:
|
||||
return
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=self.settings.LOG_CHAT_ID,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Use thread_id if provided, otherwise use from settings
|
||||
final_thread_id = thread_id or self.settings.LOG_THREAD_ID
|
||||
|
||||
kwargs = {
|
||||
"chat_id": self.settings.LOG_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True
|
||||
@@ -38,26 +54,42 @@ class NotificationService:
|
||||
if final_thread_id:
|
||||
kwargs["message_thread_id"] = final_thread_id
|
||||
|
||||
await self.bot.send_message(**kwargs)
|
||||
# Queue message for sending (groups are rate limited to 15/minute)
|
||||
await queue_manager.send_message(self.settings.LOG_CHAT_ID, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
logging.error(f"Failed to queue notification to log channel {self.settings.LOG_CHAT_ID}: {e}")
|
||||
|
||||
async def _send_to_admins(self, message: str):
|
||||
"""Send message to all admin users"""
|
||||
"""Send message to all admin users using message queue"""
|
||||
if not self.settings.ADMIN_IDS:
|
||||
return
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
if not queue_manager:
|
||||
logging.warning("Message queue manager not available, falling back to direct send")
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
return
|
||||
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
await queue_manager.send_message(
|
||||
chat_id=admin_id,
|
||||
text=message,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to admin {admin_id}: {e}")
|
||||
logging.error(f"Failed to queue notification to admin {admin_id}: {e}")
|
||||
|
||||
async def notify_new_user_registration(self, user_id: int, username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
@@ -189,6 +221,42 @@ class NotificationService:
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_panel_sync(self, status: str, details: str,
|
||||
users_processed: int, subs_synced: int,
|
||||
username: Optional[str] = None):
|
||||
"""Send notification about panel synchronization"""
|
||||
if not getattr(self.settings, 'LOG_PANEL_SYNC', True):
|
||||
return
|
||||
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
# Status emoji based on sync result
|
||||
status_emoji = {
|
||||
"completed": "✅",
|
||||
"completed_with_errors": "⚠️",
|
||||
"failed": "❌"
|
||||
}.get(status, "🔄")
|
||||
|
||||
message = _(
|
||||
"log_panel_sync",
|
||||
default="{status_emoji} <b>Синхронизация с панелью</b>\n\n"
|
||||
"📊 Статус: <b>{status}</b>\n"
|
||||
"👥 Обработано пользователей: <b>{users_processed}</b>\n"
|
||||
"📋 Синхронизировано подписок: <b>{subs_synced}</b>\n"
|
||||
"🕐 Время: {timestamp}\n\n"
|
||||
"📝 Детали:\n{details}",
|
||||
status_emoji=status_emoji,
|
||||
status=status,
|
||||
users_processed=users_processed,
|
||||
subs_synced=subs_synced,
|
||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"),
|
||||
details=details
|
||||
)
|
||||
|
||||
# Send to log channel
|
||||
await self._send_to_log_channel(message)
|
||||
|
||||
async def notify_suspicious_promo_attempt(
|
||||
self, user_id: int, suspicious_input: str,
|
||||
username: Optional[str] = None, first_name: Optional[str] = None):
|
||||
@@ -265,4 +333,13 @@ async def notify_admin_promo_activation(bot: Bot, settings: Settings,
|
||||
user_id=user_id,
|
||||
code=code,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def notify_admin_panel_sync(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n, status: str,
|
||||
details: str, users_processed: int,
|
||||
subs_synced: int) -> None:
|
||||
"""Send notification to admins about panel sync (legacy)"""
|
||||
notification_service = NotificationService(bot, settings, i18n)
|
||||
await notification_service.notify_panel_sync(status, details, users_processed, subs_synced)
|
||||
@@ -55,7 +55,6 @@ class PromoCodeService:
|
||||
reason=f"promo code {code_input_upper}")
|
||||
|
||||
if new_end_date:
|
||||
|
||||
activation_recorded = await promo_code_dal.record_promo_activation(
|
||||
session, promo_data.promo_code_id, user_id, payment_id=None)
|
||||
promo_incremented = await promo_code_dal.increment_promo_code_usage(
|
||||
@@ -83,5 +82,4 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("error_applying_promo_bonus")
|
||||
else:
|
||||
|
||||
return False, _("error_applying_promo_bonus")
|
||||
|
||||
@@ -563,6 +563,10 @@ class SubscriptionService:
|
||||
)
|
||||
start_date = datetime.now(timezone.utc)
|
||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||
|
||||
# For promo code activations, use the configured user traffic limit
|
||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
||||
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
@@ -572,7 +576,7 @@ class SubscriptionService:
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -593,14 +597,23 @@ class SubscriptionService:
|
||||
)
|
||||
|
||||
if updated_sub_model:
|
||||
# Prepare panel update payload
|
||||
panel_update_payload = {
|
||||
"expireAt": new_end_date_obj.isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
}
|
||||
|
||||
# For promo code activations, remove traffic limit
|
||||
if "promo code" in reason.lower():
|
||||
panel_update_payload["trafficLimitBytes"] = self.settings.user_traffic_limit_bytes
|
||||
panel_update_payload["trafficLimitStrategy"] = self.settings.USER_TRAFFIC_STRATEGY
|
||||
logging.info(f"Updating traffic limit for user {user_id} to {self.settings.user_traffic_limit_bytes} bytes due to promo code activation")
|
||||
|
||||
panel_update_success = (
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"expireAt": new_end_date_obj.isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
},
|
||||
panel_update_payload,
|
||||
)
|
||||
)
|
||||
if not panel_update_success:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Bot utilities package
|
||||
@@ -0,0 +1,183 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, Any, Callable, Awaitable, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from aiogram import Bot
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedMessage:
|
||||
"""Represents a queued message with all necessary parameters"""
|
||||
chat_id: int
|
||||
method_name: str # 'send_message', 'edit_message_text', etc.
|
||||
kwargs: Dict[str, Any]
|
||||
callback: Optional[Callable[[Any], Awaitable[None]]] = None # Optional callback for result
|
||||
|
||||
|
||||
class MessageQueue:
|
||||
"""Message queue with rate limiting for Telegram API"""
|
||||
|
||||
def __init__(self, messages_per_second: float, burst_size: int = 5):
|
||||
self.messages_per_second = messages_per_second
|
||||
self.burst_size = burst_size
|
||||
self.queue: deque[QueuedMessage] = deque()
|
||||
self.last_send_times: deque[datetime] = deque()
|
||||
self.is_processing = False
|
||||
self.delay_between_messages = 1.0 / messages_per_second
|
||||
|
||||
async def add_message(self, message: QueuedMessage) -> None:
|
||||
"""Add message to queue"""
|
||||
self.queue.append(message)
|
||||
if not self.is_processing:
|
||||
asyncio.create_task(self._process_queue())
|
||||
|
||||
async def _process_queue(self) -> None:
|
||||
"""Process messages from queue with rate limiting"""
|
||||
if self.is_processing:
|
||||
return
|
||||
|
||||
self.is_processing = True
|
||||
|
||||
try:
|
||||
while self.queue:
|
||||
# Check if we need to wait
|
||||
await self._wait_if_needed()
|
||||
|
||||
# Get and process next message
|
||||
message = self.queue.popleft()
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
while self.last_send_times and self.last_send_times[0] < cutoff_time:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
|
||||
finally:
|
||||
self.is_processing = False
|
||||
|
||||
async def _wait_if_needed(self) -> None:
|
||||
"""Wait if we need to respect rate limits"""
|
||||
if not self.last_send_times:
|
||||
return
|
||||
|
||||
# Calculate time since last message
|
||||
time_since_last = (datetime.now() - self.last_send_times[-1]).total_seconds()
|
||||
|
||||
if time_since_last < self.delay_between_messages:
|
||||
wait_time = self.delay_between_messages - time_since_last
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send a single message - to be implemented by subclass"""
|
||||
raise NotImplementedError("Subclass must implement _send_message")
|
||||
|
||||
|
||||
class TelegramMessageQueue(MessageQueue):
|
||||
"""Telegram-specific message queue"""
|
||||
|
||||
def __init__(self, bot: Bot, messages_per_second: float, burst_size: int = 5):
|
||||
super().__init__(messages_per_second, burst_size)
|
||||
self.bot = bot
|
||||
|
||||
async def _send_message(self, message: QueuedMessage) -> Any:
|
||||
"""Send message using bot method"""
|
||||
method = getattr(self.bot, message.method_name)
|
||||
result = await method(chat_id=message.chat_id, **message.kwargs)
|
||||
|
||||
# Call callback if provided
|
||||
if message.callback:
|
||||
await message.callback(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class MessageQueueManager:
|
||||
"""Manager for different types of message queues"""
|
||||
|
||||
def __init__(self, bot: Bot):
|
||||
self.bot = bot
|
||||
|
||||
# Different queues for different types of chats
|
||||
self.group_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=15/60, # 15 messages per minute for groups
|
||||
burst_size=3
|
||||
)
|
||||
|
||||
self.user_queue = TelegramMessageQueue(
|
||||
bot=bot,
|
||||
messages_per_second=25, # 25 messages per second for users
|
||||
burst_size=10
|
||||
)
|
||||
|
||||
def _is_group_chat(self, chat_id: int) -> bool:
|
||||
"""Check if chat_id belongs to a group or channel"""
|
||||
return str(chat_id).startswith('-100')
|
||||
|
||||
async def send_message(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_message call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_message',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def edit_message_text(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue an edit_message_text call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='edit_message_text',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def send_document(self, chat_id: int, **kwargs) -> None:
|
||||
"""Queue a send_document call"""
|
||||
queue = self.group_queue if self._is_group_chat(chat_id) else self.user_queue
|
||||
message = QueuedMessage(
|
||||
chat_id=chat_id,
|
||||
method_name='send_document',
|
||||
kwargs=kwargs
|
||||
)
|
||||
await queue.add_message(message)
|
||||
|
||||
async def answer_callback_query(self, callback_query_id: str, **kwargs) -> None:
|
||||
"""Send callback query answer immediately (not rate limited)"""
|
||||
await self.bot.answer_callback_query(callback_query_id, **kwargs)
|
||||
|
||||
def get_queue_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about queues"""
|
||||
return {
|
||||
"group_queue_size": len(self.group_queue.queue),
|
||||
"user_queue_size": len(self.user_queue.queue),
|
||||
"group_queue_processing": self.group_queue.is_processing,
|
||||
"user_queue_processing": self.user_queue.is_processing,
|
||||
"group_recent_sends": len(self.group_queue.last_send_times),
|
||||
"user_recent_sends": len(self.user_queue.last_send_times)
|
||||
}
|
||||
|
||||
|
||||
# Global queue manager instance
|
||||
_queue_manager: Optional[MessageQueueManager] = None
|
||||
|
||||
|
||||
def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
||||
"""Initialize global queue manager"""
|
||||
global _queue_manager
|
||||
_queue_manager = MessageQueueManager(bot)
|
||||
return _queue_manager
|
||||
|
||||
|
||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||
"""Get global queue manager instance"""
|
||||
return _queue_manager
|
||||
@@ -113,6 +113,7 @@ class Settings(BaseSettings):
|
||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||
|
||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||
|
||||
# Inline mode thumbnail URLs
|
||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||
|
||||
+22
-12
@@ -10,7 +10,6 @@ from db.models import PromoCode, PromoCodeActivation, User, Payment
|
||||
|
||||
async def create_promo_code(session: AsyncSession,
|
||||
promo_data: Dict[str, Any]) -> PromoCode:
|
||||
|
||||
new_promo = PromoCode(**promo_data)
|
||||
session.add(new_promo)
|
||||
await session.flush()
|
||||
@@ -65,24 +64,33 @@ async def get_all_promo_codes_with_details(session: AsyncSession, limit: int = 5
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_promo_code_by_id(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
"""Get promo code by ID"""
|
||||
stmt = select(PromoCode).where(PromoCode.promo_code_id == promo_id)
|
||||
async def get_promo_codes_count(session: AsyncSession) -> int:
|
||||
"""Get total count of all promo codes"""
|
||||
from sqlalchemy import func
|
||||
stmt = select(func.count(PromoCode.promo_code_id))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> List:
|
||||
"""Get activation history for a specific promo code"""
|
||||
from db.models import PromoCodeActivation
|
||||
async def get_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int, limit: Optional[int] = None, offset: int = 0) -> List[PromoCodeActivation]:
|
||||
"""Get activation history for a specific promo code with optional pagination."""
|
||||
stmt = (select(PromoCodeActivation)
|
||||
.where(PromoCodeActivation.promo_code_id == promo_code_id)
|
||||
.order_by(PromoCodeActivation.activated_at.desc())
|
||||
.limit(50)) # Limit to last 50 activations
|
||||
.offset(offset))
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_promo_activations_by_code_id(session: AsyncSession, promo_code_id: int) -> int:
|
||||
"""Count total activations for a specific promo code."""
|
||||
stmt = select(func.count()).select_from(PromoCodeActivation).where(PromoCodeActivation.promo_code_id == promo_code_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
@@ -99,6 +107,11 @@ async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[Pr
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
# First, delete related activations due to foreign key constraint
|
||||
activations = await get_promo_activations_by_code_id(session, promo_id)
|
||||
for activation in activations:
|
||||
await session.delete(activation)
|
||||
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
return promo
|
||||
@@ -124,7 +137,6 @@ async def increment_promo_code_usage(
|
||||
async def get_user_activation_for_promo(
|
||||
session: AsyncSession, promo_code_id: int,
|
||||
user_id: int) -> Optional[PromoCodeActivation]:
|
||||
|
||||
stmt = select(PromoCodeActivation).where(
|
||||
PromoCodeActivation.promo_code_id == promo_code_id,
|
||||
PromoCodeActivation.user_id == user_id).limit(1)
|
||||
@@ -137,7 +149,6 @@ async def record_promo_activation(
|
||||
promo_code_id: int,
|
||||
user_id: int,
|
||||
payment_id: Optional[int] = None) -> Optional[PromoCodeActivation]:
|
||||
|
||||
existing_activation = await get_user_activation_for_promo(
|
||||
session, promo_code_id, user_id)
|
||||
if existing_activation:
|
||||
@@ -162,7 +173,6 @@ async def record_promo_activation(
|
||||
logging.error(
|
||||
f"Cannot record promo activation: Payment {payment_id} not found."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
activation_data = {
|
||||
|
||||
+17
-3
@@ -131,7 +131,7 @@
|
||||
"admin_sync_status_never_run": "Panel sync never run.",
|
||||
|
||||
"admin_broadcast_enter_message": "Enter the broadcast message (HTML supported):",
|
||||
"admin_broadcast_confirm_prompt": "You are about to send the following message (first 200 characters):\n\n{message_preview}\n\nConfirm sending?",
|
||||
"admin_broadcast_confirm_prompt": "You are about to send the following message:\n\n{message_preview}\n\nConfirm sending?",
|
||||
"confirm_broadcast_send_button": "✅ Send",
|
||||
"cancel_broadcast_button": "❌ Cancel",
|
||||
"admin_broadcast_sending_started": "Starting broadcast...",
|
||||
@@ -145,6 +145,8 @@
|
||||
"admin_promo_create_prompt": "Enter promo details in the format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]\nExample: <code>{example_format}</code>\n(Validity is optional; default is indefinite)",
|
||||
"admin_promo_invalid_format": "Invalid format. Please use: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"admin_promo_invalid_code_format": "Code must be 3–30 alphanumeric characters.",
|
||||
"admin_promo_invalid_bonus_days": "Bonus days must be a positive number.",
|
||||
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
|
||||
"admin_promo_invalid_bonus_or_activations": "Bonus days and max uses must be positive numbers.",
|
||||
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
|
||||
"admin_promo_invalid_values": "Invalid values. {error}",
|
||||
@@ -161,9 +163,20 @@
|
||||
"admin_promo_edit_button": "✏️ Edit",
|
||||
"admin_promo_delete_button": "🗑 Delete",
|
||||
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"admin_promo_updated_success": "Promo <code>{code}</code> updated.",
|
||||
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.",
|
||||
"admin_promo_updated_success": "Promo {code} updated.",
|
||||
"admin_promo_deleted_success": "Promo {code} deleted.",
|
||||
"admin_promo_not_found": "Promo not found.",
|
||||
"admin_promo_export_csv_button": "📄 Export to CSV",
|
||||
"admin_promo_export_caption": "📄 Activations for promo code {code}",
|
||||
"admin_promo_edit_select_field": "Select a field to edit:",
|
||||
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
|
||||
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
|
||||
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
|
||||
"admin_promo_edit_success": "Promo code updated successfully.",
|
||||
"admin_promo_invalid_input": "Invalid input, please try again.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
|
||||
"admin_promo_edit_max_activations": "🔢 Max Activations",
|
||||
"admin_promo_edit_validity": "⏰ Validity",
|
||||
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
|
||||
@@ -303,6 +316,7 @@
|
||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
|
||||
|
||||
"admin_general_cancel_operation": "Operation cancelled ❌",
|
||||
|
||||
+27
-3
@@ -131,7 +131,7 @@
|
||||
"admin_sync_status_never_run": "Синхронизация с панелью еще не проводилась.",
|
||||
|
||||
"admin_broadcast_enter_message": "Введите сообщение для рассылки (HTML поддерживается):",
|
||||
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение (первые 200 символов):\n\n{message_preview}\n\nПодтверждаете отправку?",
|
||||
"admin_broadcast_confirm_prompt": "Вы собираетесь отправить следующее сообщение:\n\n{message_preview}\n\nПодтверждаете отправку?",
|
||||
"confirm_broadcast_send_button": "✅ Отправить",
|
||||
"cancel_broadcast_button": "❌ Отмена",
|
||||
"admin_broadcast_sending_started": "Начинаю рассылку...",
|
||||
@@ -145,11 +145,23 @@
|
||||
"admin_promo_create_prompt": "Введите детали промокода в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК_ДЕЙСТВИЯ_В_ДНЯХ_ОТ_СЕЙЧАС]\nПример: <code>{example_format}</code>\n(Срок действия необязателен, по умолчанию - бессрочный)",
|
||||
"admin_promo_invalid_format": "Неверный формат ввода. Пожалуйста, используйте: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [ДНИ_ДЕЙСТВИЯ]",
|
||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
"admin_promo_invalid_bonus_or_activations": "Количество бонусных дней и максимальных активаций должны быть положительными числами.",
|
||||
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
|
||||
"admin_promo_invalid_values": "Неверные значения. {error}",
|
||||
"admin_promo_invalid_format_general": "Ошибка парсинга деталей промокода. Проверьте формат.",
|
||||
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
|
||||
"admin_promo_set_validity_days": "⏰ Установить срок (дни)",
|
||||
"admin_back_to_panel": "⬅️ В панель",
|
||||
"admin_promo_unlimited": "♾️ Неограниченно",
|
||||
"admin_bulk_promo_created_title": "📦 Массовое создание завершено",
|
||||
"admin_bulk_promo_created_stats": "📊 Создано: <b>{created}</b> из <b>{total}</b>",
|
||||
"admin_bulk_promo_settings": "🎁 Бонусные дни: <b>{bonus_days}</b>\n📊 Макс. активаций: <b>{max_activations}</b>\n⏰ Срок действия: <b>{validity}</b>",
|
||||
"admin_promo_list_page_info": "Страница {current}/{total} ({count} промокодов)",
|
||||
"admin_queue_status_button": "📊 Статус очередей",
|
||||
"admin_queue_status_title": "📊 Статус очередей сообщений",
|
||||
"admin_queue_status_info": "📤 <b>Очереди сообщений:</b>\n\n👥 <b>Пользователи (25 сообщ/сек):</b>\n 📋 В очереди: {user_queue_size}\n 🔄 Обрабатывается: {user_processing}\n 📈 Отправлено за минуту: {user_recent}\n\n📢 <b>Группы/каналы (15 сообщ/мин):</b>\n 📋 В очереди: {group_queue_size}\n 🔄 Обрабатывается: {group_processing}\n 📈 Отправлено за минуту: {group_recent}",
|
||||
"admin_promo_creation_failed_duplicate": "❌ Ошибка: Промокод <code>{code}</code> уже существует.",
|
||||
"admin_promo_creation_failed": "❌ Не удалось создать промокод. Пожалуйста, попробуйте позже.",
|
||||
"admin_active_promos_list_header": "Активные промокоды:",
|
||||
@@ -161,9 +173,20 @@
|
||||
"admin_promo_edit_button": "✏️ Изменить",
|
||||
"admin_promo_delete_button": "🗑 Удалить",
|
||||
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
|
||||
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
|
||||
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.",
|
||||
"admin_promo_updated_success": "Промокод {code} обновлен.",
|
||||
"admin_promo_deleted_success": "Промокод {code} удален.",
|
||||
"admin_promo_not_found": "Промокод не найден.",
|
||||
"admin_promo_export_csv_button": "📄 Экспорт в CSV",
|
||||
"admin_promo_export_caption": "📄 Активации промокода {code}",
|
||||
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
|
||||
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
|
||||
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
|
||||
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
|
||||
"admin_promo_edit_success": "Промокод успешно обновлен.",
|
||||
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
|
||||
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
|
||||
"admin_promo_edit_max_activations": "🔢 Макс. активации",
|
||||
"admin_promo_edit_validity": "⏰ Срок действия",
|
||||
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||
@@ -302,6 +325,7 @@
|
||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
|
||||
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
|
||||
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
|
||||
|
||||
"admin_general_cancel_operation": "Операция отменена ❌",
|
||||
|
||||
Reference in New Issue
Block a user