Compare commits

...

69 Commits

Author SHA1 Message Date
Fr1ngg 6d9a5fb578 Update README.md 2025-08-07 08:23:07 +03:00
Fr1ngg 44d19245c4 Update admin_handlers.py 2025-08-07 08:19:54 +03:00
Fr1ngg ce36c268cc Update main.py 2025-08-07 07:53:09 +03:00
Fr1ngg 1357b07921 Update middlewares.py 2025-08-07 07:52:49 +03:00
Fr1ngg d4bfdcb749 Update config.py 2025-08-07 07:52:33 +03:00
Fr1ngg 39621706c8 Update subscription_monitor.py 2025-08-07 07:52:01 +03:00
Fr1ngg 4684e7a1e5 Update utils.py 2025-08-07 07:51:36 +03:00
Fr1ngg 16bb3c8ad3 Update translations.py 2025-08-07 07:51:15 +03:00
Fr1ngg 16fbdcc655 Create api_error_handlers.py 2025-08-07 07:50:56 +03:00
Fr1ngg 416d268352 Update remnawave_api.py 2025-08-07 07:50:30 +03:00
Fr1ngg 44fb88f368 Update keyboards.py 2025-08-07 07:50:06 +03:00
Fr1ngg f050c12253 Update database.py 2025-08-07 07:49:48 +03:00
Fr1ngg 82486d121e Update handlers.py 2025-08-07 07:49:25 +03:00
Fr1ngg 2bf382dbac Update handlers.py 2025-08-07 07:49:06 +03:00
Fr1ngg ab562e0df0 Update handlers.py 2025-08-07 07:48:48 +03:00
Fr1ngg 02e8fba9d0 Update admin_handlers.py 2025-08-07 07:48:21 +03:00
Fr1ngg 530df33ba4 Update README.md 2025-08-05 15:41:41 +03:00
Fr1ngg 8b2e8cb681 Update README.md 2025-08-05 15:40:27 +03:00
Fr1ngg 2cb8c937e1 Update README.md 2025-08-05 15:39:33 +03:00
Fr1ngg ea5c08fb58 Update README.md 2025-08-05 15:35:42 +03:00
Fr1ngg 2507168916 Update README.md 2025-08-05 15:31:45 +03:00
Fr1ngg ec6e08f0d3 Update middlewares.py 2025-08-05 15:25:23 +03:00
Fr1ngg ceee7cc977 Update remnawave_api.py 2025-08-05 15:25:06 +03:00
Fr1ngg ece05cd9e9 Update main.py 2025-08-05 15:24:35 +03:00
Fr1ngg 2345016aa3 Update main.py 2025-08-05 15:24:16 +03:00
Fr1ngg d349352812 Update utils.py 2025-08-05 15:23:57 +03:00
Fr1ngg e002ca2cb2 Update handlers.py 2025-08-05 15:23:35 +03:00
Fr1ngg 849afb4aa4 Update admin_handlers.py 2025-08-05 15:23:15 +03:00
Fr1ngg 943628e6fe Update keyboards.py 2025-08-05 15:22:50 +03:00
Fr1ngg 6621de95c0 Update database.py
Fix admin_history_payments
2025-08-05 15:22:15 +03:00
Fr1ngg 87797153be Update README.md 2025-08-05 07:37:15 +03:00
Fr1ngg 4e833f9b8c Create docker-build.yml 2025-08-05 07:30:52 +03:00
Fr1ngg 560a2c362f Delete .github/workflows/build.yml 2025-08-05 07:22:59 +03:00
Fr1ngg 71e9e1ebe2 Create build.yml 2025-08-05 07:17:51 +03:00
Fr1ngg 73ed24aecf Update README.md 2025-08-05 05:41:43 +03:00
Fr1ngg 09eff6cf8e Update README.md 2025-08-05 05:38:04 +03:00
Fr1ngg ae7a36523b Create Dockerfile 2025-08-05 05:32:03 +03:00
Fr1ngg 48a111da76 Create Makefile 2025-08-05 05:31:27 +03:00
Fr1ngg 0f94a7cdcd Update requirements.txt 2025-08-05 05:30:43 +03:00
Fr1ngg 79761b16bf Update docker-compose.yml 2025-08-05 05:30:01 +03:00
Fr1ngg 7235caae0b Delete Dockerfile 2025-08-05 04:26:57 +03:00
Fr1ngg 2f294cdd21 Update run.sh 2025-08-05 04:23:26 +03:00
Fr1ngg 33a124c1d2 Update run.sh 2025-08-05 04:23:11 +03:00
Fr1ngg 91ed42ff08 Update README.md 2025-08-05 04:21:03 +03:00
Fr1ngg a2ffb3396e Update README.md 2025-08-05 04:19:13 +03:00
Fr1ngg 6846d58d46 Update README.md 2025-08-05 04:17:58 +03:00
Fr1ngg 0177ae064f Update README.md 2025-08-05 03:42:25 +03:00
Fr1ngg 72caed0138 Update README.md 2025-08-05 03:37:39 +03:00
Fr1ngg 332ce9e54d Update README.md 2025-08-05 03:21:35 +03:00
Fr1ngg c1ec6ed9fd Update README.md 2025-08-05 03:21:02 +03:00
Fr1ngg 203d082317 Update subscription_monitor.py 2025-08-05 03:05:49 +03:00
Fr1ngg 80067a2e93 Update middlewares.py 2025-08-05 03:01:59 +03:00
Fr1ngg 9807eb33d3 Update .env.example 2025-08-05 03:01:21 +03:00
Fr1ngg 063d7517e7 Update admin_handlers.py 2025-08-05 03:00:33 +03:00
Fr1ngg f3ea09ad4a Update config.py 2025-08-05 03:00:06 +03:00
Fr1ngg 9650eb07d1 Update database.py 2025-08-05 02:59:28 +03:00
Fr1ngg 63c049abce Update keyboards.py 2025-08-05 02:59:00 +03:00
Fr1ngg 02d501175d Update main.py 2025-08-05 02:58:34 +03:00
Fr1ngg f49ca0f72c Update middlewares.py 2025-08-05 02:58:10 +03:00
Fr1ngg f5613c6e5e Create subscription_monitor.py
Sub monitor service
2025-08-05 02:57:45 +03:00
Fr1ngg fce5790765 Update utils.py
Update for sub monitor
2025-08-05 02:57:10 +03:00
Fr1ngg 5a0a65e591 Update handlers.py
Update for sub monitor
2025-08-05 02:56:36 +03:00
Fr1ngg 4b4de9528b Update remnawave_api.py
Update for sub monitor
2025-08-05 02:56:04 +03:00
Fr1ngg 88f3c239bf Update admin_handlers.py
Убираем вывод триал подписок из админки
2025-08-05 01:19:16 +03:00
Fr1ngg 293c1bebba Update main.py 2025-08-05 01:18:33 +03:00
Fr1ngg f84273dd28 Update handlers.py
Убираем вывод тестовых подписок в админке созданных юзерами
2025-08-05 01:18:00 +03:00
Fr1ngg 5430f36e24 Update README.md 2025-08-04 20:56:43 +03:00
Fr1ngg 97f0ffd294 Update README.md 2025-08-04 20:54:55 +03:00
Fr1ngg 5cf8ba3d15 Update README.md 2025-08-04 20:53:10 +03:00
19 changed files with 7297 additions and 469 deletions
+5
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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"]
+96
View File
@@ -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}}"
+88 -30
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+233
View File
@@ -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
+12 -2
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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
View File
@@ -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
+173 -81
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+38 -8
View File
@@ -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
+2 -2
View File
@@ -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"
+503
View 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
View File
@@ -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}',
+50 -3
View File
@@ -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"""