Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d9a5fb578 | |||
| 44d19245c4 | |||
| ce36c268cc | |||
| 1357b07921 | |||
| d4bfdcb749 | |||
| 39621706c8 | |||
| 4684e7a1e5 | |||
| 16bb3c8ad3 | |||
| 16fbdcc655 | |||
| 416d268352 | |||
| 44fb88f368 | |||
| f050c12253 | |||
| 82486d121e | |||
| 2bf382dbac | |||
| ab562e0df0 | |||
| 02e8fba9d0 | |||
| 530df33ba4 | |||
| 8b2e8cb681 | |||
| 2cb8c937e1 | |||
| ea5c08fb58 | |||
| 2507168916 | |||
| ec6e08f0d3 | |||
| ceee7cc977 | |||
| ece05cd9e9 | |||
| 2345016aa3 | |||
| d349352812 | |||
| e002ca2cb2 | |||
| 849afb4aa4 | |||
| 943628e6fe | |||
| 6621de95c0 | |||
| 87797153be | |||
| 4e833f9b8c | |||
| 560a2c362f | |||
| 71e9e1ebe2 | |||
| 73ed24aecf | |||
| 09eff6cf8e | |||
| ae7a36523b | |||
| 48a111da76 | |||
| 0f94a7cdcd | |||
| 79761b16bf | |||
| 7235caae0b | |||
| 2f294cdd21 | |||
| 33a124c1d2 | |||
| 91ed42ff08 | |||
| a2ffb3396e | |||
| 6846d58d46 | |||
| 0177ae064f | |||
| 72caed0138 | |||
| 332ce9e54d | |||
| c1ec6ed9fd | |||
| 203d082317 | |||
| 80067a2e93 | |||
| 9807eb33d3 | |||
| 063d7517e7 | |||
| f3ea09ad4a | |||
| 9650eb07d1 | |||
| 63c049abce | |||
| 02d501175d | |||
| f49ca0f72c | |||
| f5613c6e5e | |||
| fce5790765 | |||
| 5a0a65e591 | |||
| 4b4de9528b | |||
| 88f3c239bf | |||
| 293c1bebba | |||
| f84273dd28 | |||
| 5430f36e24 | |||
| 97f0ffd294 | |||
| 5cf8ba3d15 |
@@ -22,3 +22,8 @@ TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=3600
|
||||
MONITOR_DAILY_CHECK_HOUR=10
|
||||
MONITOR_WARNING_DAYS=2
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
+31
-15
@@ -1,30 +1,46 @@
|
||||
# Используем официальный Python образ
|
||||
# Use Python 3.11 slim image
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем системные зависимости
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем файл requirements.txt
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Устанавливаем Python зависимости
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel && \
|
||||
pip install --no-cache-dir -r requirements.txt --prefer-binary
|
||||
|
||||
# Копируем исходный код
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Создаем пользователя для безопасности
|
||||
RUN useradd --create-home --shell /bin/bash app \
|
||||
&& chown -R app:app /app
|
||||
USER app
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
|
||||
# Открываем порт (если нужен для веб-хуков)
|
||||
EXPOSE 8000
|
||||
# Switch to non-root user
|
||||
USER botuser
|
||||
|
||||
# Команда запуска
|
||||
CMD ["python", "main.py"]
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import asyncio; import sys; sys.exit(0)"
|
||||
|
||||
# Default command
|
||||
CMD ["python3", "main.py"]
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# RemnaWave Bot Docker Management
|
||||
|
||||
.PHONY: help build up down restart logs clean db-backup db-restore
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " build - Build all Docker images"
|
||||
@echo " up - Start all services"
|
||||
@echo " up-min - Start only bot and database (minimal setup)"
|
||||
@echo " up-full - Start all services including nginx and redis"
|
||||
@echo " down - Stop all services"
|
||||
@echo " restart - Restart all services"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " logs-bot - Show logs for bot service only"
|
||||
@echo " logs-db - Show logs for database service only"
|
||||
@echo " clean - Remove all containers, networks, and volumes"
|
||||
@echo " db-backup - Create database backup"
|
||||
@echo " db-restore - Restore database from backup"
|
||||
@echo " shell-bot - Open shell in bot container"
|
||||
@echo " shell-db - Open shell in database container"
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start minimal services (bot + database)
|
||||
up-min: setup-dirs
|
||||
docker compose up -d postgres bot
|
||||
|
||||
# Start all services including optional ones
|
||||
up-full: setup-dirs
|
||||
docker compose --profile with-nginx up -d
|
||||
|
||||
# Start main services (default)
|
||||
up: setup-dirs
|
||||
docker compose up -d postgres redis bot
|
||||
|
||||
# Setup required directories
|
||||
setup-dirs:
|
||||
mkdir -p logs data backups
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Show logs for bot only
|
||||
logs-bot:
|
||||
docker compose logs -f bot
|
||||
|
||||
# Show logs for database only
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Clean up everything (DANGEROUS - removes all data)
|
||||
clean:
|
||||
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
|
||||
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Database backup
|
||||
db-backup:
|
||||
@mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
|
||||
@echo "Backup created in backups/ directory"
|
||||
|
||||
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
|
||||
db-restore:
|
||||
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
|
||||
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
|
||||
@echo "Database restored from $(BACKUP)"
|
||||
|
||||
# Open shell in bot container
|
||||
shell-bot:
|
||||
docker compose exec bot /bin/bash
|
||||
|
||||
# Open shell in database container
|
||||
shell-db:
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
|
||||
|
||||
# Check services status
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# View service resource usage
|
||||
stats:
|
||||
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||
@@ -1,10 +1,11 @@
|
||||
<img width="900" height="782" alt="Снимок экрана 2025-08-04 в 17 48 12" src="https://github.com/user-attachments/assets/bcb3ed55-2582-4946-adc7-24396246cc02" />
|
||||
<img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 50 04" src="https://github.com/user-attachments/assets/a7bece59-9038-4983-82cf-a853f12bed6f" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 50 13" src="https://github.com/user-attachments/assets/4a3a0249-0b4f-4e18-8b27-e044dcfcdc50" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 50 21" src="https://github.com/user-attachments/assets/34c7fe40-9aaf-4160-913d-1f98ddbb5137" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 50 34" src="https://github.com/user-attachments/assets/84ec3760-6c3e-4700-8aa9-84aec533e579" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 50 49" src="https://github.com/user-attachments/assets/69b362ef-4658-4e3f-8457-3623e6998f6a" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 52 02" src="https://github.com/user-attachments/assets/5cd6187a-dc80-492c-8baa-bbfdac4a38ac" /><img width="900" height="633" alt="Снимок экрана 2025-08-04 в 17 52 09" src="https://github.com/user-attachments/assets/fc31972e-e95e-4f65-8465-3b9dc7246df1" /><img width="900" height="748" alt="Снимок экрана 2025-08-04 в 17 52 31" src="https://github.com/user-attachments/assets/c834cfdb-85a9-4b3b-985e-9386a8b67873" /><img width="900" height="749" alt="Снимок экрана 2025-08-04 в 17 52 46" src="https://github.com/user-attachments/assets/53210179-eb4f-4884-b6ed-1604bd324656" /><img width="900" height="749" alt="Снимок экрана 2025-08-04 в 17 52 55" src="https://github.com/user-attachments/assets/16eb4a20-25f2-4ac5-8570-9b5809021199" /><img width="900" height="749" alt="Снимок экрана 2025-08-04 в 17 53 13" src="https://github.com/user-attachments/assets/c100f74f-f1b8-486a-9107-2c6d1af5da4a" /><img width="900" height="749" alt="Снимок экрана 2025-08-04 в 17 53 21" src="https://github.com/user-attachments/assets/dd64921f-3129-4ee7-a973-314e8c787fbc" /><img width="900" height="749" alt="Снимок экрана 2025-08-04 в 17 55 41" src="https://github.com/user-attachments/assets/4a0c995f-a252-473b-9f5f-ff98d3fd004a" /><img width="900" height="682" alt="Снимок экрана 2025-08-04 в 17 57 56" src="https://github.com/user-attachments/assets/b9832f8a-0408-47f9-84e0-7c51a46aa394" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 17 58 09" src="https://github.com/user-attachments/assets/99a4eef9-da30-4fb6-befa-26520c84e37c" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 17 58 30" src="https://github.com/user-attachments/assets/b6986980-4a56-4014-ad3f-c4c5916e2af4" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 17 58 44" src="https://github.com/user-attachments/assets/55551413-e921-4df7-8129-cc79e1c8feba" />
|
||||
<img width="900" height="770" alt="Снимок экрана 2025-08-04 в 17 59 22" src="https://github.com/user-attachments/assets/f5c1b179-d61d-4cec-be4b-99ce3691ddc9" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 17 59 39" src="https://github.com/user-attachments/assets/2ffab61f-94c6-4134-9d3e-7975d2dc68a8" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 18 00 24" src="https://github.com/user-attachments/assets/74fb9c05-9597-4fb0-98cf-be089fd5de9d" /><img width="900" height="770" alt="Снимок экрана 2025-08-04 в 18 00 36" src="https://github.com/user-attachments/assets/10941dd6-631b-4af9-bb61-666d5052d00a" />
|
||||
<img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 15 13" src="https://github.com/user-attachments/assets/91098622-1bce-4f27-afef-60a3c5b5061f" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 22" src="https://github.com/user-attachments/assets/46b87e75-b420-4ac6-91b9-8c7e9bcffb2a" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 39" src="https://github.com/user-attachments/assets/ca97811f-ca00-4133-a120-1c11f0efa0fc" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 45" src="https://github.com/user-attachments/assets/258e1adb-2c39-4126-82a7-7791b56d42db" /><img width="906" height="496" alt="Снимок экрана 2025-08-05 в 03 14 53" src="https://github.com/user-attachments/assets/073455fc-f42d-4d70-839d-59042add2d94" /><img width="906" height="316" alt="Снимок экрана 2025-08-05 в 03 16 00" src="https://github.com/user-attachments/assets/2034dde8-a48b-4149-a23f-b788aa40e0b1" /><img width="894" height="317" alt="Снимок экрана 2025-08-05 в 15 32 32" src="https://github.com/user-attachments/assets/a96337cf-f58a-488e-9600-c94a92bdbfc2" /><img width="906" height="366" alt="Снимок экрана 2025-08-05 в 03 16 18" src="https://github.com/user-attachments/assets/3a3d1e0a-92fc-4573-a48c-f36481d6d0de" /><img width="906" height="842" alt="Снимок экрана 2025-08-05 в 03 17 24" src="https://github.com/user-attachments/assets/8b407f69-6861-4810-822e-c3f7b8f63629" /><img width="906" height="274" alt="Снимок экрана 2025-08-05 в 03 17 43" src="https://github.com/user-attachments/assets/923a945a-5ef8-4dcb-9804-fffc37ab8887" /><img width="936" height="364" alt="Снимок экрана 2025-08-05 в 03 20 03" src="https://github.com/user-attachments/assets/1faecdfe-f80c-4ac2-ad38-81a30fc6623d" /><img width="892" height="486" alt="Снимок экрана 2025-08-07 в 07 43 47" src="https://github.com/user-attachments/assets/0dd6cb8e-fd2f-4a98-8920-aadceee09fd0" /><img width="892" height="762" alt="Снимок экрана 2025-08-07 в 07 44 20" src="https://github.com/user-attachments/assets/d7c95e3e-cf04-40bc-9422-d7289447625d" /><img width="892" height="823" alt="Снимок экрана 2025-08-07 в 07 46 45" src="https://github.com/user-attachments/assets/9ab2c378-0abc-447d-9e95-a3ab8dab2f18" />
|
||||
<img width="892" height="501" alt="Снимок экрана 2025-08-07 в 07 42 07" src="https://github.com/user-attachments/assets/839c02da-4461-4127-894a-772e66175e23" /><img width="892" height="805" alt="Снимок экрана 2025-08-07 в 07 57 01" src="https://github.com/user-attachments/assets/bc35f79d-0b0d-4c81-8623-696b708642ad" /><img width="892" height="834" alt="Снимок экрана 2025-08-07 в 07 41 09" src="https://github.com/user-attachments/assets/d4731a79-0171-4254-aa78-2e4c7305829b" /><img width="631" height="606" alt="Снимок экрана 2025-08-06 в 18 48 31" src="https://github.com/user-attachments/assets/c44548b0-f27b-4f67-b3c0-2c002ae33979" />
|
||||
|
||||
|
||||
|
||||
#Описание
|
||||
|
||||
RemnaWave Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
|
||||
RemnaWave Bedolaga Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
|
||||
|
||||
Бот интегрирован с системой RemnaWave версии 2.0.8
|
||||
|
||||
@@ -14,7 +15,7 @@ RemnaWave Telegram Bot — это многофункциональный бот
|
||||
|
||||
Создание и покупка подписок с управлением трафиком, длительностью и ценой
|
||||
|
||||
Бесплатная тестовая подписка с ограничениями
|
||||
Бесплатная тестовая подписка с заданными ограничениями(срок, лимит трафика, назначение сквада)
|
||||
|
||||
Пополнение баланса: 1) Через саппорт в ручную 2) Отправка заявки с суммой админу (С возможность подтвердить/отклонить заявку)
|
||||
|
||||
@@ -26,9 +27,15 @@ RemnaWave Telegram Bot — это многофункциональный бот
|
||||
|
||||
Рассылка сообщений отдельным пользователям и всем сразу
|
||||
|
||||
Сервис контроля истечения сроков действия подписки(Уведомляет об истечении за указанный в настройках срок), уведомления с предложением продления подписи. (NEW)
|
||||
|
||||
Интеграция с RemnaWave API для управления подписками и пользователями RemnaWave
|
||||
|
||||
История платежей(Не работает, в доработке) и управление платежами (подтверждение, отклонение)
|
||||
Полная синхранизация Remnawave <--> Bot - Перенос подписок из панели Remnawave в бот по Telegram id
|
||||
|
||||
Управление системой Remnawave (NEW)
|
||||
|
||||
Управление платежами (подтверждение, отклонение) + История платежей(Все действия с балансом и подписками в постраничной истории)
|
||||
|
||||
|
||||
#Требования
|
||||
@@ -43,24 +50,16 @@ PostgreSQL, SQLite или другая поддерживаемая SQL-база
|
||||
|
||||
URL и токен RemnaWave API
|
||||
|
||||
Ссылки на подписку из ремны формата SUB_PUBLIC_DOMAIN=sub.example.com/sub
|
||||
|
||||
#Установка
|
||||
|
||||
1) Клонируйте репозиторий:
|
||||
1. Клонируйте репозиторий:
|
||||
|
||||
git clone https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
2) Установите python3 python pip
|
||||
|
||||
sudo apt install pip
|
||||
sudo apt install python3
|
||||
|
||||
3) Установите зависимости:
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
4) Создайте файл .env в корне проекта и заполните его необходимыми переменными окружения. Пример:
|
||||
2. Создайте файл .env в корне проекта и заполните его необходимыми переменными окружения. Пример:
|
||||
|
||||
BOT_TOKEN=ваш_telegram_bot_token
|
||||
REMNAWAVE_URL=https://your-remnawave-url.ru
|
||||
@@ -76,17 +75,46 @@ URL и токен RemnaWave API
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=19bd5bde-5eea-4368-809c-6ba1ffb93897
|
||||
TRIAL_PRICE=0.0
|
||||
MONITOR_CHECK_INTERVAL=3600
|
||||
MONITOR_DAILY_CHECK_HOUR=10
|
||||
MONITOR_WARNING_DAYS=2
|
||||
|
||||
5) Запустите бота:
|
||||
|
||||
1) Хлебный - создание службы автозапуска, проверка файлов, запуск бота
|
||||
|
||||
4. Соберите образ (Makefile Dockerfile docker-compose):
|
||||
|
||||
chmod +x run.sh
|
||||
./run.sh
|
||||
make build
|
||||
|
||||
2) Для мужчин (Службу там поднять самому, докерфайл собрать или под скрином развернуть - уже твое дело)
|
||||
5. Запуск:
|
||||
|
||||
python main.py
|
||||
Запуск минимальной конфигурации (бот + база данных):
|
||||
|
||||
make up-min
|
||||
|
||||
Или запуск с Redis:
|
||||
|
||||
make up
|
||||
|
||||
Или запуск со всеми сервисами включая Nginx:
|
||||
|
||||
make up-full
|
||||
|
||||
5. Управление
|
||||
|
||||
Просмотр логов:
|
||||
|
||||
make logs-bot
|
||||
|
||||
Статус сервисов:
|
||||
|
||||
make status
|
||||
|
||||
Перезапуск:
|
||||
|
||||
make restart
|
||||
|
||||
Остановка:
|
||||
|
||||
make down
|
||||
|
||||
#Конфигурация
|
||||
|
||||
@@ -100,6 +128,8 @@ REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
|
||||
|
||||
DATABASE_URL — строка подключения к базе данных.
|
||||
|
||||
SUBSCRIPTION_BASE_URL=https://sub.example.com (без / на конце)
|
||||
|
||||
ADMIN_IDS — через запятую Telegram ID администраторов.
|
||||
|
||||
SUPPORT_USERNAME — ник поддержки, без @ указывать
|
||||
@@ -116,10 +146,28 @@ TRIAL_SQUAD_UUID=(УКазать UUID сквада из панели!)
|
||||
|
||||
TRIAL_PRICE=0.0(не трогать)
|
||||
|
||||
Monitor Service Settings (дополнительные настройки)
|
||||
|
||||
MONITOR_CHECK_INTERVAL=3600 (Запуск службы проверки)
|
||||
|
||||
MONITOR_DAILY_CHECK_HOUR=10 (Разовый чек в определенный промежуток дня)
|
||||
|
||||
MONITOR_WARNING_DAYS=2 (За сколько дней слать уведомления)
|
||||
|
||||
|
||||
#Использование
|
||||
|
||||
/start
|
||||
/start - запуск
|
||||
|
||||
#Синхронизация подписок
|
||||
|
||||
Вы можете перенести свои существующие подписки из панели Remnawave прямо в бота всего одним кликом.
|
||||
Для этого в админ панеле реализован соостветствующий пункт: Админ панель - Система Remnawave - Синхронизация с Remnawave - Импорт всех по Telegram ID. После нажатия подтянет всех пользователей в бота, подпискам из панели будет назначено имя "Старая подписка" - такую подписку невозможно продлить.
|
||||
|
||||
ДОПОЛНИТЕЛЬНО:
|
||||
Реализована возможность зачистки импортированных из панели подписок по тг айди Админ панель - Система Remnawave - Синхронизация с Remnawave - Просмотрт планов - Удалалить импортированные
|
||||
|
||||
Остальное трогать без понимания кода - не рекомендую.
|
||||
|
||||
#Структура проекта
|
||||
|
||||
@@ -141,6 +189,8 @@ utils.py — вспомогательные функции.
|
||||
|
||||
middlewares.py — промежуточные слои для обработки сообщений и запросов.
|
||||
|
||||
subscription_monitor.py - сервис мониторинга сроков истечения подписок
|
||||
|
||||
.env — файл конфигурации с переменными окружения.
|
||||
|
||||
requirements.txt — список зависимостей Python.
|
||||
@@ -165,12 +215,20 @@ run.sh — скрипт установки и управления ботом (
|
||||
|
||||
Отправка сообщений пользователям или массовая рассылка
|
||||
|
||||
Просмотр краткой статистики
|
||||
Мониторинг подписок (Проверка статуса службы, принудитедьный запуск, деактивация истекщих подписок(на случай падения базы), персональный тест(можно отправить уведомления юзеру по tg id)
|
||||
|
||||
Просмотр статистики
|
||||
|
||||
Управление систеой Remnawave (Ноды, пользователи, синхронизация и импорт подписок из базы Remnawave в бот)
|
||||
|
||||
#ToDo
|
||||
|
||||
1) Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
|
||||
Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
|
||||
1) Дописать службу для оповещения об истечении срока подписки и контроля - Done v1.1.0
|
||||
2) Подключить различные шлюзы для пополнения баланса
|
||||
3) Дописать службу для оповещения об истечении срока подписки и контроля
|
||||
4) Синхранизацию с Remnawave между пользователями по тг id
|
||||
5) Полнофункциональную панель упарвления
|
||||
3) Синхранизацию с Remnawave между пользователями по тг id
|
||||
4) Полнофункциональную панель упарвления
|
||||
5) Добавить возможность удаление промокодов - In progress
|
||||
6) Доработать алгоритм удаления подписок ибо удаление(А НЕ деактивация) сейчас - скроект эту подписку у всех юзеров которые ее купили, так что удаляйте на свой страх и риск я предупредил) - In progress
|
||||
8) Отправка уведомлений административных в другие чаты-топики
|
||||
9) Рефка (как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю)
|
||||
|
||||
+4492
-19
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
# api_error_handlers.py - Дополнительные утилиты для обработки ошибок API
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from aiogram.types import CallbackQuery
|
||||
from aiogram import Router
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from translations import t
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class APIErrorHandler:
|
||||
"""Класс для обработки ошибок API и предоставления пользователю понятной информации"""
|
||||
|
||||
@staticmethod
|
||||
async def handle_api_error(callback: CallbackQuery, error: Exception,
|
||||
operation: str, user_language: str = 'ru',
|
||||
fallback_keyboard=None) -> bool:
|
||||
"""
|
||||
Обработка ошибок API с отправкой понятного сообщения пользователю
|
||||
|
||||
Returns:
|
||||
bool: True если ошибка была обработана, False если нужно перепробросить
|
||||
"""
|
||||
error_message = str(error).lower()
|
||||
|
||||
if "timeout" in error_message or "connection" in error_message:
|
||||
text = "⏱ Таймаут подключения к API\n\n"
|
||||
text += "Возможные причины:\n"
|
||||
text += "• Медленный интернет\n"
|
||||
text += "• Перегрузка сервера RemnaWave\n"
|
||||
text += "• Временные проблемы с сетью\n\n"
|
||||
text += "🔄 Попробуйте повторить операцию через несколько секунд"
|
||||
|
||||
elif "401" in error_message or "unauthorized" in error_message:
|
||||
text = "🔐 Ошибка авторизации API\n\n"
|
||||
text += "Токен доступа недействителен или истек.\n"
|
||||
text += "Обратитесь к администратору для обновления токена."
|
||||
|
||||
elif "404" in error_message or "not found" in error_message:
|
||||
text = f"❌ Ресурс не найден\n\n"
|
||||
text += f"Операция: {operation}\n"
|
||||
text += "Возможно, запрашиваемый объект был удален или не существует."
|
||||
|
||||
elif "500" in error_message or "internal server error" in error_message:
|
||||
text = "🔥 Внутренняя ошибка сервера RemnaWave\n\n"
|
||||
text += "Сервер временно недоступен.\n"
|
||||
text += "Попробуйте повторить операцию позже."
|
||||
|
||||
else:
|
||||
text = f"❌ Ошибка API операции: {operation}\n\n"
|
||||
text += f"Детали: {str(error)[:100]}{'...' if len(str(error)) > 100 else ''}\n\n"
|
||||
text += "Обратитесь к администратору если проблема повторяется."
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=fallback_keyboard or error_recovery_keyboard(operation, user_language)
|
||||
)
|
||||
return True
|
||||
except Exception as edit_error:
|
||||
logger.error(f"Failed to edit message with error info: {edit_error}")
|
||||
try:
|
||||
await callback.answer(f"❌ Ошибка: {operation}", show_alert=True)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def safe_api_call(api_method: Callable, *args, **kwargs) -> tuple[bool, Any]:
|
||||
"""
|
||||
Безопасный вызов метода API с обработкой ошибок
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, result: Any)
|
||||
"""
|
||||
try:
|
||||
result = await api_method(*args, **kwargs)
|
||||
return True, result
|
||||
except Exception as e:
|
||||
logger.error(f"API call failed: {api_method.__name__} - {e}")
|
||||
return False, str(e)
|
||||
|
||||
# Дополнительные обработчики для исправления конкретных проблем
|
||||
def create_error_recovery_keyboard(error_context: str, language: str = 'ru'):
|
||||
"""Создание клавиатуры для восстановления после ошибки"""
|
||||
from keyboards import error_recovery_keyboard
|
||||
return error_recovery_keyboard(error_context, language)
|
||||
|
||||
# Улучшенные функции для работы с RemnaWave API
|
||||
async def safe_get_nodes(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
"""Безопасное получение списка нод"""
|
||||
try:
|
||||
logger.info("Attempting to fetch nodes from API...")
|
||||
nodes = await api.get_all_nodes()
|
||||
|
||||
if nodes is None:
|
||||
logger.warning("API returned None for nodes")
|
||||
return False, []
|
||||
|
||||
if not isinstance(nodes, list):
|
||||
logger.warning(f"API returned non-list for nodes: {type(nodes)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(nodes)} nodes")
|
||||
return True, nodes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching nodes: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_get_system_users(api: RemnaWaveAPI) -> tuple[bool, list]:
|
||||
"""Безопасное получение списка пользователей системы"""
|
||||
try:
|
||||
logger.info("Attempting to fetch system users from API...")
|
||||
users = await api.get_all_system_users_full()
|
||||
|
||||
if users is None:
|
||||
logger.warning("API returned None for users")
|
||||
return False, []
|
||||
|
||||
if not isinstance(users, list):
|
||||
logger.warning(f"API returned non-list for users: {type(users)}")
|
||||
return False, []
|
||||
|
||||
logger.info(f"Successfully fetched {len(users)} users")
|
||||
return True, users
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching system users: {e}")
|
||||
return False, []
|
||||
|
||||
async def safe_restart_nodes(api: RemnaWaveAPI, all_nodes: bool = True, node_id: str = None) -> tuple[bool, str]:
|
||||
"""Безопасная перезагрузка нод"""
|
||||
try:
|
||||
if all_nodes:
|
||||
logger.info("Attempting to restart all nodes...")
|
||||
result = await api.restart_all_nodes()
|
||||
else:
|
||||
logger.info(f"Attempting to restart node {node_id}...")
|
||||
result = await api.restart_node(node_id)
|
||||
|
||||
if result:
|
||||
message = "Команда перезагрузки отправлена успешно"
|
||||
logger.info(f"Restart command sent successfully")
|
||||
return True, message
|
||||
else:
|
||||
message = "API вернул отрицательный результат"
|
||||
logger.warning("API returned negative result for restart")
|
||||
return False, message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error restarting nodes: {e}")
|
||||
return False, str(e)
|
||||
|
||||
# Функции для проверки состояния API
|
||||
async def check_api_health(api: RemnaWaveAPI) -> Dict[str, Any]:
|
||||
"""Проверка состояния API"""
|
||||
health_info = {
|
||||
'api_available': False,
|
||||
'nodes_accessible': False,
|
||||
'users_accessible': False,
|
||||
'system_stats_accessible': False,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
if api is None:
|
||||
health_info['errors'].append("API instance is None")
|
||||
return health_info
|
||||
|
||||
# Проверяем доступность API
|
||||
try:
|
||||
# Простая проверка через получение нод (обычно быстрая операция)
|
||||
success, nodes = await safe_get_nodes(api)
|
||||
if success:
|
||||
health_info['api_available'] = True
|
||||
health_info['nodes_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch nodes")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Nodes check failed: {e}")
|
||||
|
||||
# Проверяем доступность пользователей
|
||||
try:
|
||||
success, users = await safe_get_system_users(api)
|
||||
if success:
|
||||
health_info['users_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch users")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"Users check failed: {e}")
|
||||
|
||||
# Проверяем системную статистику
|
||||
try:
|
||||
stats = await api.get_system_stats()
|
||||
if stats:
|
||||
health_info['system_stats_accessible'] = True
|
||||
else:
|
||||
health_info['errors'].append("Cannot fetch system stats")
|
||||
except Exception as e:
|
||||
health_info['errors'].append(f"System stats check failed: {e}")
|
||||
|
||||
return health_info
|
||||
|
||||
# Декоратор для автоматической обработки ошибок API
|
||||
def handle_api_errors(operation_name: str):
|
||||
"""Декоратор для автоматической обработки ошибок API в handler'ах"""
|
||||
def decorator(func):
|
||||
async def wrapper(callback: CallbackQuery, user, *args, **kwargs):
|
||||
try:
|
||||
return await func(callback, user, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in {func.__name__}: {e}")
|
||||
|
||||
# Получаем API из kwargs если есть
|
||||
api = kwargs.get('api')
|
||||
fallback_keyboard = None
|
||||
|
||||
# Создаем fallback клавиатуру в зависимости от операции
|
||||
if 'nodes' in operation_name.lower():
|
||||
from keyboards import admin_system_keyboard
|
||||
fallback_keyboard = admin_system_keyboard(user.language)
|
||||
elif 'users' in operation_name.lower():
|
||||
from keyboards import system_users_keyboard
|
||||
fallback_keyboard = system_users_keyboard(user.language)
|
||||
|
||||
# Обрабатываем ошибку
|
||||
await APIErrorHandler.handle_api_error(
|
||||
callback, e, operation_name, user.language, fallback_keyboard
|
||||
)
|
||||
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -22,6 +22,11 @@ class Config:
|
||||
TRIAL_TRAFFIC_GB: int
|
||||
TRIAL_SQUAD_UUID: str
|
||||
TRIAL_PRICE: float
|
||||
|
||||
# Monitor service settings
|
||||
MONITOR_CHECK_INTERVAL: int
|
||||
MONITOR_DAILY_CHECK_HOUR: int
|
||||
MONITOR_WARNING_DAYS: int
|
||||
|
||||
def load_config() -> Config:
|
||||
"""Load configuration from environment variables"""
|
||||
@@ -39,7 +44,7 @@ def load_config() -> Config:
|
||||
|
||||
# Если SUBSCRIPTION_BASE_URL не установлен, используем значение по умолчанию
|
||||
if not subscription_base_url:
|
||||
subscription_base_url = 'https://sub.example.com'
|
||||
subscription_base_url = 'https://sub.fring.tech'
|
||||
|
||||
return Config(
|
||||
BOT_TOKEN=os.getenv('BOT_TOKEN', ''),
|
||||
@@ -59,5 +64,10 @@ def load_config() -> Config:
|
||||
TRIAL_DURATION_DAYS=int(os.getenv('TRIAL_DURATION_DAYS', '3')),
|
||||
TRIAL_TRAFFIC_GB=int(os.getenv('TRIAL_TRAFFIC_GB', '2')),
|
||||
TRIAL_SQUAD_UUID=os.getenv('TRIAL_SQUAD_UUID', '19bd5bde-5eea-4368-809c-6ba1ffb93897'),
|
||||
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0'))
|
||||
TRIAL_PRICE=float(os.getenv('TRIAL_PRICE', '0.0')),
|
||||
|
||||
# Monitor service settings
|
||||
MONITOR_CHECK_INTERVAL=int(os.getenv('MONITOR_CHECK_INTERVAL', '3600')),
|
||||
MONITOR_DAILY_CHECK_HOUR=int(os.getenv('MONITOR_DAILY_CHECK_HOUR', '10')),
|
||||
MONITOR_WARNING_DAYS=int(os.getenv('MONITOR_WARNING_DAYS', '2'))
|
||||
)
|
||||
|
||||
+260
-34
@@ -1,6 +1,6 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer
|
||||
from sqlalchemy import BigInteger, String, Float, DateTime, Boolean, Text, Integer, text
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
import logging
|
||||
@@ -38,6 +38,7 @@ class Subscription(Base):
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
is_trial: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
is_imported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
class UserSubscription(Base):
|
||||
__tablename__ = 'user_subscriptions'
|
||||
@@ -48,7 +49,9 @@ class UserSubscription(Base):
|
||||
short_uuid: Mapped[str] = mapped_column(String(255)) # УБРАНО unique=True
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
traffic_limit_gb: Mapped[Optional[int]] = mapped_column(Integer) # Добавлено поле
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, onupdate=datetime.utcnow) # Добавлено поле
|
||||
|
||||
class Payment(Base):
|
||||
__tablename__ = 'payments'
|
||||
@@ -100,6 +103,10 @@ class Database:
|
||||
async with self.engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Выполняем миграции
|
||||
await self.migrate_user_subscriptions()
|
||||
await self.migrate_subscription_imported_field()
|
||||
|
||||
async def close(self):
|
||||
await self.engine.dispose()
|
||||
|
||||
@@ -166,18 +173,48 @@ class Database:
|
||||
return False
|
||||
|
||||
# Subscription methods
|
||||
async def get_all_subscriptions(self, include_inactive: bool = False) -> List[Subscription]:
|
||||
async def get_all_subscriptions(self, include_inactive: bool = False, exclude_trial: bool = True, exclude_imported: bool = True) -> List[Subscription]:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
query = select(Subscription)
|
||||
if not include_inactive:
|
||||
query = query.where(Subscription.is_active == True)
|
||||
if exclude_trial:
|
||||
query = query.where(Subscription.is_trial == False)
|
||||
if exclude_imported:
|
||||
query = query.where(Subscription.is_imported == False) # Исключаем импортированные
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_subscriptions_admin(self) -> List[Subscription]:
|
||||
"""Get all subscriptions including imported ones (for admin purposes)"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(select(Subscription))
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting admin subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def migrate_subscription_imported_field(self):
|
||||
"""Add is_imported field to subscriptions table"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE subscriptions
|
||||
ADD COLUMN IF NOT EXISTS is_imported BOOLEAN DEFAULT FALSE
|
||||
"""))
|
||||
logger.info("Successfully added is_imported field to subscriptions table")
|
||||
except Exception as e:
|
||||
logger.info(f"Migration may have already been applied: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during subscription migration: {e}")
|
||||
|
||||
async def get_subscription_by_id(self, subscription_id: int) -> Optional[Subscription]:
|
||||
async with self.session_factory() as session:
|
||||
@@ -193,7 +230,7 @@ class Database:
|
||||
|
||||
async def create_subscription(self, name: str, description: str, price: float,
|
||||
duration_days: int, traffic_limit_gb: int,
|
||||
squad_uuid: str) -> Subscription:
|
||||
squad_uuid: str, is_imported: bool = False) -> Subscription:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
subscription = Subscription(
|
||||
@@ -202,7 +239,8 @@ class Database:
|
||||
price=price,
|
||||
duration_days=duration_days,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
squad_uuid=squad_uuid
|
||||
squad_uuid=squad_uuid,
|
||||
is_imported=is_imported # Добавляем поддержку is_imported
|
||||
)
|
||||
session.add(subscription)
|
||||
await session.commit()
|
||||
@@ -250,26 +288,47 @@ class Database:
|
||||
logger.error(f"Error getting user subscriptions for {user_id}: {e}")
|
||||
return []
|
||||
|
||||
async def create_user_subscription(self, user_id: int, subscription_id: int,
|
||||
short_uuid: str, expires_at: datetime) -> UserSubscription:
|
||||
async def create_user_subscription(self, user_id: int, subscription_id: int,
|
||||
short_uuid: str, expires_at: datetime,
|
||||
is_active: bool = True, traffic_limit_gb: int = None) -> Optional[UserSubscription]:
|
||||
"""Create user subscription with proper error handling"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
user_sub = UserSubscription(
|
||||
# Проверяем что подписка не существует
|
||||
from sqlalchemy import select
|
||||
existing = await session.execute(
|
||||
select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.short_uuid == short_uuid
|
||||
)
|
||||
)
|
||||
existing_sub = existing.scalar_one_or_none()
|
||||
|
||||
if existing_sub:
|
||||
logger.warning(f"Subscription with short_uuid {short_uuid} already exists for user {user_id}")
|
||||
return existing_sub
|
||||
|
||||
# Создаем новую подписку
|
||||
new_subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_id=subscription_id,
|
||||
short_uuid=short_uuid,
|
||||
expires_at=expires_at
|
||||
expires_at=expires_at,
|
||||
is_active=is_active
|
||||
)
|
||||
session.add(user_sub)
|
||||
|
||||
session.add(new_subscription)
|
||||
await session.commit()
|
||||
await session.refresh(user_sub)
|
||||
return user_sub
|
||||
await session.refresh(new_subscription)
|
||||
|
||||
return new_subscription
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating user subscription: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
return None
|
||||
|
||||
# Payment methods
|
||||
# Payment methods
|
||||
async def create_payment(self, user_id: int, amount: float, payment_type: str,
|
||||
description: str, status: str = 'pending') -> Payment:
|
||||
async with self.session_factory() as session:
|
||||
@@ -415,48 +474,104 @@ class Database:
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, func
|
||||
|
||||
|
||||
# Total users
|
||||
total_users = await session.execute(
|
||||
select(func.count(User.id))
|
||||
)
|
||||
total_users = total_users.scalar()
|
||||
|
||||
# Total subscriptions
|
||||
total_subs = await session.execute(
|
||||
|
||||
# Total subscriptions (excluding trial)
|
||||
total_subs_non_trial = await session.execute(
|
||||
select(func.count(UserSubscription.id))
|
||||
.join(Subscription, UserSubscription.subscription_id == Subscription.id)
|
||||
.where(Subscription.is_trial == False)
|
||||
)
|
||||
total_subs = total_subs.scalar()
|
||||
|
||||
# Total payments
|
||||
total_subs_non_trial = total_subs_non_trial.scalar()
|
||||
|
||||
# Total payments (excluding trial payments)
|
||||
total_payments = await session.execute(
|
||||
select(func.sum(Payment.amount)).where(Payment.status == 'completed')
|
||||
select(func.sum(Payment.amount)).where(
|
||||
Payment.status == 'completed',
|
||||
Payment.payment_type != 'trial' # Исключаем тестовые платежи
|
||||
)
|
||||
)
|
||||
total_payments = total_payments.scalar() or 0
|
||||
|
||||
|
||||
return {
|
||||
'total_users': total_users,
|
||||
'total_subscriptions': total_subs,
|
||||
'total_revenue': total_payments
|
||||
'total_users': total_users,
|
||||
'total_subscriptions_non_trial': total_subs_non_trial,
|
||||
'total_revenue': total_payments
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats: {e}")
|
||||
return {
|
||||
'total_users': 0,
|
||||
'total_subscriptions': 0,
|
||||
'total_subscriptions_non_trial': 0,
|
||||
'total_revenue': 0
|
||||
}
|
||||
|
||||
async def update_user_subscription(self, user_sub: UserSubscription) -> UserSubscription:
|
||||
}
|
||||
|
||||
async def get_trial_subscriptions(self) -> List[Subscription]:
|
||||
"""Get only trial subscriptions"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
await session.merge(user_sub)
|
||||
await session.commit()
|
||||
return user_sub
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(Subscription).where(Subscription.is_trial == True)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user subscription {user_sub.id}: {e}")
|
||||
logger.error(f"Error getting trial subscriptions: {e}")
|
||||
return []
|
||||
|
||||
async def get_user_subscription_by_short_uuid(self, user_id: int, short_uuid: str) -> Optional[UserSubscription]:
|
||||
"""Get user subscription by short_uuid"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(
|
||||
UserSubscription.user_id == user_id,
|
||||
UserSubscription.short_uuid == short_uuid
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscription by short_uuid: {e}")
|
||||
return None
|
||||
|
||||
async def update_user_subscription(self, user_subscription: UserSubscription) -> bool:
|
||||
"""Update user subscription"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
# Устанавливаем время обновления
|
||||
user_subscription.updated_at = datetime.utcnow()
|
||||
|
||||
# Обновляем подписку
|
||||
await session.merge(user_subscription)
|
||||
await session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating user subscription: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
return False
|
||||
|
||||
async def migrate_user_subscriptions(self):
|
||||
"""Migrate user_subscriptions table to add missing columns"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
# Проверяем существование столбцов и добавляем их если нет
|
||||
try:
|
||||
await conn.execute(text("""
|
||||
ALTER TABLE user_subscriptions
|
||||
ADD COLUMN IF NOT EXISTS traffic_limit_gb INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP
|
||||
"""))
|
||||
logger.info("Successfully migrated user_subscriptions table")
|
||||
except Exception as e:
|
||||
logger.info(f"Migration may have already been applied or error occurred: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during migration: {e}")
|
||||
|
||||
async def get_expiring_subscriptions(self, user_id: int, days_threshold: int = 3) -> List[UserSubscription]:
|
||||
async with self.session_factory() as session:
|
||||
@@ -507,3 +622,114 @@ class Database:
|
||||
logger.error(f"Error marking trial used for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
async def get_all_payments_paginated(self, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get all payments with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id))
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_payments_by_type_paginated(self, payment_type: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get payments by type with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id)).where(Payment.payment_type == payment_type)
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.payment_type == payment_type)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments by type: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_payments_by_status_paginated(self, status: str, offset: int = 0, limit: int = 10) -> tuple[List[Payment], int]:
|
||||
"""Get payments by status with pagination"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, desc, func
|
||||
|
||||
# Получаем общее количество записей
|
||||
count_result = await session.execute(
|
||||
select(func.count(Payment.id)).where(Payment.status == status)
|
||||
)
|
||||
total_count = count_result.scalar()
|
||||
|
||||
# Получаем платежи с пагинацией
|
||||
result = await session.execute(
|
||||
select(Payment)
|
||||
.where(Payment.status == status)
|
||||
.order_by(desc(Payment.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
payments = list(result.scalars().all())
|
||||
|
||||
return payments, total_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting paginated payments by status: {e}")
|
||||
return [], 0
|
||||
|
||||
async def get_user_subscriptions_by_plan_id(self, plan_id: int) -> List[UserSubscription]:
|
||||
"""Get all user subscriptions for a specific plan"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(UserSubscription).where(UserSubscription.subscription_id == plan_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user subscriptions for plan {plan_id}: {e}")
|
||||
return []
|
||||
|
||||
async def delete_user_subscription(self, user_subscription_id: int) -> bool:
|
||||
"""Delete user subscription by ID"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
result = await session.execute(
|
||||
delete(UserSubscription).where(UserSubscription.id == user_subscription_id)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting user subscription {user_subscription_id}: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
@@ -1 +1,102 @@
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: remnawave_bot_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: remnawave_bot
|
||||
POSTGRES_USER: remnawave_user
|
||||
POSTGRES_PASSWORD: secure_password_123
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U remnawave_user -d remnawave_bot"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# RemnaWave Bot
|
||||
bot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: remnawave_bot
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# Override database URL to use PostgreSQL container
|
||||
DATABASE_URL: postgresql+asyncpg://remnawave_user:secure_password_123@postgres:5432/remnawave_bot
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
- ./data:/app/data
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c 'print(\"Bot is running\")'"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Redis (optional, for caching and session storage)
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: remnawave_bot_redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --requirepass redis_password_123
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- bot_network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# Nginx (optional, for serving static files or reverse proxy)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: remnawave_bot_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./ssl:/etc/nginx/ssl:ro
|
||||
- ./static:/usr/share/nginx/html:ro
|
||||
networks:
|
||||
- bot_network
|
||||
depends_on:
|
||||
- bot
|
||||
profiles:
|
||||
- with-nginx
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
|
||||
+125
-45
@@ -22,26 +22,48 @@ class BotStates(StatesGroup):
|
||||
waiting_language = State()
|
||||
waiting_amount = State()
|
||||
waiting_promocode = State()
|
||||
waiting_topup_amount = State()
|
||||
|
||||
# Admin states
|
||||
# Admin subscription management
|
||||
admin_create_sub_name = State()
|
||||
admin_create_sub_desc = State()
|
||||
admin_create_sub_price = State()
|
||||
admin_create_sub_days = State()
|
||||
admin_create_sub_traffic = State()
|
||||
admin_create_sub_squad = State()
|
||||
admin_create_sub_squad_select = State()
|
||||
admin_edit_sub_value = State()
|
||||
|
||||
# Admin balance management
|
||||
admin_add_balance_user = State()
|
||||
admin_add_balance_amount = State()
|
||||
admin_payment_history_page = State()
|
||||
|
||||
# Admin promocode management
|
||||
admin_create_promo_code = State()
|
||||
admin_create_promo_discount = State()
|
||||
admin_create_promo_limit = State()
|
||||
admin_edit_sub_value = State()
|
||||
|
||||
# Admin messaging
|
||||
admin_send_message_user = State()
|
||||
admin_send_message_text = State()
|
||||
admin_broadcast_text = State()
|
||||
admin_create_sub_squad_select = State()
|
||||
|
||||
# Admin user management
|
||||
admin_search_user_uuid = State()
|
||||
admin_search_user_any = State()
|
||||
admin_edit_user_expiry = State()
|
||||
admin_edit_user_traffic = State()
|
||||
|
||||
# Admin monitoring
|
||||
admin_test_monitor_user = State()
|
||||
|
||||
admin_sync_single_user = State()
|
||||
|
||||
admin_debug_user_structure = State()
|
||||
|
||||
admin_rename_plans_confirm = State()
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -290,18 +312,19 @@ async def confirm_trial_callback(callback: CallbackQuery, db: Database, **kwargs
|
||||
)
|
||||
return
|
||||
|
||||
# Создаем тестовую подписку в базе данных (создаем временную подписку)
|
||||
# Создаем временную тестовую подписку, которая НЕ будет отображаться в админке
|
||||
trial_subscription = await db.create_subscription(
|
||||
name="Тестовая подписка",
|
||||
description="Бесплатная тестовая подписка на 3 дня",
|
||||
price=config.TRIAL_PRICE,
|
||||
name=f"Trial_{user.telegram_id}_{int(datetime.utcnow().timestamp())}", # Уникальное имя
|
||||
description="Автоматически созданная тестовая подписка",
|
||||
price=0,
|
||||
duration_days=config.TRIAL_DURATION_DAYS,
|
||||
traffic_limit_gb=config.TRIAL_TRAFFIC_GB,
|
||||
squad_uuid=config.TRIAL_SQUAD_UUID
|
||||
)
|
||||
|
||||
# Помечаем подписку как тестовую
|
||||
# Помечаем подписку как тестовую И неактивную для админки
|
||||
trial_subscription.is_trial = True
|
||||
trial_subscription.is_active = False # Скрываем от обычных запросов
|
||||
await db.update_subscription(trial_subscription)
|
||||
|
||||
# Создаем пользовательскую подписку
|
||||
@@ -517,9 +540,7 @@ async def buy_subscription_callback(callback: CallbackQuery, db: Database, **kwa
|
||||
return
|
||||
|
||||
try:
|
||||
# Получаем все подписки, исключая тестовые
|
||||
all_subscriptions = await db.get_all_subscriptions()
|
||||
subscriptions = [sub for sub in all_subscriptions if not sub.is_trial]
|
||||
subscriptions = await db.get_all_subscriptions(exclude_trial=True)
|
||||
|
||||
if not subscriptions:
|
||||
await callback.message.edit_text(
|
||||
@@ -818,8 +839,9 @@ async def view_subscription_detail(callback: CallbackQuery, db: Database, **kwar
|
||||
|
||||
@router.callback_query(F.data.startswith("extend_sub_"))
|
||||
async def extend_subscription_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
"""Show extend subscription confirmation"""
|
||||
"""Show subscription extension confirmation"""
|
||||
user = kwargs.get('user')
|
||||
|
||||
if not user:
|
||||
await callback.answer("❌ Ошибка пользователя")
|
||||
return
|
||||
@@ -827,45 +849,61 @@ async def extend_subscription_callback(callback: CallbackQuery, db: Database, **
|
||||
try:
|
||||
user_sub_id = int(callback.data.split("_")[2])
|
||||
|
||||
# Get user subscription
|
||||
# Получаем все подписки пользователя и находим нужную
|
||||
user_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if not user_sub:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
await callback.answer(t('subscription_not_found', user.language))
|
||||
return
|
||||
|
||||
# Get subscription details
|
||||
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
await callback.answer(t('subscription_not_found', user.language))
|
||||
return
|
||||
|
||||
# ИСПРАВЛЕНИЕ: Запрещаем продление тестовых подписок
|
||||
# Check if subscription is trial (can't extend trial)
|
||||
if subscription.is_trial:
|
||||
await callback.answer("❌ Тестовую подписку нельзя продлить")
|
||||
return
|
||||
|
||||
# Check balance
|
||||
# Check if user has enough balance
|
||||
if user.balance < subscription.price:
|
||||
await callback.answer(t('insufficient_balance', user.language))
|
||||
needed = subscription.price - user.balance
|
||||
text = f"❌ Недостаточно средств для продления!\n\n"
|
||||
text += f"💰 Стоимость продления: {subscription.price} руб.\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб.\n"
|
||||
text += f"💸 Нужно пополнить: {needed} руб."
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💰 Пополнить баланс", callback_data="topup_balance")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data=f"view_sub_{user_sub_id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
text = t('extend_confirmation', user.language,
|
||||
name=subscription.name,
|
||||
days=subscription.duration_days,
|
||||
price=subscription.price
|
||||
)
|
||||
# Show confirmation
|
||||
text = f"🔄 Продление подписки\n\n"
|
||||
text += f"📋 Подписка: {subscription.name}\n"
|
||||
text += f"💰 Стоимость: {subscription.price} руб.\n"
|
||||
text += f"⏱ Продлить на: {subscription.duration_days} дней\n"
|
||||
text += f"💳 Ваш баланс: {user.balance} руб.\n\n"
|
||||
text += f"После продления останется: {user.balance - subscription.price} руб."
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=extend_subscription_keyboard(user_sub_id, user.language)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error showing extend confirmation: {e}")
|
||||
logger.error(f"Error showing extend subscription: {e}")
|
||||
await callback.answer(t('error_occurred', user.language))
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_extend_"))
|
||||
async def confirm_extend_subscription(callback: CallbackQuery, db: Database, **kwargs):
|
||||
async def confirm_extend_subscription_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
"""Confirm subscription extension"""
|
||||
user = kwargs.get('user')
|
||||
api = kwargs.get('api')
|
||||
@@ -877,56 +915,87 @@ async def confirm_extend_subscription(callback: CallbackQuery, db: Database, **k
|
||||
try:
|
||||
user_sub_id = int(callback.data.split("_")[2])
|
||||
|
||||
# Get user subscription
|
||||
# Получаем все подписки пользователя и находим нужную
|
||||
user_subs = await db.get_user_subscriptions(user.telegram_id)
|
||||
user_sub = next((sub for sub in user_subs if sub.id == user_sub_id), None)
|
||||
|
||||
if not user_sub:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
await callback.answer(t('subscription_not_found', user.language))
|
||||
return
|
||||
|
||||
# Get subscription details
|
||||
subscription = await db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
await callback.answer("❌ Подписка не найдена")
|
||||
await callback.answer(t('subscription_not_found', user.language))
|
||||
return
|
||||
|
||||
# ИСПРАВЛЕНИЕ: Дополнительная проверка на тестовую подписку
|
||||
# Check if subscription is trial (can't extend trial)
|
||||
if subscription.is_trial:
|
||||
await callback.answer("❌ Тестовую подписку нельзя продлить")
|
||||
return
|
||||
|
||||
# Check balance again
|
||||
if user.balance < subscription.price:
|
||||
await callback.answer(t('insufficient_balance', user.language))
|
||||
await callback.answer("❌ Недостаточно средств")
|
||||
return
|
||||
|
||||
# Calculate new expiry date
|
||||
from datetime import timedelta
|
||||
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
|
||||
from datetime import datetime, timedelta
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Update subscription in RemnaWave if API is available
|
||||
# ИСПРАВЛЕНИЕ: Правильное вычисление новой даты истечения
|
||||
# Если подписка все еще активна, продлеваем от текущей даты истечения
|
||||
# Если истекла, продлеваем от текущего момента
|
||||
if user_sub.expires_at > now:
|
||||
new_expiry = user_sub.expires_at + timedelta(days=subscription.duration_days)
|
||||
else:
|
||||
new_expiry = now + timedelta(days=subscription.duration_days)
|
||||
|
||||
# ГЛАВНОЕ ИСПРАВЛЕНИЕ: Обновляем подписку в RemnaWave с правильными полями
|
||||
if api and user_sub.short_uuid:
|
||||
try:
|
||||
# Получаем информацию о пользователе по short_uuid
|
||||
logger.info(f"Updating RemnaWave subscription for shortUuid: {user_sub.short_uuid}")
|
||||
|
||||
# Сначала получаем полную информацию о пользователе
|
||||
# Сначала получаем информацию о пользователе по short_uuid
|
||||
remna_user_details = await api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if remna_user_details:
|
||||
user_uuid = remna_user_details.get('uuid')
|
||||
if user_uuid:
|
||||
# Обновляем пользователя в RemnaWave с новой датой истечения
|
||||
update_data = {
|
||||
'expireAt': new_expiry.isoformat() + 'Z'
|
||||
# ИСПРАВЛЕНИЕ: Используем правильное поле для даты истечения
|
||||
# В RemnaWave API может использоваться 'expireAt' или 'expiryTime'
|
||||
expiry_str = new_expiry.isoformat() + 'Z'
|
||||
|
||||
# Попробуем оба варианта поля даты истечения
|
||||
update_data_v1 = {
|
||||
'enable': True,
|
||||
'expireAt': expiry_str # Вариант 1
|
||||
}
|
||||
|
||||
logger.info(f"Updating user {user_uuid} with new expiry: {update_data['expireAt']}")
|
||||
result = await api.update_user(user_uuid, update_data)
|
||||
update_data_v2 = {
|
||||
'enable': True,
|
||||
'expiryTime': expiry_str # Вариант 2
|
||||
}
|
||||
|
||||
logger.info(f"Updating user {user_uuid} with new expiry: {expiry_str}")
|
||||
|
||||
# Пробуем первый вариант
|
||||
result = await api.update_user(user_uuid, update_data_v1)
|
||||
|
||||
if not result:
|
||||
# Если первый не сработал, пробуем второй
|
||||
logger.info("Trying alternative field name 'expiryTime'")
|
||||
result = await api.update_user(user_uuid, update_data_v2)
|
||||
|
||||
if result:
|
||||
logger.info(f"Successfully updated RemnaWave user expiry")
|
||||
logger.info(f"Successfully updated RemnaWave user expiry to {expiry_str}")
|
||||
else:
|
||||
logger.warning(f"Failed to update user in RemnaWave")
|
||||
logger.warning(f"Failed to update user in RemnaWave - trying direct API call")
|
||||
|
||||
# ДОПОЛНИТЕЛЬНАЯ ПОПЫТКА: Используем специальный метод для обновления даты истечения
|
||||
if hasattr(api, 'update_user_expiry'):
|
||||
result = await api.update_user_expiry(user_sub.short_uuid, expiry_str)
|
||||
if result:
|
||||
logger.info(f"Successfully updated expiry using update_user_expiry method")
|
||||
else:
|
||||
logger.warning(f"Could not get user UUID from RemnaWave response")
|
||||
else:
|
||||
@@ -934,10 +1003,11 @@ async def confirm_extend_subscription(callback: CallbackQuery, db: Database, **k
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update expiry in RemnaWave: {e}")
|
||||
# Продолжаем выполнение даже если обновление в RemnaWave не удалось
|
||||
# НЕ прерываем выполнение, продолжаем обновление в локальной БД
|
||||
|
||||
# Update local database
|
||||
user_sub.expires_at = new_expiry
|
||||
user_sub.is_active = True
|
||||
await db.update_user_subscription(user_sub)
|
||||
|
||||
# Deduct balance
|
||||
@@ -953,9 +1023,18 @@ async def confirm_extend_subscription(callback: CallbackQuery, db: Database, **k
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success_text = f"✅ Подписка успешно продлена!\n\n"
|
||||
success_text += f"📋 Подписка: {subscription.name}\n"
|
||||
success_text += f"📅 Новая дата истечения: {format_datetime(new_expiry, user.language)}\n"
|
||||
success_text += f"💰 Списано: {subscription.price} руб.\n"
|
||||
success_text += f"💳 Остаток на балансе: {user.balance} руб."
|
||||
|
||||
await callback.message.edit_text(
|
||||
t('subscription_extended', user.language),
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
success_text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📋 Мои подписки", callback_data="my_subscriptions")],
|
||||
[InlineKeyboardButton(text="🏠 Главное меню", callback_data="main_menu")]
|
||||
])
|
||||
)
|
||||
|
||||
log_user_action(user.telegram_id, "subscription_extended", f"Sub: {subscription.name}")
|
||||
@@ -967,6 +1046,7 @@ async def confirm_extend_subscription(callback: CallbackQuery, db: Database, **k
|
||||
reply_markup=main_menu_keyboard(user.language, user.is_admin)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("get_connection_"))
|
||||
async def get_connection_callback(callback: CallbackQuery, db: Database, **kwargs):
|
||||
"""Get connection link"""
|
||||
|
||||
+172
-2
@@ -1,6 +1,6 @@
|
||||
from database import Subscription
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
from translations import t
|
||||
|
||||
def language_keyboard() -> InlineKeyboardMarkup:
|
||||
@@ -177,9 +177,14 @@ def admin_menu_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
InlineKeyboardButton(text="💰 " + t('manage_balance', lang), callback_data="admin_balance"),
|
||||
InlineKeyboardButton(text="🎁 " + t('manage_promocodes', lang), callback_data="admin_promocodes")
|
||||
],
|
||||
# Третий ряд - коммуникации и аналитика
|
||||
# Третий ряд - коммуникации и система
|
||||
[
|
||||
InlineKeyboardButton(text="📨 " + t('send_message', lang), callback_data="admin_messages"),
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system") # НОВОЕ!
|
||||
],
|
||||
# Четвертый ряд - мониторинг и статистика
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor"),
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
# Назад
|
||||
@@ -335,3 +340,168 @@ def trial_subscription_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text=t('back', lang), callback_data="main_menu")]
|
||||
]
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def admin_monitor_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin monitor management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статус сервиса", callback_data="monitor_status")],
|
||||
[InlineKeyboardButton(text="🔄 Принудительная проверка", callback_data="monitor_force_check")],
|
||||
[InlineKeyboardButton(text="⚰️ Деактивировать истекшие", callback_data="monitor_deactivate_expired")],
|
||||
[InlineKeyboardButton(text="👤 Тест для пользователя", callback_data="monitor_test_user")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def admin_system_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful admin system management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Системная статистика", callback_data="system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Управление нодами", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Пользователи системы", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔄 Синхронизация с RemnaWave", callback_data="sync_remnawave")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API", callback_data="debug_api_comprehensive")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def system_stats_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""System statistics keyboard with refresh"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Обновить статистику", callback_data="refresh_system_stats")],
|
||||
[InlineKeyboardButton(text="🖥 Ноды", callback_data="nodes_management")],
|
||||
[InlineKeyboardButton(text="👥 Системные пользователи", callback_data="system_users")],
|
||||
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def nodes_management_keyboard(nodes: List[Dict], lang: str = 'ru', timestamp: int = None) -> InlineKeyboardMarkup:
|
||||
"""Improved nodes management keyboard"""
|
||||
buttons = []
|
||||
|
||||
if nodes:
|
||||
# Statistics row
|
||||
online_count = len([n for n in nodes if n.get('status') == 'online'])
|
||||
total_count = len(nodes)
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"📊 Ноды: {online_count}/{total_count} онлайн",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
# Show first 5 nodes with improved display
|
||||
for i, node in enumerate(nodes[:5]):
|
||||
status = node.get('status', 'unknown')
|
||||
|
||||
# Status emoji based on actual status
|
||||
if status == 'online':
|
||||
status_emoji = "🟢"
|
||||
elif status == 'disabled':
|
||||
status_emoji = "⚫"
|
||||
elif status == 'disconnected':
|
||||
status_emoji = "🔴"
|
||||
elif status == 'xray_stopped':
|
||||
status_emoji = "🟡"
|
||||
else:
|
||||
status_emoji = "⚪"
|
||||
|
||||
node_name = node.get('name', f'Node-{i+1}')
|
||||
node_id = node.get('id', node.get('uuid'))
|
||||
|
||||
# Truncate long names
|
||||
if len(node_name) > 20:
|
||||
display_name = node_name[:17] + "..."
|
||||
else:
|
||||
display_name = node_name
|
||||
|
||||
# CPU/Memory usage if available
|
||||
usage_info = ""
|
||||
if node.get('cpuUsage'):
|
||||
usage_info += f" CPU:{node['cpuUsage']:.0f}%"
|
||||
if node.get('memUsage'):
|
||||
usage_info += f" MEM:{node['memUsage']:.0f}%"
|
||||
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"{status_emoji} {display_name}{usage_info}",
|
||||
callback_data=f"node_details_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🔄",
|
||||
callback_data=f"restart_node_{node_id}"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="⚙️",
|
||||
callback_data=f"node_settings_{node_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if len(nodes) > 5:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"... и еще {len(nodes) - 5} нод",
|
||||
callback_data="show_all_nodes"
|
||||
)
|
||||
])
|
||||
else:
|
||||
buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text="❌ Ноды не найдены",
|
||||
callback_data="noop"
|
||||
)
|
||||
])
|
||||
|
||||
# Action buttons
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Перезагрузить все", callback_data="restart_all_nodes"),
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="nodes_statistics")
|
||||
])
|
||||
|
||||
# Refresh button
|
||||
refresh_callback = f"refresh_nodes_stats_{timestamp}" if timestamp else "refresh_nodes_stats"
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔄 Обновить", callback_data=refresh_callback)
|
||||
])
|
||||
|
||||
# Back button
|
||||
buttons.append([
|
||||
InlineKeyboardButton(text="🔙 Назад", callback_data="admin_system")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def system_users_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""System users management keyboard - ИСПРАВЛЕНО"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика пользователей", callback_data="users_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список всех пользователей", callback_data="list_all_system_users")],
|
||||
[InlineKeyboardButton(text="🔍 Поиск пользователя", callback_data="search_user_uuid")],
|
||||
[InlineKeyboardButton(text="🔍 Отладка API пользователей", callback_data="debug_users_api")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_system")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def bulk_operations_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Bulk operations keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="🔄 Сбросить трафик", callback_data="bulk_reset_traffic")],
|
||||
[InlineKeyboardButton(text="❌ Отключить пользователей", callback_data="bulk_disable_users")],
|
||||
[InlineKeyboardButton(text="✅ Включить пользователей", callback_data="bulk_enable_users")],
|
||||
[InlineKeyboardButton(text="🗑 Удалить пользователей", callback_data="bulk_delete_users")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="system_users")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
def confirm_restart_keyboard(node_id: str = None, lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Confirmation keyboard for node restart"""
|
||||
action = f"confirm_restart_node_{node_id}" if node_id else "confirm_restart_all_nodes"
|
||||
back_action = f"node_details_{node_id}" if node_id else "nodes_management"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="✅ Да, перезагрузить", callback_data=action),
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data=back_action)
|
||||
]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
@@ -11,6 +11,7 @@ from aiogram.enums import ParseMode
|
||||
from config import load_config
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from subscription_monitor import create_subscription_monitor
|
||||
from middlewares import DatabaseMiddleware, UserMiddleware, LoggingMiddleware, ThrottlingMiddleware, WorkflowDataMiddleware, BotMiddleware
|
||||
from handlers import router
|
||||
from admin_handlers import admin_router
|
||||
@@ -26,125 +27,213 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def main():
|
||||
"""Main function"""
|
||||
try:
|
||||
class BotApplication:
|
||||
"""Main bot application class"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = None
|
||||
self.db = None
|
||||
self.api = None
|
||||
self.bot = None
|
||||
self.dp = None
|
||||
self.monitor_service = None
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize all components"""
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
self.config = load_config()
|
||||
|
||||
# Validate required environment variables
|
||||
if not config.BOT_TOKEN:
|
||||
if not self.config.BOT_TOKEN:
|
||||
logger.error("BOT_TOKEN is required")
|
||||
return
|
||||
raise ValueError("BOT_TOKEN is required")
|
||||
|
||||
if not config.REMNAWAVE_URL or not config.REMNAWAVE_TOKEN:
|
||||
if not self.config.REMNAWAVE_URL or not self.config.REMNAWAVE_TOKEN:
|
||||
logger.error("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
return
|
||||
raise ValueError("REMNAWAVE_URL and REMNAWAVE_TOKEN are required")
|
||||
|
||||
logger.info("Starting RemnaWave Bot...")
|
||||
logger.info(f"RemnaWave URL: {config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {config.ADMIN_IDS}")
|
||||
logger.info(f"RemnaWave URL: {self.config.REMNAWAVE_URL}")
|
||||
logger.info(f"Admin IDs: {self.config.ADMIN_IDS}")
|
||||
|
||||
# Initialize database
|
||||
db = Database(config.DATABASE_URL)
|
||||
self.db = Database(self.config.DATABASE_URL)
|
||||
await self._init_database()
|
||||
|
||||
# Try to initialize database with retry logic
|
||||
# Initialize RemnaWave API
|
||||
self.api = RemnaWaveAPI(
|
||||
self.config.REMNAWAVE_URL,
|
||||
self.config.REMNAWAVE_TOKEN,
|
||||
self.config.SUBSCRIPTION_BASE_URL
|
||||
)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
# Test API connection (optional - don't fail if it doesn't work)
|
||||
await self._test_api_connection()
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
self.bot = Bot(
|
||||
token=self.config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
# Test bot token
|
||||
await self._test_bot_token()
|
||||
|
||||
# Initialize dispatcher
|
||||
self._setup_dispatcher()
|
||||
|
||||
# Initialize subscription monitor service
|
||||
await self._init_monitor_service()
|
||||
|
||||
async def _init_database(self):
|
||||
"""Initialize database with retry logic"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await db.init_db()
|
||||
await self.db.init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization attempt {attempt + 1} failed: {e}")
|
||||
if attempt == max_retries - 1:
|
||||
logger.error("Failed to initialize database after all retries")
|
||||
return
|
||||
raise
|
||||
await asyncio.sleep(2) # Wait before retry
|
||||
|
||||
# Initialize RemnaWave API
|
||||
api = RemnaWaveAPI(config.REMNAWAVE_URL, config.REMNAWAVE_TOKEN, config.SUBSCRIPTION_BASE_URL)
|
||||
logger.info("RemnaWave API initialized")
|
||||
|
||||
# Test API connection (optional - don't fail if it doesn't work)
|
||||
|
||||
async def _test_api_connection(self):
|
||||
"""Test API connection"""
|
||||
try:
|
||||
system_stats = await api.get_system_stats()
|
||||
system_stats = await self.api.get_system_stats()
|
||||
if system_stats:
|
||||
logger.info("RemnaWave API connection successful")
|
||||
else:
|
||||
logger.warning("RemnaWave API connection test failed - continuing anyway")
|
||||
except Exception as e:
|
||||
logger.warning(f"RemnaWave API connection error: {e} - continuing anyway")
|
||||
|
||||
# Initialize bot and dispatcher
|
||||
bot = Bot(
|
||||
token=config.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
)
|
||||
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
|
||||
# Store config, api, and db in dispatcher workflow_data for access in handlers
|
||||
dp.workflow_data.update({
|
||||
"config": config,
|
||||
"api": api,
|
||||
"db": db
|
||||
})
|
||||
|
||||
# Setup middlewares in correct order
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
dp.message.middleware(WorkflowDataMiddleware())
|
||||
dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
dp.message.middleware(BotMiddleware(bot))
|
||||
dp.callback_query.middleware(BotMiddleware(bot))
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware(db))
|
||||
dp.callback_query.middleware(DatabaseMiddleware(db))
|
||||
|
||||
dp.message.middleware(UserMiddleware(db, config))
|
||||
dp.callback_query.middleware(UserMiddleware(db, config))
|
||||
|
||||
# Register routers
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
|
||||
# Setup shutdown handler
|
||||
async def on_shutdown():
|
||||
logger.info("Shutting down bot...")
|
||||
try:
|
||||
await api.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
try:
|
||||
await db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
# Test bot token before starting
|
||||
async def _test_bot_token(self):
|
||||
"""Test bot token before starting"""
|
||||
try:
|
||||
bot_info = await bot.get_me()
|
||||
bot_info = await self.bot.get_me()
|
||||
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid bot token or network error: {e}")
|
||||
return
|
||||
raise
|
||||
|
||||
def _setup_dispatcher(self):
|
||||
"""Setup dispatcher with middlewares and routers"""
|
||||
storage = MemoryStorage()
|
||||
self.dp = Dispatcher(storage=storage)
|
||||
|
||||
# Start polling
|
||||
# Store config, api, db, and monitor_service in dispatcher workflow_data for access in handlers
|
||||
self.dp.workflow_data.update({
|
||||
"config": self.config,
|
||||
"api": self.api,
|
||||
"db": self.db,
|
||||
"monitor_service": None # Will be updated after monitor service is created
|
||||
})
|
||||
|
||||
# Setup middlewares in correct order
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
self.dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
self.dp.message.middleware(WorkflowDataMiddleware())
|
||||
self.dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
self.dp.message.middleware(BotMiddleware(self.bot))
|
||||
self.dp.callback_query.middleware(BotMiddleware(self.bot))
|
||||
|
||||
self.dp.message.middleware(DatabaseMiddleware(self.db))
|
||||
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
|
||||
|
||||
self.dp.message.middleware(UserMiddleware(self.db, self.config))
|
||||
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
|
||||
|
||||
# Register routers
|
||||
self.dp.include_router(router)
|
||||
self.dp.include_router(admin_router)
|
||||
|
||||
async def _init_monitor_service(self):
|
||||
"""Initialize subscription monitor service"""
|
||||
try:
|
||||
self.monitor_service = await create_subscription_monitor(
|
||||
self.bot, self.db, self.config, self.api
|
||||
)
|
||||
|
||||
# Update workflow_data with monitor service
|
||||
self.dp.workflow_data["monitor_service"] = self.monitor_service
|
||||
|
||||
# Start the monitor service
|
||||
await self.monitor_service.start()
|
||||
logger.info("Subscription monitor service started successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize monitor service: {e}")
|
||||
# Don't fail the entire application if monitor service fails
|
||||
logger.warning("Continuing without monitor service")
|
||||
self.monitor_service = None
|
||||
|
||||
async def start(self):
|
||||
"""Start bot polling"""
|
||||
logger.info("Bot polling started successfully")
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
await self.dp.start_polling(self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during polling: {e}")
|
||||
raise
|
||||
finally:
|
||||
await on_shutdown()
|
||||
await self.shutdown()
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown all services"""
|
||||
logger.info("Shutting down bot...")
|
||||
|
||||
# Stop monitor service first
|
||||
if self.monitor_service:
|
||||
try:
|
||||
await self.monitor_service.stop()
|
||||
logger.info("Monitor service stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping monitor service: {e}")
|
||||
|
||||
# Close API connection
|
||||
if self.api:
|
||||
try:
|
||||
await self.api.close()
|
||||
logger.info("API connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing API: {e}")
|
||||
|
||||
# Close database connection
|
||||
if self.db:
|
||||
try:
|
||||
await self.db.close()
|
||||
logger.info("Database connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
# Close bot session
|
||||
if self.bot:
|
||||
try:
|
||||
await self.bot.session.close()
|
||||
logger.info("Bot session closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing bot session: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
async def main():
|
||||
"""Main function"""
|
||||
app = None
|
||||
try:
|
||||
app = BotApplication()
|
||||
await app.initialize()
|
||||
await app.start()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user (Ctrl+C)")
|
||||
except Exception as e:
|
||||
@@ -152,6 +241,9 @@ async def main():
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
if app:
|
||||
await app.shutdown()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
|
||||
+860
-226
File diff suppressed because it is too large
Load Diff
+38
-8
@@ -1,8 +1,38 @@
|
||||
aiogram==3.4.1
|
||||
aiohttp==3.9.3
|
||||
asyncpg==0.29.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
alembic==1.13.1
|
||||
python-dotenv==1.0.1
|
||||
aiosqlite==0.19.0
|
||||
psycopg2-binary==2.9.9
|
||||
# Telegram Bot Framework
|
||||
aiogram>=3.4.0
|
||||
|
||||
# Database
|
||||
SQLAlchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
|
||||
# PostgreSQL driver
|
||||
asyncpg>=0.28.0
|
||||
psycopg2-binary>=2.9.0
|
||||
|
||||
# SQLite driver (fallback)
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# HTTP Client
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Redis (optional)
|
||||
redis>=4.5.0
|
||||
aioredis>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Logging and monitoring
|
||||
structlog>=23.0.0
|
||||
|
||||
# Date and time
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
# Cryptography
|
||||
cryptography>=42.0.0
|
||||
|
||||
# JSON handling
|
||||
orjson>=3.9.0
|
||||
|
||||
@@ -200,7 +200,7 @@ User=$CURRENT_USER
|
||||
WorkingDirectory=$CURRENT_DIR
|
||||
Environment=PATH=$CURRENT_DIR/$VENV_DIR/bin
|
||||
EnvironmentFile=$CURRENT_DIR/.env
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python $BOT_FILE
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python3 $BOT_FILE
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
@@ -215,4 +215,4 @@ fi
|
||||
|
||||
msg completed
|
||||
msg start_prompt
|
||||
python "$BOT_FILE"
|
||||
python3 "$BOT_FILE"
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
"""
|
||||
Subscription Monitor Service
|
||||
Сервис для мониторинга подписок, уведомлений пользователей и предложений продления
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from dataclasses import dataclass
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from database import Database, UserSubscription, Subscription, User
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
from translations import t
|
||||
from keyboards import extend_subscription_keyboard, main_menu_keyboard
|
||||
from utils import format_datetime, log_user_action
|
||||
from config import Config
|
||||
|
||||
# Настройка логирования
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class NotificationResult:
|
||||
"""Результат отправки уведомления"""
|
||||
success: bool
|
||||
user_id: int
|
||||
message: str
|
||||
error: Optional[str] = None
|
||||
|
||||
class SubscriptionMonitorService:
|
||||
"""Сервис мониторинга подписок"""
|
||||
|
||||
def __init__(self, bot: Bot, db: Database, config: Config, api: Optional[RemnaWaveAPI] = None):
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.api = api
|
||||
self.is_running = False
|
||||
self._monitor_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Настройки уведомлений из конфига
|
||||
self.WARNING_DAYS = config.MONITOR_WARNING_DAYS # За сколько дней предупреждать
|
||||
self.CHECK_INTERVAL = config.MONITOR_CHECK_INTERVAL # Интервал проверки (в секундах)
|
||||
self.DAILY_CHECK_HOUR = config.MONITOR_DAILY_CHECK_HOUR # В какой час дня делать основную
|
||||
|
||||
async def start(self):
|
||||
"""Запуск сервиса мониторинга"""
|
||||
if self.is_running:
|
||||
logger.warning("Subscription monitor service is already running")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
self._monitor_task = asyncio.create_task(self._monitor_loop())
|
||||
logger.info("Subscription monitor service started")
|
||||
|
||||
async def stop(self):
|
||||
"""Остановка сервиса мониторинга"""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self.is_running = False
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
try:
|
||||
await self._monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("Subscription monitor service stopped")
|
||||
|
||||
async def _monitor_loop(self):
|
||||
"""Основной цикл мониторинга"""
|
||||
logger.info(f"Starting monitor loop with {self.CHECK_INTERVAL}s interval")
|
||||
|
||||
while self.is_running:
|
||||
try:
|
||||
current_time = datetime.utcnow()
|
||||
|
||||
# Основная проверка раз в день в определенное время
|
||||
if current_time.hour == self.DAILY_CHECK_HOUR:
|
||||
await self._daily_check()
|
||||
|
||||
# Дополнительная проверка каждый час для критических случаев
|
||||
await self._hourly_check()
|
||||
|
||||
# Ожидание до следующей проверки
|
||||
await asyncio.sleep(self.CHECK_INTERVAL)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Monitor loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in monitor loop: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
# Продолжаем работу даже при ошибках
|
||||
await asyncio.sleep(60) # Короткая пауза при ошибке
|
||||
|
||||
async def _daily_check(self):
|
||||
"""Ежедневная проверка всех подписок"""
|
||||
logger.info("Starting daily subscription check")
|
||||
|
||||
try:
|
||||
# Получаем все активные подписки пользователей
|
||||
all_users = await self.db.get_all_users()
|
||||
total_notifications = 0
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
active_subs = [sub for sub in user_subs if sub.is_active]
|
||||
|
||||
for user_sub in active_subs:
|
||||
# Проверяем каждую подписку
|
||||
notification_sent = await self._check_and_notify_subscription(user, user_sub)
|
||||
if notification_sent:
|
||||
total_notifications += 1
|
||||
|
||||
# Небольшая пауза между уведомлениями
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking subscriptions for user {user.telegram_id}: {e}")
|
||||
continue
|
||||
|
||||
logger.info(f"Daily check completed. Sent {total_notifications} notifications")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in daily check: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
async def _hourly_check(self):
|
||||
"""Часовая проверка критических подписок (истекают сегодня)"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
tomorrow = now + timedelta(days=1)
|
||||
|
||||
# Получаем подписки, которые истекают в ближайшие 24 часа
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
if (user_sub.is_active and
|
||||
user_sub.expires_at <= tomorrow and
|
||||
user_sub.expires_at > now):
|
||||
|
||||
await self._check_and_notify_subscription(user, user_sub, urgent=True)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hourly check for user {user.telegram_id}: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hourly check: {e}")
|
||||
|
||||
async def _check_and_notify_subscription(self, user: User, user_sub: UserSubscription, urgent: bool = False) -> bool:
|
||||
"""
|
||||
Проверить подписку и отправить уведомление если нужно
|
||||
Returns: True если уведомление было отправлено
|
||||
"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
days_until_expiry = (user_sub.expires_at - now).days
|
||||
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
|
||||
|
||||
# Получаем информацию о подписке
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if not subscription:
|
||||
logger.warning(f"Subscription {user_sub.subscription_id} not found")
|
||||
return False
|
||||
|
||||
notification_type = None
|
||||
|
||||
# Определяем тип уведомления
|
||||
if user_sub.expires_at <= now:
|
||||
# Подписка истекла
|
||||
notification_type = "expired"
|
||||
elif days_until_expiry <= 0 and hours_until_expiry <= 24:
|
||||
# Истекает сегодня
|
||||
notification_type = "expires_today"
|
||||
elif days_until_expiry == 1:
|
||||
# Истекает завтра
|
||||
notification_type = "expires_tomorrow"
|
||||
elif days_until_expiry == self.WARNING_DAYS:
|
||||
# Предупреждение за 2 дня
|
||||
notification_type = "warning"
|
||||
elif urgent and days_until_expiry <= 1:
|
||||
# Срочное уведомление
|
||||
notification_type = "urgent"
|
||||
|
||||
if notification_type:
|
||||
return await self._send_notification(user, user_sub, subscription, notification_type)
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking subscription {user_sub.id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_notification(self, user: User, user_sub: UserSubscription,
|
||||
subscription: Subscription, notification_type: str) -> bool:
|
||||
"""Отправить уведомление пользователю"""
|
||||
try:
|
||||
# Проверяем, не является ли подписка тестовой (для тестовых другая логика)
|
||||
if subscription.is_trial and notification_type in ["warning", "expires_tomorrow"]:
|
||||
# Для тестовых подписок не предлагаем продление
|
||||
return await self._send_trial_expiry_notification(user, user_sub, subscription, notification_type)
|
||||
|
||||
# Формируем текст уведомления
|
||||
message_text = self._format_notification_message(user, user_sub, subscription, notification_type)
|
||||
|
||||
# Формируем клавиатуру
|
||||
keyboard = self._create_notification_keyboard(user, user_sub, subscription, notification_type)
|
||||
|
||||
# Отправляем уведомление
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
# Логируем действие
|
||||
log_user_action(user.telegram_id, f"notification_sent_{notification_type}", f"Sub: {subscription.name}")
|
||||
|
||||
logger.info(f"Sent {notification_type} notification to user {user.telegram_id} for subscription {subscription.name}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending notification to user {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
async def _send_trial_expiry_notification(self, user: User, user_sub: UserSubscription,
|
||||
subscription: Subscription, notification_type: str) -> bool:
|
||||
"""Отправить уведомление об истечении тестовой подписки"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
days_until_expiry = (user_sub.expires_at - now).days
|
||||
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
|
||||
|
||||
if notification_type == "expires_today" or hours_until_expiry <= 24:
|
||||
message_text = (
|
||||
f"⏰ *Ваша тестовая подписка истекает сегодня!*\n\n"
|
||||
f"📋 Подписка: *{subscription.name}*\n"
|
||||
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
|
||||
f"💡 Чтобы продолжить пользоваться сервисом, приобретите полную подписку!"
|
||||
)
|
||||
elif notification_type == "expires_tomorrow" or days_until_expiry == 1:
|
||||
message_text = (
|
||||
f"⚠️ *Ваша тестовая подписка истекает завтра!*\n\n"
|
||||
f"📋 Подписка: *{subscription.name}*\n"
|
||||
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n\n"
|
||||
f"💡 Не забудьте приобрести полную подписку, чтобы продолжить пользоваться сервисом!"
|
||||
)
|
||||
else:
|
||||
return False
|
||||
|
||||
# Клавиатура для тестовой подписки
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text="💳 Купить подписку",
|
||||
callback_data="buy_subscription"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="💰 Пополнить баланс",
|
||||
callback_data="topup_balance"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="main_menu"
|
||||
)]
|
||||
])
|
||||
|
||||
await self.bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='Markdown'
|
||||
)
|
||||
|
||||
log_user_action(user.telegram_id, f"trial_notification_sent_{notification_type}", f"Sub: {subscription.name}")
|
||||
logger.info(f"Sent trial {notification_type} notification to user {user.telegram_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending trial notification to user {user.telegram_id}: {e}")
|
||||
return False
|
||||
|
||||
def _format_notification_message(self, user: User, user_sub: UserSubscription,
|
||||
subscription: Subscription, notification_type: str) -> str:
|
||||
"""Форматировать текст уведомления"""
|
||||
now = datetime.utcnow()
|
||||
days_until_expiry = (user_sub.expires_at - now).days
|
||||
hours_until_expiry = (user_sub.expires_at - now).total_seconds() / 3600
|
||||
|
||||
base_info = (
|
||||
f"📋 Подписка: *{subscription.name}*\n"
|
||||
f"📅 Истекает: *{format_datetime(user_sub.expires_at, user.language)}*\n"
|
||||
f"💰 Цена продления: *{subscription.price} руб.*"
|
||||
)
|
||||
|
||||
if notification_type == "expired":
|
||||
return (
|
||||
f"❌ *Ваша подписка истекла!*\n\n"
|
||||
f"{base_info}\n\n"
|
||||
f"🔄 Продлите подписку, чтобы продолжить пользоваться сервисом."
|
||||
)
|
||||
elif notification_type == "expires_today" or notification_type == "urgent":
|
||||
return (
|
||||
f"⏰ *Ваша подписка истекает сегодня!*\n\n"
|
||||
f"{base_info}\n"
|
||||
f"⏳ Осталось: *{int(hours_until_expiry)} часов*\n\n"
|
||||
f"🔄 Продлите подписку прямо сейчас!"
|
||||
)
|
||||
elif notification_type == "expires_tomorrow":
|
||||
return (
|
||||
f"⚠️ *Ваша подписка истекает завтра!*\n\n"
|
||||
f"{base_info}\n\n"
|
||||
f"🔄 Рекомендуем продлить подписку заранее."
|
||||
)
|
||||
elif notification_type == "warning":
|
||||
return (
|
||||
f"📢 *Напоминание о подписке*\n\n"
|
||||
f"{base_info}\n"
|
||||
f"⏳ Осталось: *{days_until_expiry} дней*\n\n"
|
||||
f"💡 Не забудьте продлить подписку вовремя!"
|
||||
)
|
||||
else:
|
||||
return f"🔔 Уведомление о подписке *{subscription.name}*"
|
||||
|
||||
def _create_notification_keyboard(self, user: User, user_sub: UserSubscription,
|
||||
subscription: Subscription, notification_type: str) -> InlineKeyboardMarkup:
|
||||
"""Создать клавиатуру для уведомления"""
|
||||
buttons = []
|
||||
|
||||
# Кнопка продления (только для не-тестовых подписок)
|
||||
if not subscription.is_trial:
|
||||
if user.balance >= subscription.price:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"🔄 Продлить за {subscription.price} руб.",
|
||||
callback_data=f"extend_sub_{user_sub.id}"
|
||||
)])
|
||||
else:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"💰 Пополнить баланс (нужно {subscription.price - user.balance} руб.)",
|
||||
callback_data="topup_balance"
|
||||
)])
|
||||
|
||||
# Кнопка покупки новой подписки
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="💳 Купить подписку",
|
||||
callback_data="buy_subscription"
|
||||
)])
|
||||
|
||||
# Кнопка "Мои подписки"
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="📋 Мои подписки",
|
||||
callback_data="my_subscriptions"
|
||||
)])
|
||||
|
||||
# Кнопка главного меню
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="🏠 Главное меню",
|
||||
callback_data="main_menu"
|
||||
)])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
async def check_single_user(self, user_id: int) -> List[NotificationResult]:
|
||||
"""Проверить подписки конкретного пользователя (для тестирования)"""
|
||||
results = []
|
||||
|
||||
try:
|
||||
user = await self.db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
return [NotificationResult(False, user_id, "User not found")]
|
||||
|
||||
user_subs = await self.db.get_user_subscriptions(user_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
if user_sub.is_active:
|
||||
try:
|
||||
sent = await self._check_and_notify_subscription(user, user_sub)
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
sub_name = subscription.name if subscription else "Unknown"
|
||||
|
||||
results.append(NotificationResult(
|
||||
success=sent,
|
||||
user_id=user_id,
|
||||
message=f"Subscription: {sub_name}, Sent: {sent}"
|
||||
))
|
||||
except Exception as e:
|
||||
results.append(NotificationResult(
|
||||
success=False,
|
||||
user_id=user_id,
|
||||
message=f"Error checking subscription {user_sub.id}",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
results.append(NotificationResult(
|
||||
success=False,
|
||||
user_id=user_id,
|
||||
message="Error checking user",
|
||||
error=str(e)
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
"""Получить статус сервиса"""
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"check_interval": self.CHECK_INTERVAL,
|
||||
"daily_check_hour": self.DAILY_CHECK_HOUR,
|
||||
"warning_days": self.WARNING_DAYS,
|
||||
"last_check": datetime.utcnow().isoformat() if self.is_running else None
|
||||
}
|
||||
|
||||
async def force_daily_check(self):
|
||||
"""Принудительно запустить ежедневную проверку"""
|
||||
logger.info("Force starting daily check")
|
||||
await self._daily_check()
|
||||
|
||||
async def deactivate_expired_subscriptions(self):
|
||||
"""Деактивировать истекшие подписки"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
all_users = await self.db.get_all_users()
|
||||
deactivated_count = 0
|
||||
|
||||
for user in all_users:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
if user_sub.is_active and user_sub.expires_at <= now:
|
||||
# Деактивируем подписку
|
||||
user_sub.is_active = False
|
||||
await self.db.update_user_subscription(user_sub)
|
||||
|
||||
# Деактивируем в RemnaWave если API доступно
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
remna_user_details = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if remna_user_details:
|
||||
user_uuid = remna_user_details.get('uuid')
|
||||
if user_uuid:
|
||||
# Блокируем пользователя в RemnaWave
|
||||
await self.api.update_user(user_uuid, {"enable": False})
|
||||
logger.info(f"Disabled user {user_uuid} in RemnaWave")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to disable user in RemnaWave: {e}")
|
||||
|
||||
deactivated_count += 1
|
||||
log_user_action(user.telegram_id, "subscription_expired", f"SubID: {user_sub.id}")
|
||||
|
||||
logger.info(f"Deactivated {deactivated_count} expired subscriptions")
|
||||
return deactivated_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deactivating expired subscriptions: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
# Функция для инициализации и запуска сервиса
|
||||
async def create_subscription_monitor(bot: Bot, db: Database, config: Config,
|
||||
api: Optional[RemnaWaveAPI] = None) -> SubscriptionMonitorService:
|
||||
"""Создать и настроить сервис мониторинга подписок"""
|
||||
service = SubscriptionMonitorService(bot, db, config, api)
|
||||
return service
|
||||
|
||||
|
||||
# Пример использования в основном файле бота
|
||||
"""
|
||||
from subscription_monitor import create_subscription_monitor
|
||||
|
||||
async def main():
|
||||
# Инициализация бота, базы данных, конфига
|
||||
bot = Bot(token=config.BOT_TOKEN)
|
||||
db = Database(config.DATABASE_URL)
|
||||
api = RemnaWaveAPI(config.REMNAWAVE_API_URL, config.REMNAWAVE_API_KEY)
|
||||
|
||||
# Создание и запуск сервиса мониторинга
|
||||
monitor_service = await create_subscription_monitor(bot, db, config, api)
|
||||
await monitor_service.start()
|
||||
|
||||
try:
|
||||
# Запуск бота
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
# Остановка сервиса при завершении
|
||||
await monitor_service.stop()
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
"""
|
||||
+25
-2
@@ -16,7 +16,9 @@ TRANSLATIONS = {
|
||||
'trial_not_available': '❌ Тестовая подписка недоступна',
|
||||
'trial_success': '🎉 Тестовая подписка успешно активирована!\n\nТеперь вы можете найти её в разделе "Мои подписки".',
|
||||
'trial_error': '❌ Ошибка при создании тестовой подписки',
|
||||
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nТариф действует 3 дня!\n\nОграничение трафика - 2гб!',
|
||||
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nНа тарифе действует ограничение в 3 дня\n\nОграничение по трафику - 2гб',
|
||||
'subscriptions_list': '📋 Список подписок в продаже:',
|
||||
|
||||
|
||||
# Balance menu
|
||||
'your_balance': '💰 Ваш баланс: {balance:.2f} руб.',
|
||||
@@ -25,6 +27,17 @@ TRANSLATIONS = {
|
||||
'topup_card': 'Пополнение картой',
|
||||
'topup_support': 'Через саппорт',
|
||||
'back': 'Назад',
|
||||
'system_management': 'Управление системой',
|
||||
'nodes_management': 'Управление нодами',
|
||||
'system_users': 'Системные пользователи',
|
||||
'system_statistics': 'Системная статистика',
|
||||
'restart_nodes': 'Перезагрузить ноды',
|
||||
'bulk_operations': 'Массовые операции',
|
||||
'search_user': 'Поиск пользователя',
|
||||
'user_details': 'Детали пользователя',
|
||||
'reset_traffic': 'Сбросить трафик',
|
||||
'disable_user': 'Отключить пользователя',
|
||||
'enable_user': 'Включить пользователя',
|
||||
|
||||
'send_message': 'Отправить сообщение',
|
||||
'send_to_user': 'Отправить пользователю',
|
||||
@@ -145,7 +158,17 @@ TRANSLATIONS = {
|
||||
'enter_user_id_message': 'Enter user id message',
|
||||
'enter_message_text': 'Enter message text',
|
||||
'trial_subscription': 'Trial subscription',
|
||||
|
||||
'system_management': 'System Management',
|
||||
'nodes_management': 'Nodes Management',
|
||||
'system_users': 'System Users',
|
||||
'system_statistics': 'System Statistics',
|
||||
'restart_nodes': 'Restart Nodes',
|
||||
'bulk_operations': 'Bulk Operations',
|
||||
'search_user': 'Search User',
|
||||
'user_details': 'User Details',
|
||||
'reset_traffic': 'Reset Traffic',
|
||||
'disable_user': 'Disable User',
|
||||
'enable_user': 'Enable User',
|
||||
|
||||
# Balance menu
|
||||
'your_balance': '💰 Your balance: ${balance:.2f}',
|
||||
|
||||
@@ -209,9 +209,56 @@ def get_subscription_connection_url(base_url: str, short_uuid: str) -> str:
|
||||
"""Generate subscription connection URL"""
|
||||
return f"{base_url.rstrip('/')}/api/sub/{short_uuid}"
|
||||
|
||||
def log_user_action(user_id: int, action: str, details: str = ""):
|
||||
"""Log user action"""
|
||||
logger.info(f"User {user_id} - {action}: {details}")
|
||||
def log_user_action(telegram_id: int, action: str, details: str = None):
|
||||
"""Log user action for audit"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
log_message = f"Admin action by {telegram_id}: {action}"
|
||||
if details:
|
||||
log_message += f" - {details}"
|
||||
|
||||
logger.info(log_message)
|
||||
|
||||
def format_subscription_status(expires_at: datetime, lang: str = 'ru') -> str:
|
||||
"""Format subscription status with emoji"""
|
||||
now = datetime.utcnow()
|
||||
|
||||
if expires_at < now:
|
||||
return "❌ Истекла" if lang == 'ru' else "❌ Expired"
|
||||
|
||||
days_left = (expires_at - now).days
|
||||
|
||||
if days_left == 0:
|
||||
return "⚠️ Истекает сегодня" if lang == 'ru' else "⚠️ Expires today"
|
||||
elif days_left == 1:
|
||||
return "⚠️ Истекает завтра" if lang == 'ru' else "⚠️ Expires tomorrow"
|
||||
elif days_left <= 3:
|
||||
return f"🔶 Осталось {days_left} дней" if lang == 'ru' else f"🔶 {days_left} days left"
|
||||
else:
|
||||
return f"✅ Активна ({days_left} дней)" if lang == 'ru' else f"✅ Active ({days_left} days)"
|
||||
|
||||
def format_monitor_notification_type(notification_type: str, lang: str = 'ru') -> str:
|
||||
"""Format notification type for display"""
|
||||
type_map = {
|
||||
'expired': 'Истекла' if lang == 'ru' else 'Expired',
|
||||
'expires_today': 'Истекает сегодня' if lang == 'ru' else 'Expires today',
|
||||
'expires_tomorrow': 'Истекает завтра' if lang == 'ru' else 'Expires tomorrow',
|
||||
'warning': 'Предупреждение' if lang == 'ru' else 'Warning',
|
||||
'urgent': 'Срочно' if lang == 'ru' else 'Urgent'
|
||||
}
|
||||
return type_map.get(notification_type, notification_type)
|
||||
|
||||
def calculate_days_until_expiry(expires_at: datetime) -> int:
|
||||
"""Calculate days until expiry"""
|
||||
now = datetime.utcnow()
|
||||
delta = expires_at - now
|
||||
return max(0, delta.days)
|
||||
|
||||
def is_subscription_expiring_soon(expires_at: datetime, warning_days: int = 2) -> bool:
|
||||
"""Check if subscription is expiring soon"""
|
||||
days_left = calculate_days_until_expiry(expires_at)
|
||||
return days_left <= warning_days
|
||||
|
||||
class States:
|
||||
"""State constants for FSM"""
|
||||
|
||||
Reference in New Issue
Block a user