Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c8099168d | ||
|
|
4675141974 | ||
|
|
67f31d62e4 | ||
|
|
c969d52ce1 | ||
|
|
3bd29511e1 |
@@ -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`
|
||||
|
||||
+250
-337
@@ -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
|
||||
@@ -17,10 +19,45 @@ 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"""
|
||||
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 = _("admin_promo_status_active") if promo.is_active else _("admin_promo_status_inactive")
|
||||
if promo.valid_until and promo.valid_until < datetime.now(timezone.utc):
|
||||
status = _("admin_promo_status_expired")
|
||||
elif promo.current_activations >= promo.max_activations:
|
||||
status = _("admin_promo_status_used_up")
|
||||
|
||||
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,57 +65,19 @@ 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"🎟 <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"""
|
||||
async def promo_management_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:
|
||||
@@ -86,337 +85,251 @@ async def promo_management_handler(callback: types.CallbackQuery,
|
||||
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"
|
||||
)
|
||||
promo_models = await promo_code_dal.get_all_promo_codes_with_details(session, limit=50, offset=0)
|
||||
if not promo_models:
|
||||
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()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in promo management: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
builder.row(InlineKeyboardButton(text=f"📝 {promo.code}", callback_data=f"promo_detail:{promo.promo_code_id}"))
|
||||
builder.row(InlineKeyboardButton(text=_("back_to_admin_panel_button"), callback_data="admin_action:main"))
|
||||
|
||||
await callback.message.edit_text(_("admin_promo_management_title"), reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@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}"
|
||||
)
|
||||
|
||||
if len(activations) > 10:
|
||||
text_lines.append(f"\n... и еще {len(activations) - 10}")
|
||||
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")])
|
||||
|
||||
text = "\n".join(text_lines)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_back_to_promo", default="🔙 К промокоду"),
|
||||
callback_data=f"promo_detail:{promo_id}"
|
||||
)
|
||||
)
|
||||
|
||||
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 viewing activations: {e}")
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
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.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, {}, session) # Settings not needed here
|
||||
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)
|
||||
|
||||
+17
-15
@@ -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,25 @@ 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)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
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 +99,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 +129,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 +141,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 +165,6 @@ async def record_promo_activation(
|
||||
logging.error(
|
||||
f"Cannot record promo activation: Payment {payment_id} not found."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
activation_data = {
|
||||
|
||||
+13
-2
@@ -161,9 +161,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.",
|
||||
|
||||
+13
-2
@@ -161,9 +161,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> не найден в базе данных бота.",
|
||||
|
||||
Reference in New Issue
Block a user