Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b14baca51 | |||
| eb1a75c9f5 | |||
| 5e79106134 | |||
| ee45ec356e | |||
| 9275bff828 | |||
| 9150c70618 | |||
| b792e749d6 | |||
| 9871572740 | |||
| 52c1cb3cbc | |||
| d5b2b371c8 | |||
| d5cb022b02 | |||
| 021f62fd30 | |||
| 684e398798 | |||
| 12b64e9978 | |||
| 273df23f20 | |||
| a9b0dbf3eb | |||
| 5f56e068d3 | |||
| 92b82132d8 | |||
| 79ae9d3a1c | |||
| 86714a93f9 | |||
| 553f6f8cf6 | |||
| b561f67c12 | |||
| d03f9b80c7 | |||
| 6d9a5fb578 | |||
| 44d19245c4 | |||
| ce36c268cc | |||
| 1357b07921 | |||
| d4bfdcb749 | |||
| 39621706c8 | |||
| 4684e7a1e5 | |||
| 16bb3c8ad3 | |||
| 16fbdcc655 | |||
| 416d268352 | |||
| 44fb88f368 | |||
| f050c12253 | |||
| 82486d121e | |||
| 2bf382dbac | |||
| ab562e0df0 | |||
| 02e8fba9d0 | |||
| 530df33ba4 | |||
| 8b2e8cb681 | |||
| 2cb8c937e1 | |||
| ea5c08fb58 | |||
| 2507168916 | |||
| ec6e08f0d3 | |||
| ceee7cc977 | |||
| ece05cd9e9 | |||
| 2345016aa3 | |||
| d349352812 | |||
| e002ca2cb2 | |||
| 849afb4aa4 | |||
| 943628e6fe | |||
| 6621de95c0 | |||
| 87797153be | |||
| 4e833f9b8c | |||
| 560a2c362f | |||
| 71e9e1ebe2 | |||
| 73ed24aecf | |||
| 09eff6cf8e | |||
| ae7a36523b | |||
| 48a111da76 | |||
| 0f94a7cdcd | |||
| 79761b16bf | |||
| 7235caae0b | |||
| 2f294cdd21 | |||
| 33a124c1d2 | |||
| 91ed42ff08 | |||
| a2ffb3396e | |||
| 6846d58d46 | |||
| 0177ae064f | |||
| 72caed0138 | |||
| 332ce9e54d | |||
| c1ec6ed9fd | |||
| 203d082317 | |||
| 80067a2e93 | |||
| 9807eb33d3 | |||
| 063d7517e7 | |||
| f3ea09ad4a | |||
| 9650eb07d1 | |||
| 63c049abce | |||
| 02d501175d | |||
| f49ca0f72c | |||
| f5613c6e5e | |||
| fce5790765 | |||
| 5a0a65e591 | |||
| 4b4de9528b | |||
| 88f3c239bf | |||
| 293c1bebba | |||
| f84273dd28 | |||
| 5430f36e24 | |||
| 97f0ffd294 | |||
| 5cf8ba3d15 | |||
| aaecee1e9b | |||
| 701f8b258d | |||
| a222e7aea1 | |||
| ef668bd6a8 | |||
| 5082ccba70 | |||
| 9d124310d1 | |||
| ba9d85333f | |||
| 3b40897809 | |||
| 4bbd7d3e4a | |||
| c0bc43050c | |||
| 6b1b600ac8 | |||
| 12ec36b6d1 | |||
| 3571f0b84f | |||
| f9f18be746 | |||
| 8b8d674acb | |||
| eeb417234b | |||
| 869f77fb8e | |||
| ea6ea561d2 | |||
| 9a8217cf3f | |||
| 1c0e26d9e8 | |||
| 1d3b21717e |
@@ -1,6 +1,13 @@
|
||||
# Bot Configuration
|
||||
BOT_TOKEN=
|
||||
|
||||
# настройки реф программы юзернейм без @!
|
||||
BOT_USERNAME=
|
||||
REFERRAL_FIRST_REWARD=100.0
|
||||
REFERRAL_REFERRED_BONUS=100.0
|
||||
REFERRAL_THRESHOLD=200.0
|
||||
REFERRAL_PERCENTAGE=0.2
|
||||
|
||||
# RemnaWave API Configuration
|
||||
REMNAWAVE_URL=https://example.com
|
||||
REMNAWAVE_MODE=remote
|
||||
@@ -20,3 +27,8 @@ TRIAL_DURATION_DAYS=3
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID=
|
||||
TRIAL_PRICE=0.0
|
||||
|
||||
# Monitor Service Settings (дополнительные настройки)
|
||||
MONITOR_CHECK_INTERVAL=3600
|
||||
MONITOR_DAILY_CHECK_HOUR=10
|
||||
MONITOR_WARNING_DAYS=2
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: BedolagaBot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: fr1ngg/remnawave-bedolaga-telegram-bot:latest
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# 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/*
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -r botuser && useradd -r -g botuser botuser
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY 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 . .
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/logs /app/data && \
|
||||
chown -R botuser:botuser /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER botuser
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import asyncio; import sys; sys.exit(0)"
|
||||
|
||||
# Default command
|
||||
CMD ["python3", "main.py"]
|
||||
@@ -0,0 +1,96 @@
|
||||
# RemnaWave Bot Docker Management
|
||||
|
||||
.PHONY: help build up down restart logs clean db-backup db-restore
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available commands:"
|
||||
@echo " build - Build all Docker images"
|
||||
@echo " up - Start all services"
|
||||
@echo " up-min - Start only bot and database (minimal setup)"
|
||||
@echo " up-full - Start all services including nginx and redis"
|
||||
@echo " down - Stop all services"
|
||||
@echo " restart - Restart all services"
|
||||
@echo " logs - Show logs for all services"
|
||||
@echo " logs-bot - Show logs for bot service only"
|
||||
@echo " logs-db - Show logs for database service only"
|
||||
@echo " clean - Remove all containers, networks, and volumes"
|
||||
@echo " db-backup - Create database backup"
|
||||
@echo " db-restore - Restore database from backup"
|
||||
@echo " shell-bot - Open shell in bot container"
|
||||
@echo " shell-db - Open shell in database container"
|
||||
|
||||
# Build all images
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start minimal services (bot + database)
|
||||
up-min: setup-dirs
|
||||
docker compose up -d postgres bot
|
||||
|
||||
# Start all services including optional ones
|
||||
up-full: setup-dirs
|
||||
docker compose --profile with-nginx up -d
|
||||
|
||||
# Start main services (default)
|
||||
up: setup-dirs
|
||||
docker compose up -d postgres redis bot
|
||||
|
||||
# Setup required directories
|
||||
setup-dirs:
|
||||
mkdir -p logs data backups
|
||||
|
||||
# Stop all services
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# Restart all services
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
# Show logs for all services
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Show logs for bot only
|
||||
logs-bot:
|
||||
docker compose logs -f bot
|
||||
|
||||
# Show logs for database only
|
||||
logs-db:
|
||||
docker compose logs -f postgres
|
||||
|
||||
# Clean up everything (DANGEROUS - removes all data)
|
||||
clean:
|
||||
@echo "This will remove all containers, networks, and volumes. Are you sure? [y/N]"
|
||||
@read answer && [ "$$answer" = "y" ] || [ "$$answer" = "Y" ]
|
||||
docker compose down -v --remove-orphans
|
||||
docker system prune -f
|
||||
|
||||
# Database backup
|
||||
db-backup:
|
||||
@mkdir -p backups
|
||||
docker compose exec postgres pg_dump -U remnawave_user remnawave_bot > backups/backup_$(shell date +%Y%m%d_%H%M%S).sql
|
||||
@echo "Backup created in backups/ directory"
|
||||
|
||||
# Database restore (use: make db-restore BACKUP=backup_20231201_120000.sql)
|
||||
db-restore:
|
||||
@if [ -z "$(BACKUP)" ]; then echo "Usage: make db-restore BACKUP=backup_file.sql"; exit 1; fi
|
||||
docker compose exec -T postgres psql -U remnawave_user -d remnawave_bot < backups/$(BACKUP)
|
||||
@echo "Database restored from $(BACKUP)"
|
||||
|
||||
# Open shell in bot container
|
||||
shell-bot:
|
||||
docker compose exec bot /bin/bash
|
||||
|
||||
# Open shell in database container
|
||||
shell-db:
|
||||
docker compose exec postgres psql -U remnawave_user -d remnawave_bot
|
||||
|
||||
# Check services status
|
||||
status:
|
||||
docker compose ps
|
||||
|
||||
# View service resource usage
|
||||
stats:
|
||||
docker stats --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}"
|
||||
@@ -1,76 +1,65 @@
|
||||
<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" /><img width="866" height="655" alt="Снимок экрана 2025-08-08 в 05 08 56" src="https://github.com/user-attachments/assets/2957b76c-8b2b-4a32-8ca4-44cbe3c8fa7b" /><img width="866" height="256" alt="Снимок экрана 2025-08-08 в 05 10 29" src="https://github.com/user-attachments/assets/39dbece4-6004-475e-815a-a520499c8839" /><img width="866" height="195" alt="Снимок экрана 2025-08-08 в 05 10 54" src="https://github.com/user-attachments/assets/b3196856-9f36-46d9-a1f5-93b71699174f" /><img width="894" height="212" alt="Снимок экрана 2025-08-08 в 05 11 18" src="https://github.com/user-attachments/assets/47b4b625-93d2-48c7-ae3a-a6a96811f935" /><img width="894" height="173" alt="Снимок экрана 2025-08-08 в 05 29 42" src="https://github.com/user-attachments/assets/600eecc7-e255-421f-a355-a410332e4313" />
|
||||
|
||||
#Описание
|
||||
|
||||
RemnaWave Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
|
||||
RemnaWave Bedolaga Telegram Bot — это многофункциональный бот для управления подписками(Для каждой подписки возможно назначить свой сквад со своими инбаундами - нововведение Remnawave 2.0.0+), балансом, промокодами, тестовой подпиской и рассылками пользователям через Telegram.
|
||||
|
||||
Бот интегрирован с системой RemnaWave версии 2.0.8
|
||||
|
||||
#Основные возможности
|
||||
|
||||
Мультиязычный интерфейс (на данный момент русский и английский языки)
|
||||
Создание и покупка подписок с управлением трафиком, длительностью и ценой
|
||||
Бесплатная тестовая подписка с ограничениями
|
||||
|
||||
Создание и покупка подписок
|
||||
|
||||
Бесплатная тестовая подписка с заданными ограничениями(срок, лимит трафика, назначение сквада)
|
||||
|
||||
Пополнение баланса: 1) Через саппорт в ручную 2) Отправка заявки с суммой админу (С возможность подтвердить/отклонить заявку)
|
||||
|
||||
Управление балансом пользователей (пополнение, списание)
|
||||
|
||||
Промокоды со скидками и ограничениями по использованию
|
||||
|
||||
Реф. программа - С несколькими уровнями: 1) награда первое пополнение приглашенному и владельцу реф ссылки. 2) Награда владельцу за каждое последующее пополнение в процентах от суммы пополнения
|
||||
|
||||
Полноценная админ-панель с контролем пользователей, созданием подписок(Подтягивает UUID сквада из панели), платежей и статистикой
|
||||
|
||||
Рассылка сообщений отдельным пользователям и всем сразу
|
||||
|
||||
Сервис контроля истечения сроков действия подписки(Уведомляет об истечении за указанный в настройках срок), уведомления с предложением продления подписи. (NEW)
|
||||
|
||||
Интеграция с RemnaWave API для управления подписками и пользователями RemnaWave
|
||||
История платежей(Не работает, в доработке) и управление платежами (подтверждение, отклонение)
|
||||
|
||||
Полная синхранизация Remnawave <--> Bot - Перенос подписок из панели Remnawave в бот по Telegram id
|
||||
|
||||
Управление системой Remnawave (NEW)
|
||||
|
||||
Управление платежами (подтверждение, отклонение) + История платежей(Все действия с балансом и подписками в постраничной истории)
|
||||
|
||||
|
||||
#Требования
|
||||
|
||||
Pip
|
||||
|
||||
Python 3.8+
|
||||
|
||||
PostgreSQL, SQLite или другая поддерживаемая SQL-база данных
|
||||
|
||||
Токен Telegram-бота
|
||||
|
||||
URL и токен RemnaWave API
|
||||
Список администраторов Telegram ID
|
||||
|
||||
Ссылки на подписку из ремны формата 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) Настройте handlers.py! В нем механизм подмены поддомена адреса подписки обязательно заменить на свой! Строка 997+
|
||||
|
||||
# Fallback Замена поддомена у ссылки подписки
|
||||
config = kwargs.get('config')
|
||||
if config and 'adminka.' in config.REMNAWAVE_URL:
|
||||
base_url = config.REMNAWAVE_URL.replace('adminka.', 'sub.')
|
||||
|
||||
3) В remnawave_api строки 187-206 заменить поддомен панели на свой!
|
||||
|
||||
# Формируем правильную ссылку на основе base_url
|
||||
# Заменяем adminka на sub в URL
|
||||
if 'adminka.' in self.base_url:
|
||||
sub_url = self.base_url.replace('adminka.', 'sub.')
|
||||
else:
|
||||
sub_url = self.base_url
|
||||
|
||||
subscription_url = f"{sub_url}/sub/{short_uuid}"
|
||||
logger.info(f"Generated subscription URL: {subscription_url}")
|
||||
|
||||
return subscription_url
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get subscription URL: {e}")
|
||||
# Fallback с заменой домена
|
||||
if 'adminka.' in self.base_url:
|
||||
sub_url = self.base_url.replace('adminka.', 'sub.')
|
||||
else:
|
||||
sub_url = self.base_url
|
||||
return f"{sub_url}/sub/{short_uuid}"
|
||||
|
||||
4) Создайте виртуальное окружение и активируйте его:
|
||||
|
||||
suo apt install python3
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
5) Установите зависимости:
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
6) Создайте файл .env в корне проекта и заполните его необходимыми переменными окружения. Пример:
|
||||
2. Создайте файл .env в корне проекта и заполните его необходимыми переменными окружения. Пример:
|
||||
|
||||
BOT_TOKEN=ваш_telegram_bot_token
|
||||
REMNAWAVE_URL=https://your-remnawave-url.ru
|
||||
@@ -85,51 +74,134 @@ 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
|
||||
|
||||
7) Запустите бота:
|
||||
|
||||
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
|
||||
|
||||
#Конфигурация
|
||||
|
||||
BOT_TOKEN — токен Telegram бота от BotFather.
|
||||
|
||||
BOT_USERNAME= (Юзернейм бота для работы реф программы)
|
||||
|
||||
REFERRAL_FIRST_REWARD=100.0 (Награда за пополнение рефа)
|
||||
|
||||
REFERRAL_REFERRED_BONUS=100.0 (Награда рефу за первое пополнение)
|
||||
|
||||
REFERRAL_THRESHOLD=200.0 (Сумма на которую должен пополниться реф, чтобы выдало бонус всем)
|
||||
|
||||
REFERRAL_PERCENTAGE=0.2 (Процент за каждое последующее пополнение рефа, работает со второго пополнения)
|
||||
|
||||
REMNAWAVE_URL — URL API RemnaWave.
|
||||
|
||||
REMNAWAVE_MODE=remote
|
||||
|
||||
REMNAWAVE_TOKEN — токен доступа к API RemnaWave.
|
||||
|
||||
DATABASE_URL — строка подключения к базе данных.
|
||||
|
||||
ADMIN_IDS — через запятую Telegram ID администраторов.
|
||||
|
||||
SUPPORT_USERNAME — ник поддержки, без @ указывать
|
||||
|
||||
Параметры тестовой подписки (включение, длительность, трафик, UUID squad, цена).
|
||||
|
||||
TRIAL_ENABLED=true/false
|
||||
|
||||
TRIAL_DURATION_DAYS=3 (дни)
|
||||
|
||||
TRIAL_TRAFFIC_GB=2
|
||||
TRIAL_SQUAD_UUID= (УКазать UUID сквада из панели!)
|
||||
TRIAL_PRICE=0.0 (не трогать)
|
||||
|
||||
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 - Просмотрт планов - Удалалить импортированные
|
||||
|
||||
Остальное трогать без понимания кода - не рекомендую.
|
||||
|
||||
#Структура проекта
|
||||
|
||||
main.py — главный файл запуска.
|
||||
|
||||
handlers.py — основные обработчики команд и действий пользователя.
|
||||
|
||||
admin_handlers.py — обработчики команд и действий администраторов.
|
||||
|
||||
database.py — модели и методы работы с базой данных (SQLAlchemy).
|
||||
|
||||
remnawave_api.py — интеграция с API RemnaWave.
|
||||
|
||||
keyboards.py — генерация клавиатур Telegram.
|
||||
|
||||
translations.py — локализация и переводы.
|
||||
|
||||
utils.py — вспомогательные функции.
|
||||
|
||||
middlewares.py — промежуточные слои для обработки сообщений и запросов.
|
||||
|
||||
subscription_monitor.py - сервис мониторинга сроков истечения подписок
|
||||
|
||||
.env — файл конфигурации с переменными окружения.
|
||||
|
||||
requirements.txt — список зависимостей Python.
|
||||
|
||||
run.sh — скрипт установки и управления ботом (опционально).
|
||||
|
||||
#Администрирование
|
||||
@@ -139,17 +211,34 @@ run.sh — скрипт установки и управления ботом (
|
||||
В админ-панели доступны:
|
||||
|
||||
Управление подписками (создание, редактирование, список, удаление, включение/отключение)
|
||||
|
||||
Управление пользователями (просмотр списка, баланс)
|
||||
|
||||
Управление балансом (пополнение пользователей через tg id)
|
||||
|
||||
Управление промокодами (создание, список)
|
||||
|
||||
Одобрение или отклонение платежей (Приходит запрос администраторам)
|
||||
|
||||
Отправка сообщений пользователям или массовая рассылка
|
||||
Просмотр краткой статистики
|
||||
|
||||
Мониторинг подписок (Проверка статуса службы, принудитедьный запуск, деактивация истекщих подписок(на случай падения базы), персональный тест(можно отправить уведомления юзеру по tg id)
|
||||
|
||||
Просмотр статистики
|
||||
|
||||
Управление систеой Remnawave (Ноды, пользователи, синхронизация и импорт подписок из базы Remnawave в бот)
|
||||
|
||||
#ToDo
|
||||
|
||||
1) Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
|
||||
Код колхозный и не без вайбкодинга тут обошлось, но будет допиливаться, текущая реализация работает - уже хорошо
|
||||
1) Дописать службу для оповещения об истечении срока подписки и контроля - Done v1.1.0
|
||||
2) Подключить различные шлюзы для пополнения баланса
|
||||
3) Дописать службу для оповещения об истечении срока подписки и контроля
|
||||
4) Синхранизацию с Remnawave между пользователями по тг id
|
||||
5) Полнофункциональную панель упарвления
|
||||
3) Синхранизацию с Remnawave между пользователями по тг id - Done v1.2.0
|
||||
4) Полнофункциональную панель упарвления
|
||||
5) Добавить возможность удаление промокодов - In progress
|
||||
6) Доработать алгоритм удаления подписок ибо удаление(А НЕ деактивация) сейчас - скроект эту подписку у всех юзеров которые ее купили, так что удаляйте на свой страх и риск я предупредил) - In progress
|
||||
8) Отправка уведомлений административных в другие чаты-топики
|
||||
9) Рефка (как по мне беспонтовая штука, сервера нормальные хостите, сервис нормальный делайте и будут клиенты - не ебите мозги, но если будет не лень, то допилю), ладно трех запросов в личку мне хватило для того. чтобы пересмотреть свое мнение и вкорячить эту шляпу приоритетом - Done v 1.3.0
|
||||
10) Интегрировать панелья управления Remnawave прямо в бота - Done v1.1.0
|
||||
|
||||
А так, други, буду рад баг репортам, по настройке тоже не проблема подсказать, стучите в лс или @fringg
|
||||
|
||||
+4572
-22
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
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
|
||||
@@ -0,0 +1,172 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
print("✅ .env файл загружен успешно")
|
||||
except ImportError:
|
||||
print("⚠️ python-dotenv не установлен. Используются системные переменные окружения.")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка загрузки .env файла: {e}")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
# Bot settings
|
||||
BOT_TOKEN: str = field(default="")
|
||||
DATABASE_URL: str = field(default="sqlite+aiosqlite:///bot.db")
|
||||
|
||||
# RemnaWave API settings
|
||||
REMNAWAVE_URL: str = field(default="")
|
||||
REMNAWAVE_TOKEN: str = field(default="")
|
||||
SUBSCRIPTION_BASE_URL: str = field(default="")
|
||||
|
||||
# Admin settings
|
||||
ADMIN_IDS: List[int] = field(default_factory=list)
|
||||
SUPPORT_USERNAME: str = field(default="support")
|
||||
|
||||
# Bot settings
|
||||
DEFAULT_LANGUAGE: str = field(default="ru")
|
||||
BOT_USERNAME: str = field(default="") # ИМЯ БОТА ДЛЯ РЕФЕРАЛЬНЫХ ССЫЛОК
|
||||
|
||||
# Trial subscription settings
|
||||
TRIAL_ENABLED: bool = field(default=False)
|
||||
TRIAL_DURATION_DAYS: int = field(default=3)
|
||||
TRIAL_TRAFFIC_GB: int = field(default=2)
|
||||
TRIAL_SQUAD_UUID: str = field(default="")
|
||||
|
||||
|
||||
MONITOR_WARNING_DAYS: int = field(default=3)
|
||||
MONITOR_CHECK_INTERVAL: int = field(default=3600) # 1 час
|
||||
MONITOR_DAILY_CHECK_HOUR: int = field(default=9) # 9 утра
|
||||
|
||||
# Referral program settings
|
||||
REFERRAL_FIRST_REWARD: float = field(default=150.0) # Первая награда
|
||||
REFERRAL_REFERRED_BONUS: float = field(default=150.0) # Бонус приглашенному
|
||||
REFERRAL_THRESHOLD: float = field(default=300.0) # Порог для получения бонуса
|
||||
REFERRAL_PERCENTAGE: float = field(default=0.25) # 25% с платежей
|
||||
|
||||
def parse_admin_ids(admin_ids_str: str) -> List[int]:
|
||||
"""Parse admin IDs from string"""
|
||||
if not admin_ids_str:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Support both comma and space separated
|
||||
ids_str = admin_ids_str.replace(',', ' ').strip()
|
||||
return [int(id_str.strip()) for id_str in ids_str.split() if id_str.strip().isdigit()]
|
||||
except ValueError as e:
|
||||
logger.error(f"Error parsing admin IDs: {e}")
|
||||
return []
|
||||
|
||||
def str_to_bool(value: str) -> bool:
|
||||
"""Convert string to boolean"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return value.lower() in ('true', '1', 'yes', 'on', 'enabled')
|
||||
|
||||
def load_config() -> Config:
|
||||
"""Load configuration from environment variables"""
|
||||
|
||||
# Дебаг: проверяем что переменные загружены
|
||||
print(f"🔍 BOT_USERNAME из env: '{os.getenv('BOT_USERNAME', 'НЕ НАЙДЕН')}'")
|
||||
print(f"🔍 REFERRAL_FIRST_REWARD из env: '{os.getenv('REFERRAL_FIRST_REWARD', 'НЕ НАЙДЕН')}'")
|
||||
|
||||
config = Config(
|
||||
# Bot settings
|
||||
BOT_TOKEN=os.getenv("BOT_TOKEN", ""),
|
||||
DATABASE_URL=os.getenv("DATABASE_URL", "sqlite+aiosqlite:///bot.db"),
|
||||
|
||||
# RemnaWave API settings
|
||||
REMNAWAVE_URL=os.getenv("REMNAWAVE_URL", ""),
|
||||
REMNAWAVE_TOKEN=os.getenv("REMNAWAVE_TOKEN", ""),
|
||||
SUBSCRIPTION_BASE_URL=os.getenv("SUBSCRIPTION_BASE_URL", ""),
|
||||
|
||||
# Admin settings
|
||||
ADMIN_IDS=parse_admin_ids(os.getenv("ADMIN_IDS", "")),
|
||||
SUPPORT_USERNAME=os.getenv("SUPPORT_USERNAME", "support"),
|
||||
|
||||
# Bot settings
|
||||
DEFAULT_LANGUAGE=os.getenv("DEFAULT_LANGUAGE", "ru"),
|
||||
BOT_USERNAME=os.getenv("BOT_USERNAME", ""), # ИСПРАВЛЕНО: берется из env
|
||||
|
||||
# Trial settings
|
||||
TRIAL_ENABLED=str_to_bool(os.getenv("TRIAL_ENABLED", "false")),
|
||||
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", ""),
|
||||
|
||||
MONITOR_WARNING_DAYS=int(os.getenv("MONITOR_WARNING_DAYS", "3")),
|
||||
MONITOR_CHECK_INTERVAL=int(os.getenv("MONITOR_CHECK_INTERVAL", "3600")),
|
||||
MONITOR_DAILY_CHECK_HOUR=int(os.getenv("MONITOR_DAILY_CHECK_HOUR", "9")),
|
||||
|
||||
# Referral settings
|
||||
REFERRAL_FIRST_REWARD=float(os.getenv("REFERRAL_FIRST_REWARD", "150.0")),
|
||||
REFERRAL_REFERRED_BONUS=float(os.getenv("REFERRAL_REFERRED_BONUS", "150.0")),
|
||||
REFERRAL_THRESHOLD=float(os.getenv("REFERRAL_THRESHOLD", "300.0")),
|
||||
REFERRAL_PERCENTAGE=float(os.getenv("REFERRAL_PERCENTAGE", "0.25")),
|
||||
)
|
||||
|
||||
print(f"✅ Config BOT_USERNAME: '{config.BOT_USERNAME}'")
|
||||
print(f"✅ Config REFERRAL_FIRST_REWARD: {config.REFERRAL_FIRST_REWARD}")
|
||||
|
||||
return config
|
||||
|
||||
def load_config_manual_dotenv() -> Config:
|
||||
"""Load config with manual .env parsing"""
|
||||
|
||||
# Загружаем .env файл вручную если python-dotenv не доступен
|
||||
env_path = ".env"
|
||||
if os.path.exists(env_path):
|
||||
try:
|
||||
with open(env_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
# Убираем кавычки если есть
|
||||
value = value.strip('"\'')
|
||||
os.environ[key] = value
|
||||
print(f"✅ Файл {env_path} загружен вручную")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка загрузки {env_path}: {e}")
|
||||
|
||||
return load_config()
|
||||
|
||||
def debug_environment():
|
||||
"""Debug environment variables loading"""
|
||||
print("\n=== DEBUG ENVIRONMENT VARIABLES ===")
|
||||
|
||||
# Ключевые переменные для проверки
|
||||
key_vars = [
|
||||
'BOT_TOKEN', 'BOT_USERNAME', 'REMNAWAVE_URL', 'REMNAWAVE_TOKEN',
|
||||
'ADMIN_IDS', 'REFERRAL_FIRST_REWARD', 'REFERRAL_THRESHOLD'
|
||||
]
|
||||
|
||||
for var in key_vars:
|
||||
value = os.getenv(var, 'НЕ УСТАНОВЛЕНА')
|
||||
print(f"{var}: {value}")
|
||||
|
||||
print("=" * 40)
|
||||
|
||||
# Проверяем файл .env
|
||||
if os.path.exists('.env'):
|
||||
print("📁 Файл .env найден")
|
||||
try:
|
||||
with open('.env', 'r') as f:
|
||||
lines = f.readlines()
|
||||
print(f"📄 Строк в .env: {len(lines)}")
|
||||
for i, line in enumerate(lines[:5], 1): # Первые 5 строк
|
||||
if 'TOKEN' not in line: # Не показываем токены
|
||||
print(f" {i}: {line.strip()}")
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка чтения .env: {e}")
|
||||
else:
|
||||
print("❌ Файл .env не найден в текущей директории")
|
||||
print(f"📍 Текущая директория: {os.getcwd()}")
|
||||
|
||||
print("=" * 40 + "\n")
|
||||
+664
-35
@@ -1,8 +1,8 @@
|
||||
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
|
||||
from typing import Optional, List, Dict
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -10,6 +10,29 @@ logger = logging.getLogger(__name__)
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
class ReferralProgram(Base):
|
||||
__tablename__ = 'referral_programs'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
referrer_id: Mapped[int] = mapped_column(BigInteger, index=True) # Кто пригласил
|
||||
referred_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) # Кто был приглашен
|
||||
referral_code: Mapped[str] = mapped_column(String(20), index=True) # Промокод реферера
|
||||
first_reward_paid: Mapped[bool] = mapped_column(Boolean, default=False) # Выплачена ли разовая награда
|
||||
total_earned: Mapped[float] = mapped_column(Float, default=0.0) # Всего заработано
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
first_reward_at: Mapped[Optional[datetime]] = mapped_column(DateTime) # Когда выплатили первую награду
|
||||
|
||||
class ReferralEarning(Base):
|
||||
__tablename__ = 'referral_earnings'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
referrer_id: Mapped[int] = mapped_column(BigInteger, index=True) # Кто получил награду
|
||||
referred_id: Mapped[int] = mapped_column(BigInteger, index=True) # От кого получена награда
|
||||
amount: Mapped[float] = mapped_column(Float) # Размер награды
|
||||
earning_type: Mapped[str] = mapped_column(String(20)) # 'first_reward', 'percentage'
|
||||
related_payment_id: Mapped[Optional[int]] = mapped_column(Integer) # Связанный платеж
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = 'users'
|
||||
|
||||
@@ -38,6 +61,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 +72,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 +126,53 @@ 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()
|
||||
await self.migrate_referral_tables() # НОВАЯ МИГРАЦИЯ
|
||||
|
||||
async def migrate_referral_tables(self):
|
||||
"""Create referral system tables if they don't exist"""
|
||||
try:
|
||||
async with self.engine.begin() as conn:
|
||||
# Создаем таблицы реферальной системы
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS referral_programs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
referrer_id BIGINT NOT NULL,
|
||||
referred_id BIGINT UNIQUE NOT NULL,
|
||||
referral_code VARCHAR(20) NOT NULL,
|
||||
first_reward_paid BOOLEAN DEFAULT FALSE,
|
||||
total_earned DOUBLE PRECISION DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
first_reward_at TIMESTAMP,
|
||||
|
||||
INDEX idx_referrer (referrer_id),
|
||||
INDEX idx_referred (referred_id),
|
||||
INDEX idx_referral_code (referral_code)
|
||||
)
|
||||
"""))
|
||||
|
||||
await conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS referral_earnings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
referrer_id BIGINT NOT NULL,
|
||||
referred_id BIGINT NOT NULL,
|
||||
amount DOUBLE PRECISION NOT NULL,
|
||||
earning_type VARCHAR(20) NOT NULL,
|
||||
related_payment_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_referrer_earnings (referrer_id),
|
||||
INDEX idx_referred_earnings (referred_id),
|
||||
INDEX idx_earning_type (earning_type)
|
||||
)
|
||||
"""))
|
||||
|
||||
logger.info("Successfully created referral system tables")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral tables: {e}")
|
||||
|
||||
async def close(self):
|
||||
await self.engine.dispose()
|
||||
|
||||
@@ -166,18 +239,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 +296,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 +305,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 +354,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 +540,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 +688,451 @@ 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
|
||||
|
||||
async def create_referral(self, referrer_id: int, referred_id: int, referral_code: str) -> Optional[ReferralProgram]:
|
||||
"""Create referral relationship - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
if referred_id == 0:
|
||||
# Генерируем уникальный placeholder ID для хранения кода
|
||||
placeholder_id = 999999999 - referrer_id # Уникальный ID на основе referrer_id
|
||||
|
||||
# Проверяем что код еще не существует для этого пользователя
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
ReferralProgram.referrer_id == referrer_id,
|
||||
ReferralProgram.referred_id == placeholder_id
|
||||
)
|
||||
)
|
||||
existing_referral = existing.scalar_one_or_none()
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"Referral code already exists for user {referrer_id}")
|
||||
return existing_referral
|
||||
|
||||
# Создаем запись для хранения кода
|
||||
referral = ReferralProgram(
|
||||
referrer_id=referrer_id,
|
||||
referred_id=placeholder_id, # Уникальный placeholder
|
||||
referral_code=referral_code
|
||||
)
|
||||
session.add(referral)
|
||||
await session.commit()
|
||||
await session.refresh(referral)
|
||||
logger.info(f"Created referral code storage for user {referrer_id}")
|
||||
return referral
|
||||
|
||||
# Обычная логика для реальных рефералов
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
ReferralProgram.referred_id == referred_id,
|
||||
ReferralProgram.referred_id < 900000000, # Исключаем placeholder
|
||||
ReferralProgram.referred_id > 0 # Исключаем нулевые
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
logger.info(f"User {referred_id} already has a real referrer")
|
||||
return None
|
||||
|
||||
# Проверяем что пользователь не приглашает сам себя
|
||||
if referrer_id == referred_id:
|
||||
logger.warning(f"User {referrer_id} tried to refer themselves")
|
||||
return None
|
||||
|
||||
# Создаем реальную реферальную связь
|
||||
referral = ReferralProgram(
|
||||
referrer_id=referrer_id,
|
||||
referred_id=referred_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
session.add(referral)
|
||||
await session.commit()
|
||||
await session.refresh(referral)
|
||||
|
||||
logger.info(f"Created real referral: {referrer_id} -> {referred_id}")
|
||||
return referral
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
async def get_referral_by_referred_id(self, referred_id: int) -> Optional[ReferralProgram]:
|
||||
"""Get referral info by referred user ID"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referred_id == referred_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral: {e}")
|
||||
return None
|
||||
|
||||
async def get_user_referrals(self, referrer_id: int) -> List[ReferralProgram]:
|
||||
"""Get all referrals for a user - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, and_
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
and_(
|
||||
ReferralProgram.referrer_id == referrer_id,
|
||||
# ИСПРАВЛЕНО: исключаем placeholder записи для хранения кодов
|
||||
ReferralProgram.referred_id < 900000000,
|
||||
ReferralProgram.referred_id > 0
|
||||
)
|
||||
)
|
||||
)
|
||||
referrals = list(result.scalars().all())
|
||||
logger.debug(f"Found {len(referrals)} real referrals for user {referrer_id}")
|
||||
return referrals
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user referrals: {e}")
|
||||
return []
|
||||
|
||||
async def create_referral(self, referrer_id: int, referred_id: int, referral_code: str) -> Optional[ReferralProgram]:
|
||||
"""Create referral relationship - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select
|
||||
|
||||
# Специальный случай: создание кода для самого пользователя
|
||||
if referred_id == 0:
|
||||
# Генерируем уникальный placeholder ID для хранения кода
|
||||
placeholder_id = 999999999 - referrer_id # Уникальный ID на основе referrer_id
|
||||
|
||||
# Проверяем что код еще не существует для этого пользователя
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
ReferralProgram.referrer_id == referrer_id,
|
||||
ReferralProgram.referred_id == placeholder_id
|
||||
)
|
||||
)
|
||||
existing_referral = existing.scalar_one_or_none()
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"Referral code already exists for user {referrer_id}")
|
||||
return existing_referral
|
||||
|
||||
# Создаем запись для хранения кода
|
||||
referral = ReferralProgram(
|
||||
referrer_id=referrer_id,
|
||||
referred_id=placeholder_id, # Уникальный placeholder
|
||||
referral_code=referral_code
|
||||
)
|
||||
session.add(referral)
|
||||
await session.commit()
|
||||
await session.refresh(referral)
|
||||
return referral
|
||||
|
||||
# Обычная логика для реальных рефералов
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referred_id == referred_id)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
logger.info(f"User {referred_id} already has a referrer")
|
||||
return None
|
||||
|
||||
# Проверяем что пользователь не приглашает сам себя
|
||||
if referrer_id == referred_id:
|
||||
logger.warning(f"User {referrer_id} tried to refer themselves")
|
||||
return None
|
||||
|
||||
referral = ReferralProgram(
|
||||
referrer_id=referrer_id,
|
||||
referred_id=referred_id,
|
||||
referral_code=referral_code
|
||||
)
|
||||
session.add(referral)
|
||||
await session.commit()
|
||||
await session.refresh(referral)
|
||||
return referral
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral: {e}")
|
||||
await session.rollback()
|
||||
return None
|
||||
|
||||
async def get_user_referral_stats(self, user_id: int) -> Dict:
|
||||
"""Get user referral statistics - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
|
||||
placeholder_id = 999999999 - user_id
|
||||
|
||||
# Количество приглашенных (исключаем только конкретный placeholder этого пользователя)
|
||||
referrals_count = await session.execute(
|
||||
select(func.count(ReferralProgram.id))
|
||||
.where(
|
||||
and_(
|
||||
ReferralProgram.referrer_id == user_id,
|
||||
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
|
||||
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Количество тех, кто получил первую награду (исключаем placeholder)
|
||||
active_referrals = await session.execute(
|
||||
select(func.count(ReferralProgram.id))
|
||||
.where(
|
||||
and_(
|
||||
ReferralProgram.referrer_id == user_id,
|
||||
ReferralProgram.first_reward_paid == True,
|
||||
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
|
||||
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Общий заработок
|
||||
total_earned = await session.execute(
|
||||
select(func.sum(ReferralEarning.amount))
|
||||
.where(ReferralEarning.referrer_id == user_id)
|
||||
)
|
||||
|
||||
result = {
|
||||
'total_referrals': referrals_count.scalar() or 0,
|
||||
'active_referrals': active_referrals.scalar() or 0,
|
||||
'total_earned': total_earned.scalar() or 0.0
|
||||
}
|
||||
|
||||
logger.info(f"Referral stats for user {user_id}: {result}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting referral stats: {e}")
|
||||
return {
|
||||
'total_referrals': 0,
|
||||
'active_referrals': 0,
|
||||
'total_earned': 0.0
|
||||
}
|
||||
|
||||
async def generate_unique_referral_code(self, user_id: int) -> str:
|
||||
"""Generate unique referral code for user"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
import secrets
|
||||
import string
|
||||
|
||||
# Сначала пытаемся создать код на основе user_id
|
||||
base_code = f"REF{user_id}"
|
||||
|
||||
from sqlalchemy import select
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referral_code == base_code)
|
||||
)
|
||||
|
||||
if not existing.scalar_one_or_none():
|
||||
return base_code
|
||||
|
||||
# Если код уже существует, добавляем случайные символы
|
||||
for _ in range(10):
|
||||
random_suffix = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(4))
|
||||
code = f"REF{user_id}{random_suffix}"
|
||||
|
||||
existing = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referral_code == code)
|
||||
)
|
||||
|
||||
if not existing.scalar_one_or_none():
|
||||
return code
|
||||
|
||||
# Если все еще не удалось, используем полностью случайный код
|
||||
return f"REF{''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating referral code: {e}")
|
||||
return f"REF{user_id}ERR"
|
||||
|
||||
async def get_user_referrals(self, referrer_id: int) -> List[ReferralProgram]:
|
||||
"""Get all referrals for a user - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
placeholder_id = 999999999 - referrer_id
|
||||
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
and_(
|
||||
ReferralProgram.referrer_id == referrer_id,
|
||||
ReferralProgram.referred_id != placeholder_id, # Исключаем только наш placeholder
|
||||
ReferralProgram.referred_id != 0 # Исключаем нулевые записи
|
||||
)
|
||||
).order_by(ReferralProgram.created_at.desc()) # Сортируем по дате создания
|
||||
)
|
||||
referrals = list(result.scalars().all())
|
||||
|
||||
logger.info(f"Found {len(referrals)} real referrals for user {referrer_id} (excluding placeholder {placeholder_id})")
|
||||
|
||||
# Дополнительно логируем каждого реферала для отладки
|
||||
for ref in referrals:
|
||||
logger.debug(f"Referral: referrer={ref.referrer_id}, referred={ref.referred_id}, "
|
||||
f"first_reward_paid={ref.first_reward_paid}, total_earned={ref.total_earned}")
|
||||
|
||||
return referrals
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user referrals: {e}")
|
||||
return []
|
||||
|
||||
async def create_referral_earning(self, referrer_id: int, referred_id: int,
|
||||
amount: float, earning_type: str,
|
||||
related_payment_id: Optional[int] = None) -> bool:
|
||||
"""Create referral earning record"""
|
||||
async with self.session_factory() as session:
|
||||
try:
|
||||
earning = ReferralEarning(
|
||||
referrer_id=referrer_id,
|
||||
referred_id=referred_id,
|
||||
amount=amount,
|
||||
earning_type=earning_type,
|
||||
related_payment_id=related_payment_id
|
||||
)
|
||||
session.add(earning)
|
||||
|
||||
# Обновляем общий заработок и статус первой награды в реферальной программе
|
||||
from sqlalchemy import select, update
|
||||
|
||||
# Найти запись реферальной программы
|
||||
referral = await session.execute(
|
||||
select(ReferralProgram).where(
|
||||
ReferralProgram.referrer_id == referrer_id,
|
||||
ReferralProgram.referred_id == referred_id
|
||||
)
|
||||
)
|
||||
referral_record = referral.scalar_one_or_none()
|
||||
|
||||
if referral_record:
|
||||
# Обновляем total_earned
|
||||
referral_record.total_earned += amount
|
||||
|
||||
# Если это первая награда, помечаем как выплаченную
|
||||
if earning_type == 'first_reward':
|
||||
referral_record.first_reward_paid = True
|
||||
referral_record.first_reward_at = datetime.utcnow()
|
||||
|
||||
await session.merge(referral_record)
|
||||
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral earning: {e}")
|
||||
await session.rollback()
|
||||
return False
|
||||
|
||||
@@ -0,0 +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
|
||||
+768
-204
File diff suppressed because it is too large
Load Diff
+205
-12
@@ -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:
|
||||
@@ -31,9 +31,12 @@ def main_menu_keyboard(lang: str = 'ru', is_admin: bool = False, show_trial: boo
|
||||
|
||||
# Добавляем остальные кнопки
|
||||
buttons.extend([
|
||||
# Дополнительные функции
|
||||
# Реферальная программа и дополнительные функции
|
||||
[
|
||||
InlineKeyboardButton(text="🎁 " + t('promocode', lang), callback_data="promocode"),
|
||||
InlineKeyboardButton(text="👥 Рефералы", callback_data="referral_program") # НОВАЯ КНОПКА
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="💬 " + t('support', lang), callback_data="support")
|
||||
],
|
||||
# Последний ряд - настройки
|
||||
@@ -59,7 +62,7 @@ def balance_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
def topup_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Beautiful top up balance keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
|
||||
# [InlineKeyboardButton(text="💳 " + t('topup_card', lang), callback_data="topup_card")],
|
||||
[InlineKeyboardButton(text="👨💼 " + t('topup_support', lang), callback_data="topup_support")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="balance")]
|
||||
])
|
||||
@@ -122,16 +125,22 @@ def user_subscriptions_keyboard(user_subscriptions: List[dict], lang: str = 'ru'
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="main_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscription detail keyboard with connection and optional extend button"""
|
||||
def user_subscription_detail_keyboard(subscription_id: int, lang: str = 'ru', show_extend: bool = False, is_imported: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Beautiful user's subscription detail keyboard with connection and optional extend button - ИСПРАВЛЕНО"""
|
||||
buttons = []
|
||||
|
||||
# Add extend button if subscription is expiring soon
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
|
||||
# Connection button (главная кнопка)
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
# НОВОЕ: Разные кнопки в зависимости от типа подписки
|
||||
if is_imported:
|
||||
# Для импортированных подписок - только ссылка подключения и покупка новой
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
buttons.append([InlineKeyboardButton(text="🛒 Купить новую подписку", callback_data="buy_subscription")])
|
||||
else:
|
||||
# Для обычных подписок - кнопка продления если нужна
|
||||
if show_extend:
|
||||
buttons.append([InlineKeyboardButton(text="⏰ " + t('extend_subscription', lang), callback_data=f"extend_sub_{subscription_id}")])
|
||||
|
||||
# Connection button (главная кнопка)
|
||||
buttons.append([InlineKeyboardButton(text="🔗 Получить ссылку подключения", callback_data=f"get_connection_{subscription_id}")])
|
||||
|
||||
# Back button
|
||||
buttons.append([InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="my_subscriptions")])
|
||||
@@ -177,9 +186,17 @@ 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="👥 Рефералы", callback_data="admin_referrals") # НОВАЯ КНОПКА
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🖥 Система RemnaWave", callback_data="admin_system")
|
||||
],
|
||||
# Четвертый ряд - мониторинг и статистика
|
||||
[
|
||||
InlineKeyboardButton(text="🔍 Мониторинг подписок", callback_data="admin_monitor"),
|
||||
InlineKeyboardButton(text="📊 " + t('statistics', lang), callback_data="admin_stats")
|
||||
],
|
||||
# Назад
|
||||
@@ -335,3 +352,179 @@ 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
|
||||
|
||||
def admin_referrals_keyboard(lang: str = 'ru') -> InlineKeyboardMarkup:
|
||||
"""Admin referral management keyboard"""
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text="📊 Статистика рефералов", callback_data="referral_statistics")],
|
||||
[InlineKeyboardButton(text="👥 Список рефереров", callback_data="list_referrers")],
|
||||
[InlineKeyboardButton(text="💰 История выплат", callback_data="referral_payments")],
|
||||
[InlineKeyboardButton(text="⚙️ Настройки программы", callback_data="referral_settings")],
|
||||
[InlineKeyboardButton(text="🔙 " + t('back', lang), callback_data="admin_panel")]
|
||||
])
|
||||
return keyboard
|
||||
|
||||
@@ -7,10 +7,23 @@ from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
|
||||
# Import our modules
|
||||
from config import load_config
|
||||
# ДОБАВЛЯЕМ ДЕБАГ ЗАГРУЗКИ .env ПЕРЕД ИМПОРТОМ CONFIG
|
||||
print("🚀 Запуск бота...")
|
||||
print(f"📍 Рабочая директория: {os.getcwd()}")
|
||||
print(f"📁 Файлы в директории: {os.listdir('.')}")
|
||||
|
||||
# Проверяем наличие .env файла
|
||||
if os.path.exists('.env'):
|
||||
print("✅ Файл .env найден")
|
||||
else:
|
||||
print("❌ Файл .env НЕ НАЙДЕН!")
|
||||
print("💡 Создайте файл .env в корне проекта")
|
||||
|
||||
# Import our modules ПОСЛЕ проверки .env
|
||||
from config import load_config, debug_environment
|
||||
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 +39,243 @@ 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"""
|
||||
|
||||
# ДЕБАГ: проверяем переменные окружения
|
||||
debug_environment()
|
||||
|
||||
# Load configuration
|
||||
config = load_config()
|
||||
self.config = load_config()
|
||||
|
||||
# ДЕБАГ: проверяем что загрузилось
|
||||
print(f"🔧 Загруженная конфигурация:")
|
||||
print(f" BOT_USERNAME: '{self.config.BOT_USERNAME}'")
|
||||
print(f" REFERRAL_FIRST_REWARD: {self.config.REFERRAL_FIRST_REWARD}")
|
||||
print(f" ADMIN_IDS: {self.config.ADMIN_IDS}")
|
||||
|
||||
# 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")
|
||||
|
||||
# ПРЕДУПРЕЖДЕНИЕ если BOT_USERNAME не установлен
|
||||
if not self.config.BOT_USERNAME:
|
||||
logger.warning("⚠️ BOT_USERNAME не установлен! Реферальные ссылки работать не будут!")
|
||||
print("💡 Добавьте BOT_USERNAME=your_bot_username в .env файл")
|
||||
|
||||
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}")
|
||||
logger.info(f"Bot Username: {self.config.BOT_USERNAME}")
|
||||
|
||||
# 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)
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
async def _test_bot_token(self):
|
||||
"""Test bot token before starting"""
|
||||
try:
|
||||
bot_info = await self.bot.get_me()
|
||||
logger.info(f"Bot started: @{bot_info.username} ({bot_info.first_name})")
|
||||
|
||||
# АВТОМАТИЧЕСКИ обновляем BOT_USERNAME если он не был установлен
|
||||
if not self.config.BOT_USERNAME and bot_info.username:
|
||||
self.config.BOT_USERNAME = bot_info.username
|
||||
logger.info(f"✅ BOT_USERNAME автоматически установлен: {bot_info.username}")
|
||||
print("💡 Добавьте BOT_USERNAME в .env файл для постоянного сохранения")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid bot token or network error: {e}")
|
||||
raise
|
||||
|
||||
def _setup_dispatcher(self):
|
||||
"""Setup dispatcher with middlewares and routers"""
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(storage=storage)
|
||||
self.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
|
||||
# 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
|
||||
dp.message.middleware(LoggingMiddleware())
|
||||
dp.callback_query.middleware(LoggingMiddleware())
|
||||
self.dp.message.middleware(LoggingMiddleware())
|
||||
self.dp.callback_query.middleware(LoggingMiddleware())
|
||||
|
||||
dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
self.dp.message.middleware(ThrottlingMiddleware(rate_limit=0.5))
|
||||
self.dp.callback_query.middleware(ThrottlingMiddleware(rate_limit=0.3))
|
||||
|
||||
dp.message.middleware(WorkflowDataMiddleware())
|
||||
dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
self.dp.message.middleware(WorkflowDataMiddleware())
|
||||
self.dp.callback_query.middleware(WorkflowDataMiddleware())
|
||||
|
||||
dp.message.middleware(BotMiddleware(bot))
|
||||
dp.callback_query.middleware(BotMiddleware(bot))
|
||||
self.dp.message.middleware(BotMiddleware(self.bot))
|
||||
self.dp.callback_query.middleware(BotMiddleware(self.bot))
|
||||
|
||||
dp.message.middleware(DatabaseMiddleware(db))
|
||||
dp.callback_query.middleware(DatabaseMiddleware(db))
|
||||
self.dp.message.middleware(DatabaseMiddleware(self.db))
|
||||
self.dp.callback_query.middleware(DatabaseMiddleware(self.db))
|
||||
|
||||
dp.message.middleware(UserMiddleware(db, config))
|
||||
dp.callback_query.middleware(UserMiddleware(db, config))
|
||||
self.dp.message.middleware(UserMiddleware(self.db, self.config))
|
||||
self.dp.callback_query.middleware(UserMiddleware(self.db, self.config))
|
||||
|
||||
# Register routers
|
||||
dp.include_router(router)
|
||||
dp.include_router(admin_router)
|
||||
self.dp.include_router(router)
|
||||
self.dp.include_router(admin_router)
|
||||
|
||||
# Setup shutdown handler
|
||||
async def on_shutdown():
|
||||
logger.info("Shutting down bot...")
|
||||
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")
|
||||
|
||||
# ФИНАЛЬНАЯ ПРОВЕРКА конфигурации
|
||||
if self.config.BOT_USERNAME:
|
||||
logger.info(f"🎁 Реферальная система активна! Ссылки: https://t.me/{self.config.BOT_USERNAME}?start=ref_USERID")
|
||||
else:
|
||||
logger.warning("⚠️ Реферальная система неактивна! Установите BOT_USERNAME")
|
||||
|
||||
try:
|
||||
await self.dp.start_polling(self.bot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error during polling: {e}")
|
||||
raise
|
||||
finally:
|
||||
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 api.close()
|
||||
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 db.close()
|
||||
await self.db.close()
|
||||
logger.info("Database connection closed")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing database: {e}")
|
||||
|
||||
logger.info("Bot shutdown complete")
|
||||
|
||||
# Test bot token before starting
|
||||
try:
|
||||
bot_info = await 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
|
||||
# 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()
|
||||
|
||||
# Start polling
|
||||
logger.info("Bot polling started successfully")
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
await on_shutdown()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user (Ctrl+C)")
|
||||
except Exception as e:
|
||||
@@ -152,6 +283,9 @@ async def main():
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
if app:
|
||||
await app.shutdown()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
|
||||
+5
-5
@@ -23,7 +23,7 @@ class DatabaseMiddleware(BaseMiddleware):
|
||||
return await handler(event, data)
|
||||
|
||||
class UserMiddleware(BaseMiddleware):
|
||||
"""Middleware for user management"""
|
||||
"""Middleware for user management - ИСПРАВЛЕНО: правильная инициализация языка"""
|
||||
|
||||
def __init__(self, db: Database, config: Config):
|
||||
self.db = db
|
||||
@@ -46,16 +46,16 @@ class UserMiddleware(BaseMiddleware):
|
||||
# Check if user is admin
|
||||
is_admin = telegram_user.id in self.config.ADMIN_IDS
|
||||
|
||||
# Create new user
|
||||
# ИСПРАВЛЕНО: Создаем пользователя с пустым языком для первоначальной настройки
|
||||
user = await self.db.create_user(
|
||||
telegram_id=telegram_user.id,
|
||||
username=telegram_user.username,
|
||||
first_name=telegram_user.first_name,
|
||||
last_name=telegram_user.last_name,
|
||||
language=self.config.DEFAULT_LANGUAGE,
|
||||
language='', # ИЗМЕНЕНО: пустой язык для нового пользователя
|
||||
is_admin=is_admin
|
||||
)
|
||||
logger.info(f"Created new user: {telegram_user.id}")
|
||||
logger.info(f"Created new user: {telegram_user.id} without language")
|
||||
else:
|
||||
# Update user info if changed
|
||||
updated = False
|
||||
@@ -79,7 +79,7 @@ class UserMiddleware(BaseMiddleware):
|
||||
await self.db.update_user(user)
|
||||
|
||||
data['user'] = user
|
||||
data['lang'] = user.language if user else self.config.DEFAULT_LANGUAGE
|
||||
data['lang'] = user.language if user.language and user.language != '' else self.config.DEFAULT_LANGUAGE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in UserMiddleware: {e}")
|
||||
|
||||
+1007
-245
File diff suppressed because it is too large
Load Diff
+38
-5
@@ -1,5 +1,38 @@
|
||||
aiogram==3.4.1
|
||||
aiohttp==3.9.3
|
||||
aiosqlite==0.19.0
|
||||
sqlalchemy[asyncio]==2.0.25
|
||||
python-dotenv==1.0.1
|
||||
# Telegram Bot Framework
|
||||
aiogram>=3.4.0
|
||||
|
||||
# Database
|
||||
SQLAlchemy>=2.0.0
|
||||
alembic>=1.12.0
|
||||
|
||||
# PostgreSQL driver
|
||||
asyncpg>=0.28.0
|
||||
psycopg2-binary>=2.9.0
|
||||
|
||||
# SQLite driver (fallback)
|
||||
aiosqlite>=0.19.0
|
||||
|
||||
# HTTP Client
|
||||
aiohttp>=3.8.0
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Redis (optional)
|
||||
redis>=4.5.0
|
||||
aioredis>=2.0.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
|
||||
# Logging and monitoring
|
||||
structlog>=23.0.0
|
||||
|
||||
# Date and time
|
||||
python-dateutil>=2.8.0
|
||||
|
||||
# Cryptography
|
||||
cryptography>=42.0.0
|
||||
|
||||
# JSON handling
|
||||
orjson>=3.9.0
|
||||
|
||||
@@ -200,7 +200,7 @@ User=$CURRENT_USER
|
||||
WorkingDirectory=$CURRENT_DIR
|
||||
Environment=PATH=$CURRENT_DIR/$VENV_DIR/bin
|
||||
EnvironmentFile=$CURRENT_DIR/.env
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python $BOT_FILE
|
||||
ExecStart=$CURRENT_DIR/$VENV_DIR/bin/python3 $BOT_FILE
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
@@ -215,4 +215,4 @@ fi
|
||||
|
||||
msg completed
|
||||
msg start_prompt
|
||||
python "$BOT_FILE"
|
||||
python3 "$BOT_FILE"
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from database import Database
|
||||
from remnawave_api import RemnaWaveAPI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SubscriptionMonitorService:
|
||||
"""Service for monitoring subscriptions and sending notifications"""
|
||||
|
||||
def __init__(self, bot, db: Database, config, api: RemnaWaveAPI = None):
|
||||
self.bot = bot
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.api = api
|
||||
self.is_running = False
|
||||
self._monitor_task = None
|
||||
self._daily_task = None
|
||||
|
||||
async def start(self):
|
||||
"""Start the monitoring service"""
|
||||
if self.is_running:
|
||||
logger.warning("Monitor service is already running")
|
||||
return
|
||||
|
||||
self.is_running = True
|
||||
|
||||
# Start periodic monitoring task
|
||||
self._monitor_task = asyncio.create_task(self._monitor_loop())
|
||||
|
||||
# Start daily cleanup task
|
||||
self._daily_task = asyncio.create_task(self._daily_loop())
|
||||
|
||||
logger.info("Subscription monitor service started")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the monitoring service"""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self.is_running = False
|
||||
|
||||
# Cancel tasks
|
||||
if self._monitor_task:
|
||||
self._monitor_task.cancel()
|
||||
try:
|
||||
await self._monitor_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self._daily_task:
|
||||
self._daily_task.cancel()
|
||||
try:
|
||||
await self._daily_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
logger.info("Subscription monitor service stopped")
|
||||
|
||||
async def _monitor_loop(self):
|
||||
"""Main monitoring loop"""
|
||||
while self.is_running:
|
||||
try:
|
||||
await self._check_expiring_subscriptions()
|
||||
await asyncio.sleep(self.config.MONITOR_CHECK_INTERVAL)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in monitor loop: {e}")
|
||||
await asyncio.sleep(60) # Wait 1 minute before retry
|
||||
|
||||
async def _daily_loop(self):
|
||||
"""Daily cleanup loop"""
|
||||
while self.is_running:
|
||||
try:
|
||||
now = datetime.now()
|
||||
|
||||
# Wait until the specified hour
|
||||
if now.hour == self.config.MONITOR_DAILY_CHECK_HOUR:
|
||||
await self.force_daily_check()
|
||||
|
||||
# Wait until next day
|
||||
tomorrow = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
||||
sleep_time = (tomorrow - now).total_seconds()
|
||||
else:
|
||||
# Calculate time until next check
|
||||
target_time = now.replace(hour=self.config.MONITOR_DAILY_CHECK_HOUR, minute=0, second=0, microsecond=0)
|
||||
if target_time < now:
|
||||
target_time += timedelta(days=1)
|
||||
sleep_time = (target_time - now).total_seconds()
|
||||
|
||||
await asyncio.sleep(min(sleep_time, 3600)) # Check at least every hour
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in daily loop: {e}")
|
||||
await asyncio.sleep(3600) # Wait 1 hour before retry
|
||||
|
||||
async def _check_expiring_subscriptions(self):
|
||||
"""Check for expiring subscriptions and send warnings"""
|
||||
try:
|
||||
# Get all users
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
try:
|
||||
# Get expiring subscriptions for this user
|
||||
expiring_subs = await self.db.get_expiring_subscriptions(
|
||||
user.telegram_id,
|
||||
self.config.MONITOR_WARNING_DAYS
|
||||
)
|
||||
|
||||
for user_sub in expiring_subs:
|
||||
await self._send_expiry_warning(user, user_sub)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking subscriptions for user {user.telegram_id}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in check_expiring_subscriptions: {e}")
|
||||
|
||||
async def _send_expiry_warning(self, user, user_subscription):
|
||||
"""Send expiry warning to user"""
|
||||
try:
|
||||
days_left = (user_subscription.expires_at - datetime.utcnow()).days
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_subscription.subscription_id)
|
||||
if not subscription:
|
||||
return
|
||||
|
||||
# Don't send warnings for trial subscriptions
|
||||
if subscription.is_trial:
|
||||
return
|
||||
|
||||
message = self._format_expiry_message(subscription.name, days_left, user.language)
|
||||
|
||||
await self.bot.send_message(user.telegram_id, message)
|
||||
logger.info(f"Sent expiry warning to user {user.telegram_id} for subscription {subscription.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending expiry warning: {e}")
|
||||
|
||||
def _format_expiry_message(self, subscription_name: str, days_left: int, language: str = 'ru') -> str:
|
||||
"""Format expiry warning message"""
|
||||
if language == 'ru':
|
||||
if days_left <= 0:
|
||||
return f"⚠️ Ваша подписка '{subscription_name}' истекла!\n\nДля продления перейдите в раздел 'Мои подписки'."
|
||||
elif days_left == 1:
|
||||
return f"⚠️ Ваша подписка '{subscription_name}' истекает завтра!\n\nНе забудьте продлить её в разделе 'Мои подписки'."
|
||||
else:
|
||||
return f"⚠️ Ваша подписка '{subscription_name}' истекает через {days_left} дн.!\n\nРекомендуем продлить её заранее в разделе 'Мои подписки'."
|
||||
else:
|
||||
if days_left <= 0:
|
||||
return f"⚠️ Your subscription '{subscription_name}' has expired!\n\nTo renew, go to 'My Subscriptions'."
|
||||
elif days_left == 1:
|
||||
return f"⚠️ Your subscription '{subscription_name}' expires tomorrow!\n\nDon't forget to renew it in 'My Subscriptions'."
|
||||
else:
|
||||
return f"⚠️ Your subscription '{subscription_name}' expires in {days_left} days!\n\nWe recommend renewing it in advance in 'My Subscriptions'."
|
||||
|
||||
async def force_daily_check(self):
|
||||
"""Force daily check and cleanup"""
|
||||
try:
|
||||
logger.info("Starting daily check and cleanup")
|
||||
|
||||
# Deactivate expired subscriptions
|
||||
deactivated_count = await self.deactivate_expired_subscriptions()
|
||||
|
||||
# Send final expiry notifications
|
||||
await self._send_final_expiry_notifications()
|
||||
|
||||
logger.info(f"Daily check completed. Deactivated {deactivated_count} expired subscriptions")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in force_daily_check: {e}")
|
||||
|
||||
async def deactivate_expired_subscriptions(self) -> int:
|
||||
"""Deactivate expired subscriptions"""
|
||||
try:
|
||||
count = 0
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
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 < datetime.utcnow():
|
||||
user_sub.is_active = False
|
||||
await self.db.update_user_subscription(user_sub)
|
||||
count += 1
|
||||
|
||||
# Also try to deactivate in RemnaWave if possible
|
||||
if self.api and user_sub.short_uuid:
|
||||
try:
|
||||
user_data = await self.api.get_user_by_short_uuid(user_sub.short_uuid)
|
||||
if user_data and user_data.get('uuid'):
|
||||
await self.api.update_user(user_data['uuid'], {'status': 'EXPIRED'})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not deactivate user in RemnaWave: {e}")
|
||||
|
||||
return count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error deactivating expired subscriptions: {e}")
|
||||
return 0
|
||||
|
||||
async def _send_final_expiry_notifications(self):
|
||||
"""Send final notifications for just-expired subscriptions"""
|
||||
try:
|
||||
# Get subscriptions that expired today
|
||||
all_users = await self.db.get_all_users()
|
||||
|
||||
for user in all_users:
|
||||
user_subs = await self.db.get_user_subscriptions(user.telegram_id)
|
||||
|
||||
for user_sub in user_subs:
|
||||
# Check if subscription expired today (within last 24 hours)
|
||||
time_since_expiry = datetime.utcnow() - user_sub.expires_at
|
||||
|
||||
if (time_since_expiry.total_seconds() > 0 and
|
||||
time_since_expiry.total_seconds() <= 86400): # 24 hours
|
||||
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
if subscription and not subscription.is_trial:
|
||||
message = self._format_expiry_message(subscription.name, 0, user.language)
|
||||
await self.bot.send_message(user.telegram_id, message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending final expiry notifications: {e}")
|
||||
|
||||
async def check_single_user(self, user_id: int):
|
||||
"""Check subscriptions for a single user (for testing)"""
|
||||
try:
|
||||
results = []
|
||||
|
||||
user = await self.db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'User {user_id} not found',
|
||||
'error': None
|
||||
})
|
||||
return results
|
||||
|
||||
# Get user subscriptions
|
||||
user_subs = await self.db.get_user_subscriptions(user_id)
|
||||
|
||||
if not user_subs:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'User {user_id} has no subscriptions',
|
||||
'error': None
|
||||
})
|
||||
return results
|
||||
|
||||
for user_sub in user_subs:
|
||||
try:
|
||||
subscription = await self.db.get_subscription_by_id(user_sub.subscription_id)
|
||||
|
||||
days_left = (user_sub.expires_at - datetime.utcnow()).days
|
||||
|
||||
if days_left <= self.config.MONITOR_WARNING_DAYS:
|
||||
# Send test notification
|
||||
message = self._format_expiry_message(subscription.name, days_left, user.language)
|
||||
await self.bot.send_message(user_id, f"[ТЕСТ] {message}")
|
||||
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'Sent warning for subscription "{subscription.name}" (expires in {days_left} days)',
|
||||
'error': None
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
'success': True,
|
||||
'message': f'Subscription "{subscription.name}" is OK (expires in {days_left} days)',
|
||||
'error': None
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'success': False,
|
||||
'message': f'Error checking subscription ID {user_sub.id}',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
return [{
|
||||
'success': False,
|
||||
'message': f'Error checking user {user_id}',
|
||||
'error': str(e)
|
||||
}]
|
||||
|
||||
async def get_service_status(self) -> dict:
|
||||
"""Get service status information"""
|
||||
return {
|
||||
'is_running': self.is_running,
|
||||
'check_interval': self.config.MONITOR_CHECK_INTERVAL,
|
||||
'daily_check_hour': self.config.MONITOR_DAILY_CHECK_HOUR,
|
||||
'warning_days': self.config.MONITOR_WARNING_DAYS,
|
||||
'last_check': datetime.now().strftime("%Y-%m-%d %H:%M:%S") if self.is_running else None
|
||||
}
|
||||
|
||||
async def create_subscription_monitor(bot, db: Database, config, api: RemnaWaveAPI = None) -> SubscriptionMonitorService:
|
||||
"""Create and return subscription monitor service"""
|
||||
return SubscriptionMonitorService(bot, db, config, api)
|
||||
+54
-2
@@ -16,7 +16,9 @@ TRANSLATIONS = {
|
||||
'trial_not_available': '❌ Тестовая подписка недоступна',
|
||||
'trial_success': '🎉 Тестовая подписка успешно активирована!\n\nТеперь вы можете найти её в разделе "Мои подписки".',
|
||||
'trial_error': '❌ Ошибка при создании тестовой подписки',
|
||||
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nТариф действует 3 дня!\n\nОграничение трафика - 2гб!',
|
||||
'trial_info': '🧪 Тестовая подписка выдается на три дня!\n\nНа тарифе действует ограничение в 3 дня\n\nОграничение по трафику - 2гб',
|
||||
'subscriptions_list': '📋 Список подписок в продаже:',
|
||||
|
||||
|
||||
# Balance menu
|
||||
'your_balance': '💰 Ваш баланс: {balance:.2f} руб.',
|
||||
@@ -25,6 +27,17 @@ TRANSLATIONS = {
|
||||
'topup_card': 'Пополнение картой',
|
||||
'topup_support': 'Через саппорт',
|
||||
'back': 'Назад',
|
||||
'system_management': 'Управление системой',
|
||||
'nodes_management': 'Управление нодами',
|
||||
'system_users': 'Системные пользователи',
|
||||
'system_statistics': 'Системная статистика',
|
||||
'restart_nodes': 'Перезагрузить ноды',
|
||||
'bulk_operations': 'Массовые операции',
|
||||
'search_user': 'Поиск пользователя',
|
||||
'user_details': 'Детали пользователя',
|
||||
'reset_traffic': 'Сбросить трафик',
|
||||
'disable_user': 'Отключить пользователя',
|
||||
'enable_user': 'Включить пользователя',
|
||||
|
||||
'send_message': 'Отправить сообщение',
|
||||
'send_to_user': 'Отправить пользователю',
|
||||
@@ -51,6 +64,7 @@ TRANSLATIONS = {
|
||||
'subscription_expires': 'Истекает: {date}',
|
||||
'subscription_expired': 'Истекла',
|
||||
'subscription_active': 'Активна до: {date}',
|
||||
'your_subscriptions': '👀 Список ваших подписок:',
|
||||
|
||||
# Payments
|
||||
'payment_created': '✅ Заявка на пополнение создана.\nОбратитесь к @{support} для оплаты.',
|
||||
@@ -67,6 +81,20 @@ TRANSLATIONS = {
|
||||
'promocode_expired': '❌ Промокод истек',
|
||||
'promocode_used': '❌ Промокод уже использован',
|
||||
'promocode_limit': '❌ Лимит использований исчерпан',
|
||||
'referral_program': 'Реферальная программа',
|
||||
'referral_conditions': 'Условия программы',
|
||||
'referral_link': 'Ваша реферальная ссылка',
|
||||
'referral_code': 'Ваш промокод',
|
||||
'referral_stats': 'Статистика',
|
||||
'total_referrals': 'Всего приглашено',
|
||||
'active_referrals': 'Активных рефералов',
|
||||
'total_earned': 'Заработано',
|
||||
'my_referrals': 'Мои рефералы',
|
||||
'referral_reward_referrer': 'За каждого приглашенного: 150₽',
|
||||
'referral_reward_referred': 'Бонус новичку: 150₽ после пополнения на 300₽',
|
||||
'referral_percentage': 'Процент с платежей: 25%',
|
||||
'no_referrals': 'У вас пока нет рефералов',
|
||||
'referral_welcome': 'Добро пожаловать по реферальной ссылке!',
|
||||
|
||||
# Purchase
|
||||
'insufficient_balance': '❌ Недостаточно средств на балансе',
|
||||
@@ -145,7 +173,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}',
|
||||
@@ -173,6 +211,20 @@ TRANSLATIONS = {
|
||||
'payment_card_info': '💳 For card payment contact @{support}',
|
||||
'no_payments': '❌ Payment history is empty',
|
||||
'payment_item': '{date}: ${amount:.2f} - {description} ({status})',
|
||||
'referral_program': 'Referral Program',
|
||||
'referral_conditions': 'Program Conditions',
|
||||
'referral_link': 'Your referral link',
|
||||
'referral_code': 'Your promo code',
|
||||
'referral_stats': 'Statistics',
|
||||
'total_referrals': 'Total invited',
|
||||
'active_referrals': 'Active referrals',
|
||||
'total_earned': 'Total earned',
|
||||
'my_referrals': 'My referrals',
|
||||
'referral_reward_referrer': 'For each invited: $150',
|
||||
'referral_reward_referred': 'Newbie bonus: $150 after topping up $300',
|
||||
'referral_percentage': 'Percentage from payments: 25%',
|
||||
'no_referrals': 'You have no referrals yet',
|
||||
'referral_welcome': 'Welcome via referral link!',
|
||||
|
||||
# Promocodes
|
||||
'enter_promocode': '🎁 Enter promocode:',
|
||||
|
||||
@@ -1,191 +1,131 @@
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
import logging
|
||||
from typing import Tuple, Optional, Dict, Any
|
||||
from database import Database
|
||||
from database import ReferralProgram, ReferralEarning # ДОБАВЛЕНО: импорт моделей
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def generate_username() -> str:
|
||||
"""Generate random username for RemnaWave"""
|
||||
return f"user_{secrets.token_hex(8)}"
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Generate random password"""
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(12))
|
||||
|
||||
def generate_promocode() -> str:
|
||||
"""Generate random promocode"""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""Validate email format"""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
def is_valid_amount(amount_str: str) -> Tuple[bool, float]:
|
||||
"""Validate and parse amount"""
|
||||
def is_valid_amount(text: str) -> Tuple[bool, float]:
|
||||
"""Validate amount input"""
|
||||
try:
|
||||
amount = float(amount_str.replace(',', '.'))
|
||||
# Remove spaces and replace comma with dot
|
||||
text = text.strip().replace(' ', '').replace(',', '.')
|
||||
|
||||
amount = float(text)
|
||||
|
||||
# Check if amount is positive and reasonable
|
||||
if amount <= 0:
|
||||
return False, 0
|
||||
if amount > 100000: # Max amount limit
|
||||
return False, 0
|
||||
return False, 0.0
|
||||
|
||||
if amount > 1000000: # Max 1M rubles
|
||||
return False, 0.0
|
||||
|
||||
# Round to 2 decimal places
|
||||
amount = round(amount, 2)
|
||||
|
||||
return True, amount
|
||||
except ValueError:
|
||||
return False, 0
|
||||
|
||||
def format_date(date: datetime, lang: str = 'ru') -> str:
|
||||
"""Format date for display"""
|
||||
if lang == 'ru':
|
||||
months = [
|
||||
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
|
||||
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
|
||||
]
|
||||
return f"{date.day} {months[date.month-1]} {date.year}"
|
||||
else:
|
||||
return date.strftime("%B %d, %Y")
|
||||
|
||||
def format_datetime(date: datetime, lang: str = 'ru') -> str:
|
||||
"""Format datetime for display"""
|
||||
if lang == 'ru':
|
||||
return date.strftime("%d.%m.%Y %H:%M")
|
||||
else:
|
||||
return date.strftime("%m/%d/%Y %H:%M")
|
||||
|
||||
def calculate_expiry_date(days: int) -> str:
|
||||
"""Calculate expiry date in ISO format"""
|
||||
expiry = datetime.utcnow() + timedelta(days=days)
|
||||
return expiry.isoformat() + 'Z'
|
||||
|
||||
def parse_telegram_id(text: str) -> Optional[int]:
|
||||
"""Parse Telegram ID from text"""
|
||||
try:
|
||||
telegram_id = int(text.strip())
|
||||
if telegram_id > 0:
|
||||
return telegram_id
|
||||
except ValueError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def format_traffic(gb: int, lang: str = 'ru') -> str:
|
||||
"""Format traffic limit for display"""
|
||||
if gb == 0:
|
||||
return "Безлимитный" if lang == 'ru' else "Unlimited"
|
||||
else:
|
||||
return f"{gb} ГБ" if lang == 'ru' else f"{gb} GB"
|
||||
|
||||
def paginate_list(items: List[Any], page: int, per_page: int = 10) -> Tuple[List[Any], int]:
|
||||
"""Paginate list of items"""
|
||||
total_pages = (len(items) + per_page - 1) // per_page
|
||||
start_idx = (page - 1) * per_page
|
||||
end_idx = start_idx + per_page
|
||||
return items[start_idx:end_idx], total_pages
|
||||
|
||||
def escape_markdown(text: str) -> str:
|
||||
"""Escape markdown special characters"""
|
||||
special_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']
|
||||
for char in special_chars:
|
||||
text = text.replace(char, f'\\{char}')
|
||||
return text
|
||||
|
||||
def truncate_text(text: str, max_length: int = 4000) -> str:
|
||||
"""Truncate text to fit Telegram message limits"""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length-3] + "..."
|
||||
|
||||
def validate_squad_uuid(uuid_str: str) -> bool:
|
||||
"""Validate UUID format"""
|
||||
try:
|
||||
uuid.UUID(uuid_str)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def format_subscription_info(subscription: Dict[str, Any], lang: str = 'ru') -> str:
|
||||
"""Format subscription information for display"""
|
||||
from translations import t
|
||||
|
||||
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
|
||||
|
||||
info = t('subscription_info', lang,
|
||||
name=subscription['name'],
|
||||
price=subscription['price'],
|
||||
days=subscription['duration_days'],
|
||||
traffic=traffic,
|
||||
description=subscription.get('description', '')
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
|
||||
expires_at: datetime, lang: str = 'ru') -> str:
|
||||
"""Format user subscription information"""
|
||||
from translations import t
|
||||
|
||||
traffic = format_traffic(subscription['traffic_limit_gb'], lang)
|
||||
|
||||
# Check if expired
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = t('subscription_expired', lang)
|
||||
else:
|
||||
status = t('subscription_active', lang, date=format_date(expires_at, lang))
|
||||
|
||||
info = f"📋 {subscription['name']}\n"
|
||||
info += f"⏱ {subscription['duration_days']} дней\n" if lang == 'ru' else f"⏱ {subscription['duration_days']} days\n"
|
||||
info += f"📊 {traffic}\n"
|
||||
info += f"🕒 {status}\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
info += f"\n{subscription['description']}"
|
||||
|
||||
return info
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return False, 0.0
|
||||
|
||||
def validate_promocode_format(code: str) -> bool:
|
||||
"""Validate promocode format"""
|
||||
if not code:
|
||||
return False
|
||||
|
||||
# Remove spaces and convert to uppercase
|
||||
code = code.strip().upper()
|
||||
|
||||
# Check length
|
||||
if len(code) < 3 or len(code) > 20:
|
||||
return False
|
||||
if not re.match(r'^[A-Z0-9]+$', code.upper()):
|
||||
|
||||
# Check that code contains only letters and numbers
|
||||
if not code.replace('_', '').isalnum():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def calculate_discount(original_price: float, promocode: Dict[str, Any]) -> float:
|
||||
"""Calculate discount amount"""
|
||||
if promocode.get('discount_percent'):
|
||||
return original_price * (promocode['discount_percent'] / 100)
|
||||
def validate_squad_uuid(uuid: str) -> bool:
|
||||
"""Validate squad UUID format"""
|
||||
if not uuid or not isinstance(uuid, str):
|
||||
return False
|
||||
|
||||
uuid = uuid.strip()
|
||||
|
||||
# Basic UUID format validation
|
||||
if len(uuid) < 8:
|
||||
return False
|
||||
|
||||
# UUID should contain only hex characters and hyphens
|
||||
allowed_chars = set('0123456789abcdefABCDEF-')
|
||||
if not all(c in allowed_chars for c in uuid):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def parse_telegram_id(text: str) -> Optional[int]:
|
||||
"""Parse Telegram ID from text"""
|
||||
try:
|
||||
# Remove spaces and common prefixes
|
||||
text = text.strip().replace(' ', '')
|
||||
|
||||
# Remove common prefixes
|
||||
if text.startswith('@'):
|
||||
text = text[1:]
|
||||
|
||||
if text.startswith('id'):
|
||||
text = text[2:]
|
||||
|
||||
# Try to convert to int
|
||||
telegram_id = int(text)
|
||||
|
||||
# Validate that it's a reasonable Telegram ID
|
||||
if telegram_id <= 0 or telegram_id > 9999999999: # Max 10 digits
|
||||
return None
|
||||
|
||||
return telegram_id
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def generate_username() -> str:
|
||||
"""Generate random username for RemnaWave"""
|
||||
prefix = "user_"
|
||||
random_part = ''.join(secrets.choice(string.ascii_lowercase + string.digits) for _ in range(8))
|
||||
return prefix + random_part
|
||||
|
||||
def generate_password() -> str:
|
||||
"""Generate random password for RemnaWave"""
|
||||
return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(12))
|
||||
|
||||
def calculate_expiry_date(days: int) -> str:
|
||||
"""Calculate expiry date for subscription"""
|
||||
expiry_date = datetime.now() + timedelta(days=days)
|
||||
return expiry_date.isoformat() + 'Z'
|
||||
|
||||
def format_datetime(dt: datetime, language: str = 'ru') -> str:
|
||||
"""Format datetime for display"""
|
||||
if not dt:
|
||||
return "N/A"
|
||||
|
||||
if language == 'ru':
|
||||
return dt.strftime('%d.%m.%Y %H:%M')
|
||||
else:
|
||||
return min(promocode.get('discount_amount', 0), original_price)
|
||||
return dt.strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
def format_payment_status(status: str, lang: str = 'ru') -> str:
|
||||
"""Format payment status for display"""
|
||||
status_map = {
|
||||
'pending': 'В ожидании' if lang == 'ru' else 'Pending',
|
||||
'completed': 'Завершен' if lang == 'ru' else 'Completed',
|
||||
'cancelled': 'Отменен' if lang == 'ru' else 'Cancelled',
|
||||
'failed': 'Ошибка' if lang == 'ru' else 'Failed'
|
||||
}
|
||||
return status_map.get(status, status)
|
||||
|
||||
def clean_phone_number(phone: str) -> str:
|
||||
"""Clean and format phone number"""
|
||||
# Remove all non-digit characters
|
||||
digits = re.sub(r'\D', '', phone)
|
||||
def format_date(dt: datetime, language: str = 'ru') -> str:
|
||||
"""Format date for display"""
|
||||
if not dt:
|
||||
return "N/A"
|
||||
|
||||
# Handle Russian phone numbers
|
||||
if digits.startswith('8') and len(digits) == 11:
|
||||
digits = '7' + digits[1:]
|
||||
elif digits.startswith('9') and len(digits) == 10:
|
||||
digits = '7' + digits
|
||||
|
||||
return digits
|
||||
if language == 'ru':
|
||||
return dt.strftime('%d.%m.%Y')
|
||||
else:
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
|
||||
def format_bytes(bytes_value: int) -> str:
|
||||
"""Format bytes to human readable format"""
|
||||
@@ -200,36 +140,410 @@ def format_bytes(bytes_value: int) -> str:
|
||||
value /= 1024
|
||||
unit_index += 1
|
||||
|
||||
if unit_index == 0:
|
||||
return f"{int(value)} {units[unit_index]}"
|
||||
else:
|
||||
if value >= 100:
|
||||
return f"{value:.0f} {units[unit_index]}"
|
||||
elif value >= 10:
|
||||
return f"{value:.1f} {units[unit_index]}"
|
||||
else:
|
||||
return f"{value:.2f} {units[unit_index]}"
|
||||
|
||||
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 format_payment_status(status: str, language: str = 'ru') -> str:
|
||||
"""Format payment status for display"""
|
||||
status_map = {
|
||||
'ru': {
|
||||
'pending': 'Ожидает',
|
||||
'completed': 'Завершен',
|
||||
'cancelled': 'Отменен',
|
||||
'failed': 'Ошибка'
|
||||
},
|
||||
'en': {
|
||||
'pending': 'Pending',
|
||||
'completed': 'Completed',
|
||||
'cancelled': 'Cancelled',
|
||||
'failed': 'Failed'
|
||||
}
|
||||
}
|
||||
|
||||
return status_map.get(language, status_map['ru']).get(status, status)
|
||||
|
||||
def format_subscription_info(subscription: Dict[str, Any], language: str = 'ru') -> str:
|
||||
"""Format subscription information for display"""
|
||||
text = ""
|
||||
|
||||
if language == 'ru':
|
||||
text += f"📋 **Подписка: {subscription['name']}**\n\n"
|
||||
text += f"💰 Цена: {subscription['price']} руб.\n"
|
||||
text += f"⏱ Длительность: {subscription['duration_days']} дн.\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
|
||||
else:
|
||||
text += f"📊 Лимит трафика: Безлимит\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
text += f"\n📝 Описание:\n{subscription['description']}"
|
||||
else:
|
||||
text += f"📋 **Subscription: {subscription['name']}**\n\n"
|
||||
text += f"💰 Price: ${subscription['price']}\n"
|
||||
text += f"⏱ Duration: {subscription['duration_days']} days\n"
|
||||
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
|
||||
else:
|
||||
text += f"📊 Traffic limit: Unlimited\n"
|
||||
|
||||
if subscription.get('description'):
|
||||
text += f"\n📝 Description:\n{subscription['description']}"
|
||||
|
||||
return text
|
||||
|
||||
def format_user_subscription_info(user_sub: Dict[str, Any], subscription: Dict[str, Any],
|
||||
expires_at: datetime, language: str = 'ru') -> str:
|
||||
"""Format user subscription information for display - ИСПРАВЛЕНО: добавлены пометки для импортированных"""
|
||||
text = ""
|
||||
|
||||
if language == 'ru':
|
||||
text += f"📋 **{subscription['name']}**\n\n"
|
||||
|
||||
# Status
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = "❌ Истекла"
|
||||
days_left = 0
|
||||
elif not user_sub.get('is_active', True):
|
||||
status = "⏸ Приостановлена"
|
||||
days_left = (expires_at - now).days
|
||||
else:
|
||||
days_left = (expires_at - now).days
|
||||
status = f"✅ Активна"
|
||||
|
||||
text += f"🔘 Статус: {status}\n"
|
||||
text += f"📅 Истекает: {format_datetime(expires_at, language)}\n"
|
||||
|
||||
if days_left > 0:
|
||||
text += f"⏰ Осталось: {days_left} дн.\n"
|
||||
|
||||
# Traffic info
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Лимит трафика: {subscription['traffic_limit_gb']} ГБ\n"
|
||||
else:
|
||||
text += f"📊 Лимит трафика: Безлимит\n"
|
||||
|
||||
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'импорт' in subscription.get('description', '').lower()):
|
||||
text += f"\n🔄 Тип: Импортированная из старой системы\n"
|
||||
text += f"ℹ️ Продление недоступно"
|
||||
|
||||
# Description
|
||||
if subscription.get('description') and not ('импорт' in subscription.get('description', '').lower()):
|
||||
text += f"\n📝 {subscription['description']}"
|
||||
else:
|
||||
text += f"📋 **{subscription['name']}**\n\n"
|
||||
|
||||
# Status
|
||||
now = datetime.utcnow()
|
||||
if expires_at < now:
|
||||
status = "❌ Expired"
|
||||
days_left = 0
|
||||
elif not user_sub.get('is_active', True):
|
||||
status = "⏸ Suspended"
|
||||
days_left = (expires_at - now).days
|
||||
else:
|
||||
days_left = (expires_at - now).days
|
||||
status = f"✅ Active"
|
||||
|
||||
text += f"🔘 Status: {status}\n"
|
||||
text += f"📅 Expires: {format_datetime(expires_at, language)}\n"
|
||||
|
||||
if days_left > 0:
|
||||
text += f"⏰ Days left: {days_left}\n"
|
||||
|
||||
# Traffic info
|
||||
if subscription['traffic_limit_gb'] > 0:
|
||||
text += f"📊 Traffic limit: {subscription['traffic_limit_gb']} GB\n"
|
||||
else:
|
||||
text += f"📊 Traffic limit: Unlimited\n"
|
||||
|
||||
if subscription.get('name') == "Старая подписка" or (subscription.get('description') and 'import' in subscription.get('description', '').lower()):
|
||||
text += f"\n🔄 Type: Imported from old system\n"
|
||||
text += f"ℹ️ Extension not available"
|
||||
|
||||
# Description
|
||||
if subscription.get('description') and not ('import' in subscription.get('description', '').lower()):
|
||||
text += f"\n📝 {subscription['description']}"
|
||||
|
||||
return text
|
||||
|
||||
def log_user_action(user_id: int, action: str, details: str = ""):
|
||||
"""Log user action"""
|
||||
logger.info(f"User {user_id} - {action}: {details}")
|
||||
logger.info(f"USER_ACTION: {user_id} - {action}" + (f" - {details}" if details else ""))
|
||||
|
||||
class States:
|
||||
"""State constants for FSM"""
|
||||
WAITING_LANGUAGE = "waiting_language"
|
||||
WAITING_AMOUNT = "waiting_amount"
|
||||
WAITING_PROMOCODE = "waiting_promocode"
|
||||
async def process_referral_rewards(user_id: int, amount: float, payment_id: int, db: Database, bot=None):
|
||||
"""Process referral rewards after successful payment - ИСПРАВЛЕНА СИГНАТУРА"""
|
||||
try:
|
||||
# Получаем конфигурацию из переменных окружения напрямую
|
||||
import os
|
||||
|
||||
threshold = float(os.getenv('REFERRAL_THRESHOLD', '300.0'))
|
||||
first_reward = float(os.getenv('REFERRAL_FIRST_REWARD', '150.0'))
|
||||
referred_bonus = float(os.getenv('REFERRAL_REFERRED_BONUS', '150.0'))
|
||||
percentage = float(os.getenv('REFERRAL_PERCENTAGE', '0.25'))
|
||||
|
||||
# Проверяем есть ли у пользователя реферер
|
||||
referral = await db.get_referral_by_referred_id(user_id)
|
||||
|
||||
if not referral:
|
||||
logger.debug(f"No referral found for user {user_id}")
|
||||
return
|
||||
|
||||
# Получаем информацию о пользователе
|
||||
user = await db.get_user_by_telegram_id(user_id)
|
||||
if not user:
|
||||
logger.error(f"User {user_id} not found")
|
||||
return
|
||||
|
||||
logger.info(f"Processing referral rewards for user {user_id}, amount {amount}, referrer {referral.referrer_id}")
|
||||
|
||||
if not referral.first_reward_paid and user.balance >= threshold:
|
||||
logger.info(f"Processing first reward for referral {referral.id} (threshold: {threshold}, reward: {first_reward})")
|
||||
|
||||
await db.add_balance(referral.referrer_id, first_reward)
|
||||
|
||||
# Создаем запись о платеже для реферера
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=first_reward,
|
||||
payment_type='referral',
|
||||
description=f'Первая награда за реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
# Выплачиваем первую награду рефереру (записываем в историю)
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=first_reward,
|
||||
earning_type='first_reward',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"First reward paid: {first_reward}₽ to referrer {referral.referrer_id}")
|
||||
|
||||
if bot:
|
||||
try:
|
||||
# Уведомляем реферера
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"🎉 Поздравляем! Ваш реферал пополнил баланс на {threshold}₽+\n\n"
|
||||
f"💰 Вам начислено {first_reward}₽ за приведенного друга!\n"
|
||||
f"Теперь вы будете получать {percentage*100:.0f}% с каждого его следующего платежа."
|
||||
)
|
||||
|
||||
# Уведомляем самого пользователя
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
f"🎁 Бонус активирован! Вам начислено {referred_bonus}₽ за переход по реферальной ссылке!"
|
||||
)
|
||||
|
||||
# Добавляем бонус рефералу
|
||||
await db.add_balance(user_id, referred_bonus)
|
||||
await db.create_payment(
|
||||
user_id=user_id,
|
||||
amount=referred_bonus,
|
||||
payment_type='referral',
|
||||
description='Бонус за переход по реферальной ссылке',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
logger.info(f"Referral bonus notifications sent and balance updated")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send referral notifications: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create first reward earning")
|
||||
|
||||
if amount > 0 and referral.first_reward_paid: # ИЗМЕНЕНО: добавлено условие first_reward_paid
|
||||
percentage_reward = amount * percentage
|
||||
|
||||
if percentage_reward >= 0.01: # Минимум 1 копейка
|
||||
await db.add_balance(referral.referrer_id, percentage_reward)
|
||||
|
||||
# Создаем запись о платеже для реферера
|
||||
await db.create_payment(
|
||||
user_id=referral.referrer_id,
|
||||
amount=percentage_reward,
|
||||
payment_type='referral',
|
||||
description=f'{percentage*100:.0f}% дохода от реферала ID:{user_id}',
|
||||
status='completed'
|
||||
)
|
||||
|
||||
success = await db.create_referral_earning(
|
||||
referrer_id=referral.referrer_id,
|
||||
referred_id=user_id,
|
||||
amount=percentage_reward,
|
||||
earning_type='percentage',
|
||||
related_payment_id=payment_id
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"Percentage reward paid: {percentage_reward:.2f}₽ ({percentage*100:.0f}%) to referrer {referral.referrer_id}")
|
||||
|
||||
if bot and percentage_reward >= 1.0: # Уведомляем только если сумма >= 1₽
|
||||
try:
|
||||
await bot.send_message(
|
||||
referral.referrer_id,
|
||||
f"💰 Реферальный доход!\n\n"
|
||||
f"Ваш реферал совершил платеж на {amount:.2f}₽\n"
|
||||
f"Вам начислено: {percentage_reward:.2f}₽ ({percentage*100:.0f}%)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send percentage notification: {e}")
|
||||
else:
|
||||
logger.error(f"Failed to create percentage earning")
|
||||
elif amount > 0 and not referral.first_reward_paid:
|
||||
logger.info(f"Skipping percentage reward for user {user_id} - first reward not yet paid")
|
||||
|
||||
# Admin states
|
||||
ADMIN_CREATE_SUB_NAME = "admin_create_sub_name"
|
||||
ADMIN_CREATE_SUB_DESC = "admin_create_sub_desc"
|
||||
ADMIN_CREATE_SUB_PRICE = "admin_create_sub_price"
|
||||
ADMIN_CREATE_SUB_DAYS = "admin_create_sub_days"
|
||||
ADMIN_CREATE_SUB_TRAFFIC = "admin_create_sub_traffic"
|
||||
ADMIN_CREATE_SUB_SQUAD = "admin_create_sub_squad"
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing referral rewards: {e}")
|
||||
|
||||
async def create_referral_from_start_param(user_telegram_id: int, start_param: str, db: Database, bot=None):
|
||||
"""Create referral relationship from start parameter"""
|
||||
try:
|
||||
if not start_param.startswith("ref_"):
|
||||
return False
|
||||
|
||||
referrer_id = int(start_param.replace("ref_", ""))
|
||||
|
||||
# Проверяем что это не тот же пользователь
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to refer themselves")
|
||||
return False
|
||||
|
||||
# Проверяем что у пользователя еще нет реферера
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
# Генерируем промокод реферера
|
||||
referral_code = await db.generate_unique_referral_code(referrer_id)
|
||||
|
||||
# Создаем реферальную связь
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral: {referrer_id} -> {user_telegram_id} with code {referral_code}")
|
||||
|
||||
# Уведомляем реферера
|
||||
if bot:
|
||||
try:
|
||||
referrer = await db.get_user_by_telegram_id(referrer_id)
|
||||
if referrer:
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 Отлично! По вашей ссылке зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите 150₽ после того, как он пополнит баланс на 300₽.\n"
|
||||
f"И будете получать 25% с каждого его платежа!"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"Invalid referral parameter: {start_param}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from start param: {e}")
|
||||
return False
|
||||
|
||||
async def create_referral_from_promocode(user_telegram_id: int, referral_code: str, db: Database, bot=None):
|
||||
"""Create referral relationship from promocode - ИСПРАВЛЕННАЯ ВЕРСИЯ"""
|
||||
try:
|
||||
if not referral_code.startswith("REF"):
|
||||
return False
|
||||
|
||||
logger.info(f"Trying to use referral code {referral_code} for user {user_telegram_id}")
|
||||
|
||||
# Ищем реферера по коду - ИСПРАВЛЕНО: используем правильный импорт
|
||||
async with db.session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
result = await session.execute(
|
||||
select(ReferralProgram).where(ReferralProgram.referral_code == referral_code)
|
||||
)
|
||||
referral_record = result.scalar_one_or_none()
|
||||
|
||||
if not referral_record:
|
||||
logger.warning(f"No referrer found for code {referral_code}")
|
||||
return False
|
||||
|
||||
referrer_id = referral_record.referrer_id
|
||||
|
||||
# Проверяем что пользователь не пытается использовать свой код
|
||||
if referrer_id == user_telegram_id:
|
||||
logger.warning(f"User {user_telegram_id} tried to use own referral code")
|
||||
return False
|
||||
|
||||
# Проверяем что у пользователя еще нет реферера
|
||||
existing_referral = await db.get_referral_by_referred_id(user_telegram_id)
|
||||
|
||||
if existing_referral:
|
||||
logger.info(f"User {user_telegram_id} already has referrer")
|
||||
return False
|
||||
|
||||
# Создаем реферальную связь
|
||||
referral = await db.create_referral(referrer_id, user_telegram_id, referral_code)
|
||||
|
||||
if referral:
|
||||
logger.info(f"Created referral from promocode: {referrer_id} -> {user_telegram_id}")
|
||||
|
||||
# Уведомляем реферера
|
||||
if bot:
|
||||
try:
|
||||
await bot.send_message(
|
||||
referrer_id,
|
||||
f"🎉 По вашему промокоду {referral_code} зарегистрировался новый пользователь!\n\n"
|
||||
f"Вы получите 150₽ после того, как он пополнит баланс на 300₽."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to notify referrer: {e}")
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating referral from promocode: {e}")
|
||||
return False
|
||||
|
||||
def generate_referral_link(bot_username: str, user_id: int) -> str:
|
||||
"""Generate referral link for user"""
|
||||
if not bot_username:
|
||||
return ""
|
||||
|
||||
ADMIN_ADD_BALANCE_USER = "admin_add_balance_user"
|
||||
ADMIN_ADD_BALANCE_AMOUNT = "admin_add_balance_amount"
|
||||
if bot_username.startswith('@'):
|
||||
bot_username = bot_username[1:]
|
||||
|
||||
ADMIN_CREATE_PROMO_CODE = "admin_create_promo_code"
|
||||
ADMIN_CREATE_PROMO_DISCOUNT = "admin_create_promo_discount"
|
||||
ADMIN_CREATE_PROMO_LIMIT = "admin_create_promo_limit"
|
||||
return f"https://t.me/{bot_username}?start=ref_{user_id}"
|
||||
|
||||
def validate_referral_code(code: str) -> bool:
|
||||
"""Validate referral code format"""
|
||||
if not code or not code.startswith("REF"):
|
||||
return False
|
||||
if len(code) < 4 or len(code) > 20:
|
||||
return False
|
||||
return True
|
||||
|
||||
def format_referral_stats(stats: dict, lang: str = 'ru') -> str:
|
||||
"""Format referral statistics for display"""
|
||||
if lang == 'ru':
|
||||
return (f"👥 Приглашено: {stats['total_referrals']}\n"
|
||||
f"✅ Активных: {stats['active_referrals']}\n"
|
||||
f"💰 Заработано: {stats['total_earned']:.2f}₽")
|
||||
else:
|
||||
return (f"👥 Invited: {stats['total_referrals']}\n"
|
||||
f"✅ Active: {stats['active_referrals']}\n"
|
||||
f"💰 Earned: ${stats['total_earned']:.2f}")
|
||||
|
||||
Reference in New Issue
Block a user