From 3992121397316e4a8d95b22db877662f5ac9e8bb Mon Sep 17 00:00:00 2001 From: Evgeniy Date: Sun, 5 Apr 2026 12:18:33 +0300 Subject: [PATCH] first step --- .env.example | 25 + .gitignore | 42 + .qwen/settings.json | 7 + __init__.py | 1 + config/__init__.py | 59 ++ core/__init__.py | 518 +++++++++++ core/remnawave.py | 439 ++++++++++ core/update_tickets.sql | 38 + handlers/__init__.py | 1793 +++++++++++++++++++++++++++++++++++++++ keyboards/__init__.py | 150 ++++ keyboards/payment.py | 24 + locales/__init__.py | 91 ++ locales/en.json | 45 + locales/kz.json | 45 + locales/ru.json | 45 + main.py | 41 + requirements.txt | 4 + 17 files changed, 3367 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .qwen/settings.json create mode 100644 __init__.py create mode 100644 config/__init__.py create mode 100644 core/__init__.py create mode 100644 core/remnawave.py create mode 100644 core/update_tickets.sql create mode 100644 handlers/__init__.py create mode 100644 keyboards/__init__.py create mode 100644 keyboards/payment.py create mode 100644 locales/__init__.py create mode 100644 locales/en.json create mode 100644 locales/kz.json create mode 100644 locales/ru.json create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1e71f19 --- /dev/null +++ b/.env.example @@ -0,0 +1,25 @@ +# Telegram Bot +BOT_TOKEN=YOUR_BOT_TOKEN_HERE + +# Database +DB_HOST=localhost +DB_USER=root +DB_PASSWORD= +DB_NAME=botyobshik + +# Support +SUPPORT_USERNAME=@support_username + +# Payment +PAYMENT_CHANNEL_ID=-1003591520479 +PAYMENT_THREAD_ID=5910 +PAYMENT_TICKET_CHANNEL_ID=-1003591520479 +PAYMENT_TICKET_THREAD_ID=788 +PAYMENT_PHONE=+7 904 788 77 35 + +# Admin +ADMIN_IDS=123456789,987654321 + +# Remnawave API +REMWAVE_API_URL=https://panel.remnawave.com/api +REMWAVE_API_KEY=your_api_key_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e71dd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env +.env.local + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..a3522da --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebFetch(github.com)" + ] + } +} \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..cb585e0 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# OreolRP Subscription Bot diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..f572be1 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,59 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +# Загрузка переменных окружения +load_dotenv() + + +class Config: + """Класс для управления конфигурацией""" + + def __init__(self): + # Telegram + self.bot_token = os.getenv("BOT_TOKEN", "") + + # Database + self.db_host = os.getenv("DB_HOST", "localhost") + self.db_user = os.getenv("DB_USER", "root") + self.db_password = os.getenv("DB_PASSWORD", "") + self.db_name = os.getenv("DB_NAME", "botyobshik") + + # Support + self.support_username = os.getenv("SUPPORT_USERNAME", "@support_username") + + # Payment + self.payment_channel_id = os.getenv("PAYMENT_CHANNEL_ID", "") + self.payment_thread_id = os.getenv("PAYMENT_THREAD_ID", "") + self.payment_ticket_channel_id = os.getenv("PAYMENT_TICKET_CHANNEL_ID", "") + self.payment_ticket_thread_id = os.getenv("PAYMENT_TICKET_THREAD_ID", "") + self.payment_phone = os.getenv("PAYMENT_PHONE", "+7 904 788 77 35") + + # Admin + admin_ids_str = os.getenv("ADMIN_IDS", "") + self.admin_ids = [int(x.strip()) for x in admin_ids_str.split(",") if x.strip().isdigit()] + + # Remnawave API + self.remwave_api_url = os.getenv("REMWAVE_API_URL", "") + self.remwave_api_key = os.getenv("REMWAVE_API_KEY", "") + + @property + def db_config(self) -> dict: + """Конфигурация подключения к БД""" + return { + "host": self.db_host, + "user": self.db_user, + "password": self.db_password, + } + + @property + def db_config_with_name(self) -> dict: + """Конфигурация подключения к БД с именем базы""" + return { + **self.db_config, + "database": self.db_name + } + + +# Глобальный экземпляр конфигурации +config = Config() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..62cf529 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,518 @@ +import mysql.connector +from mysql.connector import Error +from datetime import datetime, timedelta +from typing import Optional +import asyncio +from functools import wraps + +from config import config + + +def run_sync(func): + """Декоратор для запуска синхронных функций в async""" + @wraps(func) + async def wrapper(*args, **kwargs): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, func, *args, **kwargs) + return wrapper + + +class Database: + """Класс для работы с базой данных""" + + def __init__(self): + self.db_config = config.db_config + self.db_config_with_name = config.db_config_with_name + + def get_connection(self, with_db: bool = True): + """Подключение к MySQL""" + db_config = self.db_config_with_name if with_db else self.db_config + try: + connection = mysql.connector.connect(**db_config) + return connection + except Error as e: + print(f"Ошибка подключения к БД: {e}") + return None + + async def create_database_if_not_exists(self) -> bool: + """Создание базы данных если она не существует""" + connection = self.get_connection(with_db=False) + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + f"CREATE DATABASE IF NOT EXISTS `{config.db_name}` " + "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" + ) + connection.commit() + cursor.close() + connection.close() + print(f"База данных '{config.db_name}' проверена/создана") + return True + except Error as e: + print(f"Ошибка создания БД: {e}") + return False + + async def create_tables(self) -> bool: + """Создание таблиц в базе данных""" + connection = self.get_connection(with_db=True) + if not connection: + return False + + try: + cursor = connection.cursor() + + # Таблица подписок + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscriptions ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNIQUE NOT NULL, + username VARCHAR(255), + is_active BOOLEAN DEFAULT FALSE, + subscription_start DATETIME, + subscription_end DATETIME, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + + # Таблица языков пользователей + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_languages ( + user_id BIGINT PRIMARY KEY, + language_code VARCHAR(10) DEFAULT 'ru', + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """) + + # Таблица тикетов + cursor.execute(""" + CREATE TABLE IF NOT EXISTS tickets ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + username VARCHAR(255), + message TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'open', + admin_response TEXT, + closed_by VARCHAR(255), + closed_at DATETIME, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_status (status) + ) + """) + + # Индексы + cursor.execute("CREATE INDEX IF NOT EXISTS idx_user_id ON subscriptions(user_id)") + + connection.commit() + cursor.close() + connection.close() + print("Таблицы созданы/проверены") + return True + except Error as e: + print(f"Ошибка создания таблиц: {e}") + return False + + async def initialize(self): + """Инициализация БД: создание БД и таблиц""" + await self.create_database_if_not_exists() + await self.create_tables() + + @run_sync + def get_user_subscription(self, user_id: int) -> bool: + """Проверка статуса подписки пользователя""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT is_active FROM subscriptions WHERE user_id = %s", + (user_id,) + ) + result = cursor.fetchone() + cursor.close() + connection.close() + + return result['is_active'] if result else False + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def create_user(self, user_id: int, username: str) -> bool: + """Создание нового пользователя в базе""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + """INSERT INTO subscriptions (user_id, username, is_active) + VALUES (%s, %s, FALSE) + ON DUPLICATE KEY UPDATE username = %s""", + (user_id, username, username) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def activate_subscription(self, user_id: int, days: int = 30) -> bool: + """Активация подписки на указанное количество дней""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + now = datetime.now() + end_date = now + timedelta(days=days) + + cursor.execute( + """INSERT INTO subscriptions (user_id, is_active, subscription_start, subscription_end) + VALUES (%s, TRUE, %s, %s) + ON DUPLICATE KEY UPDATE + is_active = TRUE, + subscription_start = %s, + subscription_end = %s""", + (user_id, now, end_date, now, end_date) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def deactivate_subscription(self, user_id: int) -> bool: + """Деактивация подписки""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + "UPDATE subscriptions SET is_active = FALSE WHERE user_id = %s", + (user_id,) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def get_subscription_end_date(self, user_id: int) -> Optional[datetime]: + """Получение даты окончания подписки""" + connection = self.get_connection() + if not connection: + return None + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT subscription_end FROM subscriptions WHERE user_id = %s", + (user_id,) + ) + result = cursor.fetchone() + cursor.close() + connection.close() + + return result['subscription_end'] if result and result['subscription_end'] else None + except Error as e: + print(f"Ошибка БД: {e}") + return None + + @run_sync + def get_user_language(self, user_id: int) -> str: + """Получение языка пользователя""" + connection = self.get_connection() + if not connection: + return "ru" + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT language_code FROM user_languages WHERE user_id = %s", + (user_id,) + ) + result = cursor.fetchone() + cursor.close() + connection.close() + + return result['language_code'] if result else "ru" + except Error as e: + print(f"Ошибка БД: {e}") + return "ru" + + @run_sync + def set_user_language(self, user_id: int, language: str) -> bool: + """Установка языка пользователя""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + """INSERT INTO user_languages (user_id, language_code) + VALUES (%s, %s) + ON DUPLICATE KEY UPDATE language_code = %s""", + (user_id, language, language) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def create_ticket(self, user_id: int, username: str, message: str) -> Optional[int]: + """Создание тикета""" + connection = self.get_connection() + if not connection: + return None + + try: + cursor = connection.cursor() + cursor.execute( + """INSERT INTO tickets (user_id, username, message, status) + VALUES (%s, %s, %s, 'open')""", + (user_id, username, message) + ) + connection.commit() + ticket_id = cursor.lastrowid + cursor.close() + connection.close() + return ticket_id + except Error as e: + print(f"Ошибка БД: {e}") + return None + + @run_sync + def get_user_tickets(self, user_id: int) -> list: + """Получение всех тикетов пользователя""" + connection = self.get_connection() + if not connection: + return [] + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + """SELECT id, message, status, admin_response, created_at + FROM tickets + WHERE user_id = %s + ORDER BY created_at DESC""", + (user_id,) + ) + tickets = cursor.fetchall() + cursor.close() + connection.close() + return tickets + except Error as e: + print(f"Ошибка БД: {e}") + return [] + + @run_sync + def get_ticket(self, ticket_id: int) -> Optional[dict]: + """Получение информации о тикете""" + connection = self.get_connection() + if not connection: + return None + + try: + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT * FROM tickets WHERE id = %s", + (ticket_id,) + ) + ticket = cursor.fetchone() + cursor.close() + connection.close() + return ticket + except Error as e: + print(f"Ошибка БД: {e}") + return None + + @run_sync + def update_ticket_response(self, ticket_id: int, admin_response: str, status: str = 'answered') -> bool: + """Обновление ответа администратора в тикете""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + """UPDATE tickets + SET admin_response = %s, status = %s + WHERE id = %s""", + (admin_response, status, ticket_id) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def close_ticket(self, ticket_id, closed_by): + """Закрытие тикета""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + cursor.execute( + """UPDATE tickets + SET status = 'closed', closed_by = %s, closed_at = %s + WHERE id = %s""", + (closed_by, datetime.now(), ticket_id) + ) + connection.commit() + cursor.close() + connection.close() + return True + except Error as e: + print(f"Ошибка БД: {e}") + return False + + @run_sync + def append_ticket_message(self, ticket_id: int, user_message: str) -> bool: + """Добавление сообщения к тикету""" + connection = self.get_connection() + if not connection: + return False + + try: + cursor = connection.cursor() + # Получаем текущее сообщение + cursor.execute("SELECT message FROM tickets WHERE id = %s", (ticket_id,)) + result = cursor.fetchone() + if result: + current_message = result[0] + new_message = f"{current_message}\n\n➕ Дополнение:\n{user_message}" + cursor.execute( + "UPDATE tickets SET message = %s WHERE id = %s", + (new_message, ticket_id) + ) + connection.commit() + cursor.close() + connection.close() + return True + return False + except Error as e: + print(f"Ошибка БД: {e}") + return False + + + @run_sync + def get_all_users_paginated(self, page, per_page=10): + """ + Получение всех пользователей с пагинацией + + Args: + page: Номер страницы (начиная с 1) + per_page: Количество пользователей на странице + + Returns: + dict: {'users': [...], 'total': int, 'total_pages': int, 'current_page': int} + """ + connection = self.get_connection() + if not connection: + return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page} + + try: + cursor = connection.cursor(dictionary=True) + + # Получаем общее количество пользователей + cursor.execute("SELECT COUNT(*) as count FROM subscriptions") + total = cursor.fetchone()['count'] + + # Вычисляем общее количество страниц + total_pages = (total + per_page - 1) // per_page if total > 0 else 0 + + # Ограничиваем page в допустимых пределах + page = max(1, min(page, total_pages if total_pages > 0 else 1)) + + # Получаем пользователей с пагинацией + offset = (page - 1) * per_page + cursor.execute( + """SELECT id, user_id, username, is_active, subscription_start, subscription_end, created_at + FROM subscriptions + ORDER BY created_at DESC + LIMIT %s OFFSET %s""", + (per_page, offset) + ) + users = cursor.fetchall() + + cursor.close() + connection.close() + + return { + 'users': users, + 'total': total, + 'total_pages': total_pages, + 'current_page': page + } + except Error as e: + print(f"Ошибка БД: {e}") + return {'users': [], 'total': 0, 'total_pages': 0, 'current_page': page} + + @run_sync + def get_users_count(self): + """ + Получение количества пользователей по статусам + + Returns: + dict: {'total': int, 'active': int, 'inactive': int} + """ + connection = self.get_connection() + if not connection: + return {'total': 0, 'active': 0, 'inactive': 0} + + try: + cursor = connection.cursor(dictionary=True) + + cursor.execute("SELECT COUNT(*) as count FROM subscriptions") + total = cursor.fetchone()['count'] + + cursor.execute("SELECT COUNT(*) as count FROM subscriptions WHERE is_active = TRUE") + active = cursor.fetchone()['count'] + + cursor.close() + connection.close() + + return { + 'total': total, + 'active': active, + 'inactive': total - active + } + except Error as e: + print(f"Ошибка БД: {e}") + return {'total': 0, 'active': 0, 'inactive': 0} + + +# Глобальный экземпляр базы данных +db = Database() diff --git a/core/remnawave.py b/core/remnawave.py new file mode 100644 index 0000000..50917cc --- /dev/null +++ b/core/remnawave.py @@ -0,0 +1,439 @@ +import aiohttp +from config import config + + +class RemnawaveAPI: + """Клиент для работы с Remnawave API""" + + def __init__(self): + self.api_url = config.remwave_api_url + self.api_key = config.remwave_api_key + self.headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + async def create_subscription(self, user_id: int, days: int, tariff: str) -> dict: + """ + Создание пользователя (подписки) в Remnawave + + Args: + user_id: Telegram user ID + days: Количество дней подписки + tariff: Название тарифа + + Returns: + dict: Результат создания подписки + """ + # Эндпоинт для создания пользователя + url = f"{self.api_url}/api/users" + + # Генерируем случайный username и пароль + import random + import string + random_username = f"subs{user_id}" + random_password = ''.join(random.choices(string.ascii_letters + string.digits, k=12)) + + # Вычисляем дату истечения + from datetime import datetime, timedelta + expire_at = (datetime.now() + timedelta(days=days)).isoformat() + + # Данные для создания пользователя (формат Remnawave) + data = { + "username": random_username, + "password": random_password, + "telegramId": user_id, # Число, не строка + "expireAt": expire_at # ISO формат даты + } + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url, json=data, headers=self.headers) as response: + result = await response.json() + + if response.status in [200, 201]: + print(f"✅ Подписка создана для user {user_id}") + print(f"📋 Ответ API: {result}") + + # Получаем ссылку на подключение + # API возвращает в формате: {'response': {'subscriptionUrl': '...'}} + subscription_url = None + if isinstance(result, dict): + # Проверяем response.subscriptionUrl (основной вариант) + response_data = result.get("response", {}) + if isinstance(response_data, dict): + subscription_url = response_data.get("subscriptionUrl") + + # Если не нашли, проверяем другие варианты + if not subscription_url: + subscription_url = ( + result.get("subscriptionUrl") or + result.get("subscription") or + result.get("link") or + result.get("url") or + result.get("data", {}).get("subscriptionUrl") or + result.get("data", {}).get("subscription") or + result.get("data", {}).get("link") + ) + + print(f"🔗 Subscription URL: {subscription_url}") + + return { + "success": True, + "data": result, + "subscription_url": subscription_url, + "username": random_username + } + else: + print(f"❌ Ошибка создания подписки: {response.status} - {result}") + return {"success": False, "error": result, "status": response.status} + except aiohttp.ClientError as e: + print(f"❌ Ошибка подключения к Remnawave API: {e}") + return {"success": False, "error": str(e)} + + async def get_user_subscription(self, user_id: int) -> dict: + """ + Получение информации о подписке пользователя + + Args: + user_id: Telegram user ID + + Returns: + dict: Информация о подписке + """ + url = f"{self.api_url}/api/users/telegram/{user_id}" + + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers=self.headers) as response: + result = await response.json() + + if response.status == 200: + return {"success": True, "data": result} + else: + return {"success": False, "error": result, "status": response.status} + except aiohttp.ClientError as e: + return {"success": False, "error": str(e)} + + async def extend_subscription(self, user_id: int, days: int) -> dict: + """ + Продление подписки + + Args: + user_id: Telegram user ID + days: Количество дней для продления + + Returns: + dict: Результат продления + """ + url = f"{self.api_url}/api/users/telegram/{user_id}/extend" + + data = { + "durationDays": days + } + + async with aiohttp.ClientSession() as session: + try: + async with session.post(url, json=data, headers=self.headers) as response: + result = await response.json() + + if response.status == 200: + print(f"✅ Подписка продлена для user {user_id} на {days} дней") + return {"success": True, "data": result} + else: + print(f"❌ Ошибка продления подписки: {response.status} - {result}") + return {"success": False, "error": result, "status": response.status} + except aiohttp.ClientError as e: + print(f"❌ Ошибка подключения к Remnawave API: {e}") + return {"success": False, "error": str(e)} + + async def get_all_users(self, page: int = 0, size: int = 500) -> dict: + """ + Получение всех пользователей из Remnawave с пагинацией + + Args: + page: Номер страницы (начиная с 0) + size: Размер страницы (макс 500) + + Returns: + dict: Список пользователей + """ + url = f"{self.api_url}/api/users" + params = {"start": page, "size": min(size, 500)} + + async with aiohttp.ClientSession() as session: + try: + async with session.get(url, headers=self.headers, params=params) as response: + result = await response.json() + print(f"📋 Remnawave API ответ: status={response.status}, keys={list(result.keys()) if isinstance(result, dict) else type(result)}") + + if response.status == 200: + # Remnawave возвращает {'response': {'content': [...], 'total': N}} + users_list = [] + + if isinstance(result, dict): + # Пробуем разные варианты извлечения списка + if 'response' in result and isinstance(result['response'], dict): + response_data = result['response'] + if 'content' in response_data and isinstance(response_data['content'], list): + users_list = response_data['content'] + print(f"✅ Получено {len(users_list)} пользователей (response.content)") + elif 'users' in response_data and isinstance(response_data['users'], list): + users_list = response_data['users'] + print(f"✅ Получено {len(users_list)} пользователей (response.users)") + elif 'content' in result and isinstance(result['content'], list): + users_list = result['content'] + print(f"✅ Получено {len(users_list)} пользователей (content)") + elif 'data' in result and isinstance(result['data'], list): + users_list = result['data'] + print(f"✅ Получено {len(users_list)} пользователей (data)") + elif 'users' in result and isinstance(result['users'], list): + users_list = result['users'] + print(f"✅ Получено {len(users_list)} пользователей (users)") + + if users_list: + return {"success": True, "data": {"content": users_list, "total": len(users_list)}} + + print(f"⚠️ Не найдено пользователей в ответе") + return {"success": True, "data": {"content": [], "total": 0}} + else: + print(f"❌ Ошибка получения пользователей: {response.status} - {result}") + return {"success": False, "error": result, "status": response.status} + except aiohttp.ClientError as e: + print(f"❌ Ошибка подключения к Remnawave API: {e}") + return {"success": False, "error": str(e)} + + async def sync_user(self, user_id: int, username: str, days: int = 30) -> dict: + """ + Синхронизация пользователя (создание если нет в Remnawave) + + Args: + user_id: Telegram user ID + username: Имя пользователя + days: Количество дней + + Returns: + dict: Результат синхронизации + """ + # Сначала пробуем найти пользователя по Telegram ID через API + print(f"🔍 Поиск пользователя по Telegram ID: {user_id}") + check_result = await self.get_user_subscription(user_id) + + if check_result.get("success") and check_result.get("data"): + # Пользователь уже есть в Remnawave с этим telegramId + print(f"✅ Пользователь {user_id} уже найден в Remnawave по Telegram ID") + return {"success": True, "exists": True, "data": check_result.get("data")} + + # Если не нашли по telegramId, пробуем найти по username (subs{user_id}) + remnawave_username = f"subs{user_id}" + print(f"🔍 Не найдено по Telegram ID, поиск по username: {remnawave_username}") + + # Получаем всех пользователей и ищем по username + all_users_result = await self.get_all_users(page=0, size=500) + + if all_users_result.get("success"): + users_data = all_users_result.get("data", {}) + users_list = users_data.get("content", []) + + print(f"📋 Получено {len(users_list)} пользователей из Remnawave") + + # Логируем первые несколько для отладки + if users_list and len(users_list) > 0: + first_users = [(u.get("username"), u.get("telegramId")) for u in users_list[:5] if isinstance(u, dict)] + print(f"📋 Первые пользователи: {first_users}") + + # Ищем по username + for user in users_list: + if isinstance(user, dict) and user.get("username") == remnawave_username: + user_uuid = user.get("uuid") + print(f"✅ Найден пользователь {remnawave_username} с UUID {user_uuid}") + if user_uuid: + update_result = await self._update_user_telegram_id(user_uuid, user_id) + if update_result.get("success"): + print(f"✅ Обновлён telegramId для user {user_id}") + return {"success": True, "exists": True, "updated": True, "data": update_result.get("data")} + else: + return update_result + + # Создаём нового пользователя + print(f"➕ Создание нового пользователя {remnawave_username}") + create_result = await self.create_subscription(user_id, days, remnawave_username) + + # Если ошибка "username already exists" — пользователь уже есть, это нормально + if not create_result.get("success"): + error_data = create_result.get("error", {}) + if isinstance(error_data, dict) and error_data.get("message") == "User username already exists": + print(f"ℹ️ Пользователь {remnawave_username} уже существует в Remnawave (не удалось обновить telegramId)") + # Возвращаем успех с флагом exists + return {"success": True, "exists": True, "updated": False, "data": None} + else: + print(f"❌ Ошибка создания: {error_data}") + + return create_result + + async def _find_and_update_by_username(self, username: str, telegram_id: int) -> dict: + """ + Поиск пользователя по username и обновление telegramId + + Args: + username: Имя пользователя в Remnawave + telegram_id: Telegram user ID + + Returns: + dict: Результат + """ + all_users_result = await self.get_all_users(page=0, size=500) + + if not all_users_result.get("success"): + return all_users_result + + users_data = all_users_result.get("data", {}) + users_list = users_data.get("content", []) if isinstance(users_data, dict) else (users_data if isinstance(users_data, list) else []) + + for user in users_list: + if user.get("username") == username: + user_uuid = user.get("uuid") + if user_uuid: + return await self._update_user_telegram_id(user_uuid, telegram_id) + + return {"success": False, "error": "User not found"} + + async def _update_user_telegram_id(self, user_uuid: str, telegram_id: int) -> dict: + """ + Обновление telegramId у существующего пользователя + + Args: + user_uuid: UUID пользователя в Remnawave + telegram_id: Telegram user ID + + Returns: + dict: Результат обновления + """ + # Пробуем несколько вариантов обновления + urls_to_try = [ + (f"{self.api_url}/api/users/{user_uuid}/telegram", "PATCH"), + (f"{self.api_url}/api/users/{user_uuid}", "PUT"), + (f"{self.api_url}/api/users/{user_uuid}/telegram-id", "POST"), + ] + + for url, method in urls_to_try: + async with aiohttp.ClientSession() as session: + try: + data = {"telegramId": telegram_id} + + if method == "PATCH": + async with session.patch(url, json=data, headers=self.headers) as response: + result = await response.json() + if response.status in [200, 204]: + print(f"✅ Обновлён telegramId для user {telegram_id} (PATCH)") + return {"success": True, "data": result} + elif method == "PUT": + async with session.put(url, json=data, headers=self.headers) as response: + result = await response.json() + if response.status in [200, 204]: + print(f"✅ Обновлён telegramId для user {telegram_id} (PUT)") + return {"success": True, "data": result} + elif method == "POST": + async with session.post(url, json=data, headers=self.headers) as response: + result = await response.json() + if response.status in [200, 201]: + print(f"✅ Обновлён telegramId для user {telegram_id} (POST)") + return {"success": True, "data": result} + except aiohttp.ClientError as e: + continue + + print(f"❌ Не удалось обновить telegramId - API не поддерживает обновление") + return {"success": False, "error": "API does not support user update"} + + async def sync_all_users(self) -> dict: + """ + Синхронизация всех пользователей: Remnawave → Локальная БД + + Returns: + dict: Результаты синхронизации + """ + from core import db as local_db + + # Получаем всех пользователей из Remnawave + print("🔄 Получение пользователей из Remnawave...") + remnawave_result = await self.get_all_users(page=0, size=500) + + if not remnawave_result.get("success"): + return {"success": False, "error": "Не удалось получить пользователей из Remnawave"} + + users_data = remnawave_result.get("data", {}) + users_list = users_data.get("content", []) + + print(f"📋 Получено {len(users_list)} пользователей из Remnawave") + + total = len(users_list) + imported = 0 + updated = 0 + errors = 0 + error_details = [] + + connection = local_db.get_connection() + cursor = connection.cursor() + + for user in users_list: + if not isinstance(user, dict): + continue + + user_id = user.get("telegramId") + username = user.get("username", "") + is_active = user.get("status") == "ACTIVE" + + if not user_id: + # Нет Telegram ID - пропускаем + continue + + try: + # Проверяем, есть ли пользователь в БД + cursor.execute( + "SELECT id FROM subscriptions WHERE user_id = %s", + (user_id,) + ) + existing = cursor.fetchone() + + if existing: + # Обновляем существующего + cursor.execute( + """UPDATE subscriptions + SET username = %s, is_active = %s + WHERE user_id = %s""", + (username, is_active, user_id) + ) + updated += 1 + print(f"🔄 Обновлён пользователь {user_id} ({username})") + else: + # Создаём нового + cursor.execute( + """INSERT INTO subscriptions (user_id, username, is_active) + VALUES (%s, %s, %s)""", + (user_id, username, is_active) + ) + imported += 1 + print(f"➕ Импортирован пользователь {user_id} ({username})") + + connection.commit() + + except Exception as e: + errors += 1 + error_details.append(f"User {user_id}: {str(e)}") + print(f"❌ Ошибка импорта пользователя {user_id}: {e}") + + cursor.close() + connection.close() + + return { + "success": True, + "total": total, + "imported": imported, + "updated": updated, + "errors": errors, + "error_details": error_details[:10] + } + + +# Глобальный экземпляр API +remnawave = RemnawaveAPI() diff --git a/core/update_tickets.sql b/core/update_tickets.sql new file mode 100644 index 0000000..45c8f2d --- /dev/null +++ b/core/update_tickets.sql @@ -0,0 +1,38 @@ +-- Обновление таблицы tickets для добавления полей закрытия +USE botyobshik; + +-- Проверяем и добавляем колонку closed_by +SET @dbname = DATABASE(); +SET @tablename = 'tickets'; +SET @columnname = 'closed_by'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (table_name = @tablename) + AND (table_schema = @dbname) + AND (column_name = @columnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' VARCHAR(255) AFTER admin_response') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; + +-- Проверяем и добавляем колонку closed_at +SET @columnname = 'closed_at'; +SET @preparedStatement = (SELECT IF( + ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE + (table_name = @tablename) + AND (table_schema = @dbname) + AND (column_name = @columnname) + ) > 0, + 'SELECT 1', + CONCAT('ALTER TABLE ', @tablename, ' ADD COLUMN ', @columnname, ' DATETIME AFTER closed_by') +)); +PREPARE alterIfNotExists FROM @preparedStatement; +EXECUTE alterIfNotExists; +DEALLOCATE PREPARE alterIfNotExists; diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..ca95b7e --- /dev/null +++ b/handlers/__init__.py @@ -0,0 +1,1793 @@ +from aiogram import types, Router, F +from aiogram.filters import Command +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from core import db +from core.remnawave import remnawave +from keyboards import Keyboards +from keyboards.payment import PaymentKeyboards +from config import config +from locales import localization + +# Роутер для основного меню +main_router = Router() + +# Роутер для админ-панели +admin_router = Router() + + +# Временное хранилище для тарифов пользователей (user_id -> tariff_info) +payment_temp = {} + +# Временное хранилище для создания тикетов (user_id -> True) +ticket_creation = {} + +# Временное хранилище для дополнения тикетов (user_id -> ticket_id) +ticket_append_mode = {} + + +def is_admin(user_id: int) -> bool: + """Проверка, является ли пользователь администратором""" + return user_id in config.admin_ids + + +async def get_user_locale(user_id: int) -> str: + """Получение языка пользователя из БД""" + return await db.get_user_language(user_id) + + +@main_router.message(Command("start")) +async def handle_start(message: types.Message): + """Обработчик команды /start""" + user_id = message.from_user.id + username = message.from_user.username or message.from_user.first_name + locale = await get_user_locale(user_id) + + # Создаём пользователя в БД если нет + await db.create_user(user_id, username) + + # Проверяем статус подписки + is_active = await db.get_user_subscription(user_id) + + subscription_status = ( + localization.get("subscription_active", locale) if is_active + else localization.get("subscription_inactive", locale) + ) + + # Формируем сообщение + text = ( + f"{localization.get('welcome', locale, username=username)}\n\n" + f"{localization.get('subscription_status', locale, status=subscription_status)}\n\n" + f"{localization.get('select_action', locale)}" + ) + + # Проверяем, является ли пользователь администратором + if is_admin(user_id): + # Добавляем кнопку админ-панели + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Купить подписку", + callback_data="buy_subscription" + ) + ], + [ + InlineKeyboardButton( + text="📞 Техподдержка", + callback_data="support" + ), + InlineKeyboardButton( + text="📜 Правила сервиса", + callback_data="rules" + ) + ], + [ + InlineKeyboardButton( + text="🌐 Язык", + callback_data="language" + ), + InlineKeyboardButton( + text="⚙️ Админ", + callback_data="admin_panel" + ) + ] + ] + ) + else: + keyboard = Keyboards.get_main_keyboard(locale) + + await message.answer( + text, + reply_markup=keyboard + ) + + +@main_router.callback_query(lambda c: c.data == "buy_subscription") +async def handle_callback_buy_subscription(callback: types.CallbackQuery): + """Обработчик кнопки 'Купить подписку'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + subscription_title = localization.get("subscription.standard_title", locale) + subscription_desc = localization.get("subscription.standard_description", locale) + + await callback.message.edit_text( + f"{subscription_title}\n\n{subscription_desc}", + reply_markup=Keyboards.get_buy_subscription_keyboard(locale) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "back_to_buy") +async def handle_callback_back_to_buy(callback: types.CallbackQuery): + """Возврат к выбору тарифа""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + subscription_title = localization.get("subscription.standard_title", locale) + subscription_desc = localization.get("subscription.standard_description", locale) + + await callback.message.edit_text( + f"{subscription_title}\n\n{subscription_desc}", + reply_markup=Keyboards.get_buy_subscription_keyboard(locale) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "support") +async def handle_callback_support(callback: types.CallbackQuery): + """Обработчик кнопки 'Техподдержка'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + support_title = localization.get("messages.support_title", locale) + support_desc = localization.get("messages.support_description", locale) + + await callback.message.edit_text( + f"{support_title}\n\n{support_desc}", + reply_markup=Keyboards.get_support_keyboard(locale) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "support_create_ticket") +async def handle_support_create_ticket(callback: types.CallbackQuery): + """Обработчик кнопки 'Создать тикет'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + # Устанавливаем флаг, что пользователь создаёт тикет + ticket_creation[user_id] = True + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="❌ Отмена", + callback_data="cancel_ticket" + ), + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_support" + ) + ] + ] + ) + + await callback.message.edit_text( + "📝 Создание тикета\n\n" + "Опишите вашу проблему или вопрос одним сообщением.\n\n" + "Пример: 'Не работает VPN, ошибка подключения'\n\n" + "Отправьте сообщение ниже или нажмите 'Отмена':", + reply_markup=keyboard + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "cancel_ticket") +async def handle_cancel_ticket(callback: types.CallbackQuery): + """Отмена создания тикета""" + user_id = callback.from_user.id + + # Удаляем флаг создания тикета + if user_id in ticket_creation: + del ticket_creation[user_id] + + locale = await get_user_locale(user_id) + + await callback.message.edit_text( + "❌ Создание тикета отменено.\n\n" + "Если у вас возникнут вопросы, вы всегда можете создать тикет позже.", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_support" + ) + ] + ] + ) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "support_my_tickets") +async def handle_support_my_tickets(callback: types.CallbackQuery): + """Обработчик кнопки 'Мои тикеты'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + # Получаем тикеты пользователя из БД + tickets = await db.get_user_tickets(user_id) + + if not tickets: + await callback.message.edit_text( + "📋 Мои тикеты\n\n" + "У вас пока нет активных тикетов.\n\n" + "Создайте новый тикет, если у вас есть вопрос или проблема.", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_support" + ) + ] + ] + ) + ) + else: + # Формируем список тикетов + tickets_text = "📋 Мои тикеты\n\n" + + for ticket in tickets[:5]: # Показываем последние 5 тикетов + status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" + created_at = ticket['created_at'].strftime("%d.%m.%Y %H:%M") if ticket['created_at'] else 'Неизвестно' + + tickets_text += ( + f"{status_emoji} Тикет #{ticket['id']} - {ticket['status']}\n" + f"Дата: {created_at}\n" + f"Вопрос: {ticket['message'][:50]}...\n\n" + ) + + if len(tickets) > 5: + tickets_text += f"... и ещё {len(tickets) - 5} тикетов\n" + + # Клавиатура с кнопками тикетов + ticket_buttons = [] + for ticket in tickets[:5]: + status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" + ticket_buttons.append([ + InlineKeyboardButton( + text=f"{status_emoji} #{ticket['id']} - {ticket['status']}", + callback_data=f"ticket_view_{ticket['id']}" + ) + ]) + ticket_buttons.append([ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_support" + ) + ]) + + await callback.message.edit_text( + tickets_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=ticket_buttons) + ) + + await callback.answer() + + +@main_router.callback_query(lambda c: c.data.startswith("ticket_view_")) +async def handle_ticket_view(callback: types.CallbackQuery): + """Просмотр тикета""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + ticket_id = int(callback.data.replace("ticket_view_", "")) + await show_ticket_view(callback, user_id, locale, ticket_id) + + +async def show_ticket_view(callback, user_id, locale, ticket_id): + """Отображение просмотра тикета""" + ticket = await db.get_ticket(ticket_id) + + if not ticket or ticket['user_id'] != user_id: + await callback.answer("Тикет не найден", show_alert=True) + return + + # Статус тикета + status_text = { + 'open': '🟢 Открыт', + 'answered': '🔵 Дан ответ', + 'closed': '⚫ Закрыт' + }.get(ticket['status'], ticket['status']) + + # Формируем сообщение + ticket_text = f"📋 Тикет #{ticket['id']}\n\n" + ticket_text += f"Статус: {status_text}\n" + ticket_text += f"Дата создания: {ticket['created_at'].strftime('%d.%m.%Y %H:%M') if ticket['created_at'] else 'Неизвестно'}\n\n" + ticket_text += f"❓ Ваш вопрос:\n{ticket['message']}\n\n" + + if ticket['admin_response']: + ticket_text += f"💬 Ответ поддержки:\n{ticket['admin_response']}\n\n" + + # Информация о закрытии + if ticket['status'] == 'closed': + closed_by = ticket.get('closed_by', 'Неизвестно') + closed_at = ticket.get('closed_at') + closed_info = f"🔒 Закрыл: {closed_by}" + if closed_at: + closed_info += f"\n🕒 Дата: {closed_at.strftime('%d.%m.%Y %H:%M') if hasattr(closed_at, 'strftime') else closed_at}" + ticket_text += f"{closed_info}\n\n" + + if not ticket['admin_response'] and ticket['status'] != 'closed': + ticket_text += "⏳ Ожидайте ответа поддержки...\n\n" + + # Кнопки + keyboard_buttons = [ + [ + InlineKeyboardButton( + text="✏️ Дополнить тикет", + callback_data=f"ticket_append_{ticket['id']}" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="support_my_tickets" + ) + ] + ] + + # Если тикет открыт, можно закрыть + if ticket['status'] == 'open': + keyboard_buttons.insert(0, [ + InlineKeyboardButton( + text="✅ Закрыть тикет", + callback_data=f"ticket_close_{ticket['id']}" + ) + ]) + + await callback.message.edit_text( + ticket_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data.startswith("ticket_close_")) +async def handle_ticket_close(callback: types.CallbackQuery): + """Закрытие тикета""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + username = callback.from_user.username or callback.from_user.first_name + + ticket_id = int(callback.data.replace("ticket_close_", "")) + ticket = await db.get_ticket(ticket_id) + + if not ticket or ticket['user_id'] != user_id: + await callback.answer("Тикет не найден", show_alert=True) + return + + # Закрываем тикет с указанием кто закрыл (передаём позиционно, не именованно) + await db.close_ticket(ticket_id, f"@{username}" if callback.from_user.username else f"{username}") + + await callback.answer("Тикет закрыт") + + # Обновляем сообщение с информацией кто закрыл (передаём ticket_id) + try: + await handle_ticket_view_by_id(callback, ticket_id) + except Exception as e: + if "message is not modified" not in str(e): + print(f"Ошибка обновления сообщения: {e}") + + +async def handle_ticket_view_by_id(callback, ticket_id): + """Просмотр тикета по ID (для вызова из других функций)""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + await show_ticket_view(callback, user_id, locale, ticket_id) + + +@main_router.callback_query(lambda c: c.data.startswith("ticket_append_")) +async def handle_ticket_append(callback: types.CallbackQuery): + """Режим дополнения тикета""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + ticket_id = int(callback.data.replace("ticket_append_", "")) + ticket = await db.get_ticket(ticket_id) + + if not ticket or ticket['user_id'] != user_id: + await callback.answer("Тикет не найден", show_alert=True) + return + + # Устанавливаем режим дополнения + ticket_append_mode[user_id] = ticket_id + + await callback.message.edit_text( + f"✏️ Дополнение тикета #{ticket_id}\n\n" + f"Напишите дополнение к вашему тикету.\n\n" + f"Текущий статус: {ticket['status']}\n\n" + f"Отправьте сообщение ниже или нажмите 'Отмена':", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="❌ Отмена", + callback_data=f"ticket_append_cancel_{ticket_id}" + ) + ] + ] + ) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data.startswith("ticket_append_cancel_")) +async def handle_ticket_append_cancel(callback: types.CallbackQuery): + """Отмена дополнения тикета""" + user_id = callback.from_user.id + + # Удаляем из режима дополнения + if user_id in ticket_append_mode: + del ticket_append_mode[user_id] + + # Возвращаемся к просмотру тикета + ticket_id = int(callback.data.replace("ticket_append_cancel_", "")) + + # Обновляем сообщение + await handle_ticket_view(callback) + await callback.answer("Отменено") + + +@main_router.callback_query(lambda c: c.data == "back_to_support") +async def handle_callback_back_to_support(callback: types.CallbackQuery): + """Возврат в меню поддержки""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + support_title = localization.get("messages.support_title", locale) + support_desc = localization.get("messages.support_description", locale) + + await callback.message.edit_text( + f"{support_title}\n\n{support_desc}", + reply_markup=Keyboards.get_support_keyboard(locale) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "rules") +async def handle_callback_rules(callback: types.CallbackQuery): + """Обработчик кнопки 'Правила сервиса'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + rules_title = localization.get("messages.rules_title", locale) + rules_text = localization.get("messages.rules_text", locale) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + await callback.message.edit_text( + f"{rules_title}\n\n{rules_text}", + reply_markup=keyboard + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "language") +async def handle_callback_language(callback: types.CallbackQuery): + """Обработчик кнопки 'Язык'""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + await callback.message.edit_text( + localization.get("messages.select_language", locale), + reply_markup=Keyboards.get_language_keyboard(locale) + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data == "back_to_main") +async def handle_callback_back_to_main(callback: types.CallbackQuery): + """Возврат в главное меню""" + user_id = callback.from_user.id + username = callback.from_user.username or callback.from_user.first_name + locale = await get_user_locale(user_id) + + is_active = await db.get_user_subscription(user_id) + + subscription_status = ( + localization.get("subscription_active", locale) if is_active + else localization.get("subscription_inactive", locale) + ) + + text = ( + f"{localization.get('welcome', locale, username=username)}\n\n" + f"{localization.get('subscription_status', locale, status=subscription_status)}\n\n" + f"{localization.get('select_action', locale)}" + ) + + # Проверяем, является ли пользователь администратором + if is_admin(user_id): + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="💳 Купить подписку", + callback_data="buy_subscription" + ) + ], + [ + InlineKeyboardButton( + text="📞 Техподдержка", + callback_data="support" + ), + InlineKeyboardButton( + text="📜 Правила сервиса", + callback_data="rules" + ) + ], + [ + InlineKeyboardButton( + text="🌐 Язык", + callback_data="language" + ), + InlineKeyboardButton( + text="⚙️ Админ", + callback_data="admin_panel" + ) + ] + ] + ) + else: + keyboard = Keyboards.get_main_keyboard(locale) + + await callback.message.edit_text( + text=text, + reply_markup=keyboard + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data.startswith("lang_")) +async def handle_callback_language_change(callback: types.CallbackQuery): + """Обработчик смены языка""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + lang = callback.data.replace("lang_", "") + + # Сохраняем язык в БД + await db.set_user_language(user_id, lang) + + await callback.answer( + localization.get("messages.language_changed", locale, language=localization.get_locale_name(lang)) + ) + + # Возвращаемся в главное меню + await handle_callback_back_to_main(callback) + + +@main_router.callback_query(lambda c: c.data in ["buy_14_days", "buy_30_days", "buy_60_days", "buy_90_days", "buy_180_days", "buy_360_days"]) +async def handle_callback_buy_tariff(callback: types.CallbackQuery): + """Обработчик выбора тарифа""" + user_id = callback.from_user.id + locale = await get_user_locale(user_id) + + # Карта тарифов: callback_data -> (days, price) + tariffs_map = { + "buy_14_days": (14, "150₽"), + "buy_30_days": (30, "300₽"), + "buy_60_days": (60, "600₽"), + "buy_90_days": (90, "900₽"), + "buy_180_days": (180, "1800₽"), + "buy_360_days": (360, "3600₽") + } + + tariff_info = tariffs_map.get(callback.data) + if tariff_info: + days, price = tariff_info + # Сохраняем тариф во временное хранилище + payment_temp[user_id] = {"days": days, "price": price, "tariff": callback.data} + + payment_title = localization.get("messages.payment_instruction_title", locale) + payment_text = localization.get("messages.payment_instruction_text", locale, phone=config.payment_phone) + + # Клавиатура с кнопкой "Назад" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_buy" + ) + ] + ] + ) + + await callback.message.edit_text( + f"{payment_title}\n\n{payment_text}", + reply_markup=keyboard + ) + await callback.answer() + + +@main_router.message(F.photo) +async def handle_payment_photo(message: types.Message): + """Обработчик фото чека""" + user_id = message.from_user.id + username = message.from_user.username or message.from_user.first_name + + # Получаем информацию о тарифе из временного хранилища + tariff_info = payment_temp.get(user_id, {}) + days = tariff_info.get("days", 0) + tariff = tariff_info.get("tariff", "unknown") + + # Отправляем фото в канал (в тему 5910) с кнопками аппрува + if config.payment_channel_id: + try: + await message.bot.copy_message( + chat_id=config.payment_channel_id, + from_chat_id=message.chat.id, + message_id=message.message_id, + caption=f"💳 Оплата от @{username} ({user_id})\nДней: {days}\nТариф: {tariff}\n{message.caption or ''}", + message_thread_id=5910, + reply_markup=PaymentKeyboards.get_payment_approval_keyboard(user_id, tariff, days) + ) + print(f"✅ Фото отправлено в канал {config.payment_channel_id}, тема 5910") + except Exception as e: + print(f"❌ Ошибка отправки фото в канал: {e}") + + # Отвечаем пользователю с кнопкой "В главное меню" + locale = await get_user_locale(user_id) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + await message.answer( + localization.get("messages.payment_receipt_sent", locale), + reply_markup=keyboard + ) + + +@main_router.message(F.text) +async def handle_ticket_message(message: types.Message): + """Обработчик текстовых сообщений для создания/дополнения тикета""" + user_id = message.from_user.id + username = message.from_user.username or message.from_user.first_name + text = message.text + + # Проверяем, отвечает ли админ на тикет + admin_reply_mode = ticket_append_mode.get(user_id) + if admin_reply_mode and isinstance(admin_reply_mode, str) and admin_reply_mode.startswith("admin_reply_"): + ticket_id = int(admin_reply_mode.replace("admin_reply_", "")) + + # Сохраняем ответ в БД + await db.update_ticket_response(ticket_id, text) + + # Отправляем ответ в канал + ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id + ticket_thread_id = config.payment_ticket_thread_id + + if ticket_channel_id: + try: + if ticket_thread_id: + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"💬 Ответ администратора в тикет #{ticket_id}:\n\n{text}", + message_thread_id=int(ticket_thread_id) + ) + else: + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"💬 Ответ администратора в тикет #{ticket_id}:\n\n{text}" + ) + print(f"✅ Ответ админа отправлен в тикет #{ticket_id}") + except Exception as e: + print(f"Ошибка отправки ответа админа: {e}") + + # Отвечаем пользователю + try: + ticket = await db.get_ticket(ticket_id) + if ticket: + await message.bot.send_message( + chat_id=ticket['user_id'], + text=f"📋 Ответ поддержки в вашем тикете #{ticket_id}:\n\n{text}" + ) + except Exception as e: + print(f"Не удалось отправить ответ пользователю: {e}") + + # Удаляем режим ответа + del ticket_append_mode[user_id] + + await message.answer( + f"✅ Ответ отправлен в тикет #{ticket_id}!\n\nПользователь получит уведомление.", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад в админку", + callback_data="admin_back" + ) + ] + ] + ) + ) + return + + # Проверяем, создаёт ли пользователь тикет + if ticket_creation.get(user_id): + # Сохраняем тикет в БД + ticket_id = await db.create_ticket(user_id, username, text) + + # Отправляем тикет в канал (с thread_id) + ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id + ticket_thread_id = config.payment_ticket_thread_id + + if ticket_channel_id: + try: + if ticket_thread_id: + # Отправляем в тему (форум) + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"📝 Новый тикет #{ticket_id} от @{username} ({user_id})\n\n❓ Вопрос:\n{text}", + message_thread_id=int(ticket_thread_id) + ) + else: + # Отправляем просто в канал + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"📝 Новый тикет #{ticket_id} от @{username} ({user_id})\n\n❓ Вопрос:\n{text}" + ) + print(f"✅ Тикет #{ticket_id} отправлен в канал {ticket_channel_id}, тема {ticket_thread_id}") + except Exception as e: + print(f"Ошибка отправки тикета в канал: {e}") + + # Отвечаем пользователю + locale = await get_user_locale(user_id) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + # Отправляем сообщение и сразу удаляем флаг + del ticket_creation[user_id] # Удаляем флаг ПЕРЕД отправкой + + await message.answer( + f"✅ Тикет #{ticket_id} создан!\n\nНаша поддержка ответит вам в ближайшее время.\n\nОжидайте ответа в этом чате.", + reply_markup=keyboard + ) + + # Проверяем, дополняет ли пользователь тикет + elif ticket_append_mode.get(user_id): + ticket_id = ticket_append_mode[user_id] + + # Добавляем сообщение к тикету + await db.append_ticket_message(ticket_id, text) + + # Отправляем уведомление в канал + ticket_channel_id = config.payment_ticket_channel_id or config.payment_channel_id + ticket_thread_id = config.payment_ticket_thread_id + + if ticket_channel_id: + try: + if ticket_thread_id: + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"➕ Тикет #{ticket_id} дополнен от @{username} ({user_id})\n\n📝 Дополнение:\n{text}", + message_thread_id=int(ticket_thread_id) + ) + else: + await message.bot.send_message( + chat_id=ticket_channel_id, + text=f"➕ Тикет #{ticket_id} дополнен от @{username} ({user_id})\n\n📝 Дополнение:\n{text}" + ) + print(f"✅ Дополнение к тикуту #{ticket_id} отправлено в канал") + except Exception as e: + print(f"Ошибка отправки дополнения в канал: {e}") + + # Отвечаем пользователю + locale = await get_user_locale(user_id) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + # Удаляем режим дополнения + del ticket_append_mode[user_id] + + await message.answer( + f"✅ Тикет #{ticket_id} дополнен!\n\nПоддержка увидит ваше сообщение.", + reply_markup=keyboard + ) + + +# ==================== АДМИН ПАНЕЛЬ ==================== + +@admin_router.callback_query(lambda c: c.data == "admin_panel") +async def handle_admin_panel_button(callback: types.CallbackQuery): + """Админ-панель из кнопки""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📋 Все тикеты", + callback_data="admin_all_tickets" + ) + ], + [ + InlineKeyboardButton( + text="🔍 Тикет по ID", + callback_data="admin_ticket_by_id" + ) + ], + [ + InlineKeyboardButton( + text="👥 Пользователи", + callback_data="admin_users" + ) + ], + [ + InlineKeyboardButton( + text="⚙️ Активировать подписку", + callback_data="admin_activate_sub" + ) + ], + [ + InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_main" + ) + ] + ] + ) + + await callback.message.edit_text( + "⚙️ Админ-панель\n\nВыберите действие:", + reply_markup=keyboard + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data == "admin_users") +async def handle_admin_users(callback: types.CallbackQuery): + """Управление пользователями - статистика""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Получаем статистику из БД + connection = db.get_connection() + cursor = connection.cursor(dictionary=True) + + # Всего пользователей + cursor.execute("SELECT COUNT(*) as count FROM subscriptions") + total_users = cursor.fetchone()['count'] + + # Активных пользователей + cursor.execute("SELECT COUNT(*) as count FROM subscriptions WHERE is_active = TRUE") + active_users = cursor.fetchone()['count'] + + # Заблокированных (пока нет такой колонки, считаем неактивных) + blocked_users = total_users - active_users + + # Новые пользователи сегодня + cursor.execute(""" + SELECT COUNT(*) as count FROM subscriptions + WHERE DATE(created_at) = CURDATE() + """) + today_users = cursor.fetchone()['count'] + + # Новые пользователи за неделю + cursor.execute(""" + SELECT COUNT(*) as count FROM subscriptions + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + """) + week_users = cursor.fetchone()['count'] + + # Новые пользователи за месяц + cursor.execute(""" + SELECT COUNT(*) as count FROM subscriptions + WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY) + """) + month_users = cursor.fetchone()['count'] + + cursor.close() + connection.close() + + stats_text = ( + "👥 Управление пользователями\n\n" + "📊 Статистика:\n" + f"• Всего: {total_users}\n" + f"• Активных: {active_users}\n" + f"• Заблокированных: {blocked_users}\n\n" + "📈 Новые пользователи:\n" + f"• Сегодня: {today_users}\n" + f"• За неделю: {week_users}\n" + f"• За месяц: {month_users}" + ) + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="� Все пользователи", + callback_data="admin_users_list_1" + ) + ], + [ + InlineKeyboardButton( + text="�📥 Импорт из Remnawave", + callback_data="admin_sync_remnawave" + ) + ], + [ + InlineKeyboardButton( + text="🔙 Назад в админку", + callback_data="admin_back" + ) + ] + ] + ) + + await callback.message.edit_text( + stats_text, + reply_markup=keyboard + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_users_list_")) +async def handle_admin_users_list(callback: types.CallbackQuery): + """Отображение списка всех пользователей с пагинацией""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Извлекаем номер страницы + page = int(callback.data.replace("admin_users_list_", "")) + per_page = 10 # Пользователей на странице + + # Получаем пользователей из БД + result = await db.get_all_users_paginated(page, per_page) + + users = result.get('users', []) + total = result.get('total', 0) + total_pages = result.get('total_pages', 0) + current_page = result.get('current_page', 1) + + if not users: + await callback.message.edit_text( + "Нажмите на пользователя для управления:", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад к пользователям", + callback_data="admin_users" + ) + ] + ] + ) + ) + await callback.answer() + return + + # Формируем список пользователей + keyboard_buttons = [] + + from datetime import datetime, timezone + + for user in users: + user_id = user['user_id'] + username = user.get('username') or f"User {user_id}" + is_active = user.get('is_active', False) + created_at = user.get('created_at') + + # Формируем статус активности + status_emoji = "✅" if is_active else "❌" + + # Формируем иконку подписки (если активна) + sub_emoji = "💎" if is_active else "" + + # Подарок для новых пользователей (созданы сегодня) + gift_emoji = "" + if created_at: + if hasattr(created_at, 'date'): + if created_at.date() == datetime.now().date(): + gift_emoji = "🎁" + elif isinstance(created_at, str): + try: + created_date = datetime.fromisoformat(created_at) + if created_date.date() == datetime.now().date(): + gift_emoji = "🎁" + except: + pass + + # Вычисляем время с момента регистрации + time_ago = "" + if created_at: + if hasattr(created_at, 'replace'): + try: + now = datetime.now() + if hasattr(created_at, 'tzinfo') and created_at.tzinfo is not None: + now = now.replace(tzinfo=timezone.utc) + diff = now - created_at + seconds = int(diff.total_seconds()) + + if seconds < 60: + time_ago = f"{seconds} сек. назад" + elif seconds < 3600: + minutes = seconds // 60 + time_ago = f"{minutes} мин. назад" + elif seconds < 86400: + hours = seconds // 3600 + time_ago = f"{hours} ч. назад" + else: + days = seconds // 86400 + time_ago = f"{days} дн. назад" + except: + time_ago = str(created_at)[:10] + else: + time_ago = str(created_at)[:10] + + # Формируем текст для кнопки пользователя + user_button_text = f"{status_emoji} {gift_emoji}{sub_emoji} {username} | {time_ago}" + + # Ограничиваем длину текста кнопки (максимум 64 символа) + if len(user_button_text) > 64: + user_button_text = user_button_text[:61] + "..." + + keyboard_buttons.append([ + InlineKeyboardButton( + text=user_button_text, + callback_data=f"admin_user_view_{user_id}" + ) + ]) + + # Добавляем пагинацию + pagination_buttons = [] + + # Кнопка предыдущей страницы + if current_page > 1: + pagination_buttons.append( + InlineKeyboardButton( + text="⬅️", + callback_data=f"admin_users_list_{current_page - 1}" + ) + ) + + # Номер текущей страницы + pagination_buttons.append( + InlineKeyboardButton( + text=f"{current_page}/{total_pages}", + callback_data="noop" + ) + ) + + # Кнопка следующей страницы + if current_page < total_pages: + pagination_buttons.append( + InlineKeyboardButton( + text="➡️", + callback_data=f"admin_users_list_{current_page + 1}" + ) + ) + + keyboard_buttons.append(pagination_buttons) + + # Кнопка поиска + keyboard_buttons.append([ + InlineKeyboardButton( + text="🔍 Поиск", + callback_data="admin_user_search" + ) + ]) + + # Кнопка назад + keyboard_buttons.append([ + InlineKeyboardButton( + text="🔙 Назад", + callback_data="admin_users" + ) + ]) + + await callback.message.edit_text( + "Нажмите на пользователя для управления:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data == "admin_sync_remnawave") +async def handle_admin_sync_remnawave(callback: types.CallbackQuery): + """Синхронизация всех пользователей из Remnawave в БД""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Отправляем сообщение о начале синхронизации + progress_message = await callback.message.answer("🔄 Начинаю синхронизацию из Remnawave...\n\nЭто может занять несколько минут.") + + # Запускаем синхронизацию (импорт из Remnawave в БД) + result = await remnawave.sync_all_users() + + if result.get("success"): + result_text = ( + "✅ Синхронизация завершена!\n\n" + f"📊 Результаты:\n" + f"• Всего пользователей в Remnawave: {result['total']}\n" + f"• Импортировано новых: {result['imported']}\n" + f"• Обновлено существующих: {result['updated']}\n" + f"• Ошибок: {result['errors']}" + ) + + if result.get("error_details"): + result_text += f"\n\n⚠️ Ошибки (первые 10):\n" + for error in result["error_details"]: + result_text += f"• {error}\n" + else: + result_text = f"❌ Ошибка синхронизации: {result.get('error', 'Неизвестная ошибка')}" + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад к пользователям", + callback_data="admin_users" + ) + ] + ] + ) + + try: + await progress_message.edit_text( + result_text, + reply_markup=keyboard + ) + except: + await callback.message.answer( + result_text, + reply_markup=keyboard + ) + + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_user_view_")) +async def handle_admin_user_view(callback: types.CallbackQuery): + """Просмотр информации о пользователе""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + user_id = int(callback.data.replace("admin_user_view_", "")) + + # Получаем информацию о пользователе из БД + connection = db.get_connection() + cursor = connection.cursor(dictionary=True) + cursor.execute( + "SELECT * FROM subscriptions WHERE user_id = %s", + (user_id,) + ) + user = cursor.fetchone() + cursor.close() + connection.close() + + if not user: + await callback.answer("Пользователь не найден", show_alert=True) + return + + # Формируем текст с информацией о пользователе + user_text = f"👤 Информация о пользователе\n\n" + user_text += f"ID: {user['user_id']}\n" + user_text += f"Username: @{user['username'] or 'Не указан'}\n" + user_text += f"Статус: {'✅ Активен' if user['is_active'] else '❌ Неактивен'}\n\n" + + if user.get('subscription_start'): + user_text += f"Начало подписки: {user['subscription_start'].strftime('%d.%m.%Y %H:%M') if hasattr(user['subscription_start'], 'strftime') else user['subscription_start']}\n" + + if user.get('subscription_end'): + user_text += f"Окончание подписки: {user['subscription_end'].strftime('%d.%m.%Y %H:%M') if hasattr(user['subscription_end'], 'strftime') else user['subscription_end']}\n" + + user_text += f"\nДата регистрации: {user['created_at'].strftime('%d.%m.%Y %H:%M') if hasattr(user['created_at'], 'strftime') else user['created_at']}\n" + + # Кнопки управления пользователем + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Активировать подписку" if not user['is_active'] else "❌ Деактивировать", + callback_data=f"admin_user_toggle_sub_{user_id}" + ) + ], + [ + InlineKeyboardButton( + text="📋 Тикеты пользователя", + callback_data=f"admin_user_tickets_{user_id}" + ) + ], + [ + InlineKeyboardButton( + text="🔙 Назад к списку", + callback_data="admin_users_list_1" + ) + ] + ] + ) + + await callback.message.edit_text( + user_text, + reply_markup=keyboard + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data == "admin_user_search") +async def handle_admin_user_search(callback: types.CallbackQuery): + """Заглушка для поиска пользователей""" + await callback.answer("Функция поиска скоро будет доступна", show_alert=True) + + +@admin_router.callback_query(lambda c: c.data == "noop") +async def handle_noop(callback: types.CallbackQuery): + """Заглушка для неактивных кнопок""" + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_user_toggle_sub_")) +async def handle_admin_user_toggle_sub(callback: types.CallbackQuery): + """Переключение статуса подписки пользователя""" + user_id = int(callback.data.replace("admin_user_toggle_sub_", "")) + + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Получаем текущего пользователя + connection = db.get_connection() + cursor = connection.cursor(dictionary=True) + cursor.execute("SELECT is_active FROM subscriptions WHERE user_id = %s", (user_id,)) + user = cursor.fetchone() + + if not user: + await callback.answer("Пользователь не найден", show_alert=True) + cursor.close() + connection.close() + return + + # Переключаем статус + new_status = not user['is_active'] + if new_status: + # Активируем подписку на 30 дней + cursor.execute( + """UPDATE subscriptions + SET is_active = TRUE, + subscription_start = NOW(), + subscription_end = DATE_ADD(NOW(), INTERVAL 30 DAY) + WHERE user_id = %s""", + (user_id,) + ) + else: + # Деактивируем подписку + cursor.execute( + "UPDATE subscriptions SET is_active = FALSE WHERE user_id = %s", + (user_id,) + ) + + connection.commit() + cursor.close() + connection.close() + + await callback.answer(f"Подписка {'активирована' if new_status else 'деактивирована'}") + + # Обновляем экран + await handle_admin_user_view(callback) + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_user_tickets_")) +async def handle_admin_user_tickets(callback: types.CallbackQuery): + """Просмотр тикетов пользователя""" + user_id = int(callback.data.replace("admin_user_tickets_", "")) + + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Получаем тикеты пользователя + tickets = await db.get_user_tickets(user_id) + + if not tickets: + await callback.message.answer( + "📋 Тикеты пользователя\n\n" + "У пользователя нет тикетов.", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад к пользователю", + callback_data=f"admin_user_view_{user_id}" + ) + ] + ] + ) + ) + await callback.answer() + return + + # Формируем список тикетов + tickets_text = f"📋 Тикеты пользователя\n\n" + keyboard_buttons = [] + + for ticket in tickets[:10]: + status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{status_emoji} #{ticket['id']} - {ticket['status']}", + callback_data=f"admin_ticket_view_{ticket['id']}" + ) + ]) + + keyboard_buttons.append([ + InlineKeyboardButton( + text="🔙 Назад к пользователю", + callback_data=f"admin_user_view_{user_id}" + ) + ]) + + await callback.message.answer( + tickets_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data == "admin_all_tickets") +async def handle_admin_all_tickets(callback: types.CallbackQuery): + """Все тикеты (последние 10)""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + # Получаем все тикеты (последние 10) + connection = db.get_connection() + cursor = connection.cursor(dictionary=True) + cursor.execute( + """SELECT id, user_id, username, message, status, created_at + FROM tickets ORDER BY created_at DESC LIMIT 10""" + ) + tickets = cursor.fetchall() + cursor.close() + connection.close() + + if not tickets: + await callback.answer("Тикетов нет", show_alert=True) + return + + tickets_text = "📋 Последние тикеты (10):\n\n" + keyboard_buttons = [] + + for ticket in tickets: + status_emoji = "🟢" if ticket['status'] == 'open' else "🔵" if ticket['status'] == 'answered' else "⚫" + created_at = ticket['created_at'].strftime("%d.%m.%Y %H:%M") if ticket['created_at'] else 'Неизвестно' + + tickets_text += ( + f"{status_emoji} #{ticket['id']} | @{ticket['username']} | {ticket['status']}\n" + ) + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"#{ticket['id']} - @{ticket['username']}", + callback_data=f"admin_ticket_view_{ticket['id']}" + ) + ]) + + keyboard_buttons.append([ + InlineKeyboardButton( + text="🔙 Назад", + callback_data="admin_back" + ) + ]) + + await callback.message.answer( + tickets_text, + reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_ticket_view_")) +async def handle_admin_ticket_view(callback: types.CallbackQuery): + """Просмотр тикета админом""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + ticket_id = int(callback.data.replace("admin_ticket_view_", "")) + ticket = await db.get_ticket(ticket_id) + + if not ticket: + await callback.answer("Тикет не найден", show_alert=True) + return + + status_text = { + 'open': '🟢 Открыт', + 'answered': '🔵 Дан ответ', + 'closed': '⚫ Закрыт' + }.get(ticket['status'], ticket['status']) + + ticket_text = f"📋 Тикет #{ticket['id']}\n\n" + ticket_text += f"Пользователь: @{ticket['username']} ({ticket['user_id']})\n" + ticket_text += f"Статус: {status_text}\n" + ticket_text += f"Дата создания: {ticket['created_at'].strftime('%d.%m.%Y %H:%M') if ticket['created_at'] else 'Неизвестно'}\n\n" + ticket_text += f"❓ Вопрос:\n{ticket['message']}\n\n" + + if ticket['admin_response']: + ticket_text += f"💬 Ответ поддержки:\n{ticket['admin_response']}\n\n" + + if ticket['status'] == 'closed': + closed_by = ticket.get('closed_by', 'Неизвестно') + closed_at = ticket.get('closed_at') + ticket_text += f"🔒 Закрыл: {closed_by}\n" + if closed_at: + ticket_text += f"🕒 Дата: {closed_at.strftime('%d.%m.%Y %H:%M') if hasattr(closed_at, 'strftime') else closed_at}\n" + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✏️ Ответить", + callback_data=f"admin_reply_{ticket['id']}" + ) + ], + [ + InlineKeyboardButton( + text="✅ Закрыть", + callback_data=f"admin_close_{ticket['id']}" + ) + ], + [ + InlineKeyboardButton( + text="🔙 Назад", + callback_data="admin_all_tickets" + ) + ] + ] + ) + + await callback.message.answer(ticket_text, reply_markup=keyboard) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_reply_")) +async def handle_admin_reply(callback: types.CallbackQuery): + """Ответ на тикет""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + ticket_id = int(callback.data.replace("admin_reply_", "")) + + # Устанавливаем режим ответа + ticket_append_mode[callback.from_user.id] = f"admin_reply_{ticket_id}" + + await callback.message.answer( + f"✏️ Ответ на тикет #{ticket_id}\n\n" + f"Напишите ваш ответ одним сообщением:", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="❌ Отмена", + callback_data="admin_cancel_reply" + ) + ] + ] + ) + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data == "admin_cancel_reply") +async def handle_admin_cancel_reply(callback: types.CallbackQuery): + """Отмена ответа админа""" + if callback.from_user.id in ticket_append_mode: + del ticket_append_mode[callback.from_user.id] + + await callback.message.edit_text( + "❌ Отменено\n\nВыберите действие:", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="🔙 Назад в админку", + callback_data="admin_back" + ) + ] + ] + ) + ) + await callback.answer() + + +@admin_router.callback_query(lambda c: c.data.startswith("admin_close_")) +async def handle_admin_close(callback: types.CallbackQuery): + """Закрытие тикета админом""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + ticket_id = int(callback.data.replace("admin_close_", "")) + admin_username = callback.from_user.username or callback.from_user.first_name + + await db.close_ticket(ticket_id, f"admin:{admin_username}") + + # Уведомляем пользователя + ticket = await db.get_ticket(ticket_id) + if ticket: + try: + await callback.bot.send_message( + chat_id=ticket['user_id'], + text=f"📋 Ваш тикет #{ticket_id} был закрыт администратором.\n\n" + f"Если у вас остались вопросы, создайте новый тикет." + ) + except: + pass + + await callback.answer("Тикет закрыт") + + # Обновляем сообщение + try: + await callback.message.delete() + except: + pass + + +@admin_router.callback_query(lambda c: c.data == "admin_back") +async def handle_admin_back(callback: types.CallbackQuery): + """Назад в админку""" + if not is_admin(callback.from_user.id): + await callback.answer("Доступ запрещён", show_alert=True) + return + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📋 Все тикеты", + callback_data="admin_all_tickets" + ) + ], + [ + InlineKeyboardButton( + text="🔍 Тикет по ID", + callback_data="admin_ticket_by_id" + ) + ], + [ + InlineKeyboardButton( + text="👥 Пользователи", + callback_data="admin_users" + ) + ], + [ + InlineKeyboardButton( + text="⚙️ Активировать подписку", + callback_data="admin_activate_sub" + ) + ], + [ + InlineKeyboardButton( + text="🔙 Назад", + callback_data="back_to_main" + ) + ] + ] + ) + + await callback.message.edit_text( + "⚙️ Админ-панель\n\nВыберите действие:", + reply_markup=keyboard + ) + await callback.answer() + + +@main_router.callback_query(lambda c: c.data.startswith("payment_approve_")) +async def handle_payment_approve(callback: types.CallbackQuery): + """Обработчик кнопки 'Аппрув'""" + # payment_approve_{user_id}_{tariff}_{days} + data = callback.data.replace("payment_approve_", "").split("_") + + # Получаем locale для клавиатур + locale = await get_user_locale(callback.from_user.id) + + if len(data) >= 3: + user_id = int(data[0]) + tariff = data[1] + days = int(data[2]) + + # Создаём подписку через Remnawave API + api_result = await remnawave.create_subscription(user_id, days, tariff) + + if api_result.get("success"): + # Активируем подписку в локальной БД + await db.activate_subscription(user_id, days) + + # Формируем сообщение для пользователя + subscription_url = api_result.get("subscription_url") + username = api_result.get("username", f"subs{user_id}") + + print(f"🔗 subscription_url: {subscription_url}") + print(f"👤 username: {username}") + + if subscription_url: + user_message = ( + f"✅ Оплата подтверждена!\n\n" + f"Ваша подписка активирована на {days} дней.\n\n" + f"Ссылка на подключение : {subscription_url}\n\n" + f"Нажмите на ссылку выше, чтобы добавить VPN в приложение.\n\n" + f"Спасибо за покупку! 🎉" + ) + else: + user_message = ( + f"✅ Оплата подтверждена!\n\n" + f"Ваша подписка активирована на {days} дней.\n" + f"Username: {username}\n\n" + f"Ссылка на подключение будет отправлена администратором.\n\n" + f"Спасибо за покупку! 🎉" + ) + + # Клавиатура с кнопкой "В главное меню" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + # Отправляем уведомление пользователю + try: + await callback.bot.send_message( + chat_id=user_id, + text=user_message, + reply_markup=keyboard + ) + print(f"✅ Сообщение отправлено пользователю {user_id}") + except Exception as e: + print(f"❌ Не удалось отправить уведомление пользователю {user_id}: {e}") + else: + # Ошибка API + error_msg = f"⚠️ Подписка не создана\n\nОшибка: {api_result.get('error', 'Неизвестная ошибка')}\n\nОбратитесь в техподдержку." + + # Клавиатура с кнопкой "В главное меню" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + try: + await callback.bot.send_message( + chat_id=user_id, + text=error_msg, + reply_markup=keyboard + ) + except: + pass + + # Удаляем кнопки из сообщения в канале + try: + await callback.message.edit_reply_markup(reply_markup=None) + except: + pass + + # Добавляем пометку в канал + try: + status_text = "✅ ОПЛАЧЕНО" if api_result.get("success") else "⚠️ ОШИБКА API" + await callback.message.edit_caption( + caption=f"{callback.message.caption}\n\n{status_text}", + reply_markup=None + ) + except: + pass + + await callback.answer("Готово!") + + +@main_router.callback_query(lambda c: c.data.startswith("payment_decline_")) +async def handle_payment_decline(callback: types.CallbackQuery): + """Обработчик кнопки 'Отклонить'""" + # payment_decline_{user_id}_{tariff} + data = callback.data.replace("payment_decline_", "").split("_") + + # Получаем locale для клавиатур + locale = await get_user_locale(callback.from_user.id) + + if len(data) >= 2: + user_id = int(data[0]) + + # Клавиатура с кнопкой "В главное меню" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + # Отправляем уведомление пользователю + try: + await callback.bot.send_message( + chat_id=user_id, + text=f"❌ Оплата отклонена.\n\nПо вопросам обращайтесь в техподдержку.", + reply_markup=keyboard + ) + except Exception as e: + print(f"Не удалось отправить уведомление пользователю {user_id}: {e}") + + # Удаляем кнопки из сообщения в канале + try: + await callback.message.edit_reply_markup(reply_markup=None) + except: + pass + + # Добавляем пометку в канал + try: + await callback.message.edit_caption( + caption=f"{callback.message.caption}\n\n❌ ОТКЛОНЕНО", + reply_markup=None + ) + except: + pass + + await callback.answer("Оплата отклонена") diff --git a/keyboards/__init__.py b/keyboards/__init__.py new file mode 100644 index 0000000..d63178f --- /dev/null +++ b/keyboards/__init__.py @@ -0,0 +1,150 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton +from locales import localization + + +class Keyboards: + """Класс для создания клавиатур""" + + @staticmethod + def get_main_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: + """Основная клавиатура бота""" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("buttons.buy_subscription", locale), + callback_data="buy_subscription" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.support", locale), + callback_data="support" + ), + InlineKeyboardButton( + text=localization.get("buttons.rules", locale), + callback_data="rules" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.language", locale), + callback_data="language" + ) + ] + ] + ) + + return keyboard + + @staticmethod + def get_buy_subscription_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: + """Клавиатура для покупки подписки""" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("tariffs.14_days", locale), + callback_data="buy_14_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("tariffs.30_days", locale), + callback_data="buy_30_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("tariffs.60_days", locale), + callback_data="buy_60_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("tariffs.90_days", locale), + callback_data="buy_90_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("tariffs.180_days", locale), + callback_data="buy_180_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("tariffs.360_days", locale), + callback_data="buy_360_days" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + return keyboard + + @staticmethod + def get_language_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: + """Клавиатура выбора языка""" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=localization.get("languages.ru", locale), + callback_data="lang_ru" + ), + InlineKeyboardButton( + text=localization.get("languages.en", locale), + callback_data="lang_en" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("languages.kz", locale), + callback_data="lang_kz" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + return keyboard + + @staticmethod + def get_support_keyboard(locale: str = "ru") -> InlineKeyboardMarkup: + """Клавиатура техподдержки""" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="📝 Создать тикет", + callback_data="support_create_ticket" + ) + ], + [ + InlineKeyboardButton( + text="📋 Мои тикеты", + callback_data="support_my_tickets" + ) + ], + [ + InlineKeyboardButton( + text=localization.get("buttons.back", locale), + callback_data="back_to_main" + ) + ] + ] + ) + + return keyboard diff --git a/keyboards/payment.py b/keyboards/payment.py new file mode 100644 index 0000000..2cc9e39 --- /dev/null +++ b/keyboards/payment.py @@ -0,0 +1,24 @@ +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + +class PaymentKeyboards: + """Клавиатуры для оплаты""" + + @staticmethod + def get_payment_approval_keyboard(user_id: int, tariff: str, days: int) -> InlineKeyboardMarkup: + """Клавиатура для подтверждения оплаты (в канал)""" + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="✅ Аппрув", + callback_data=f"payment_approve_{user_id}_{tariff}_{days}" + ), + InlineKeyboardButton( + text="❌ Отклонить", + callback_data=f"payment_decline_{user_id}_{tariff}" + ) + ] + ] + ) + return keyboard diff --git a/locales/__init__.py b/locales/__init__.py new file mode 100644 index 0000000..a8ae6be --- /dev/null +++ b/locales/__init__.py @@ -0,0 +1,91 @@ +import json +from pathlib import Path +from typing import Optional + + +class Localization: + """Класс для управления локализацией""" + + _instance = None + _locales = {} + _default_locale = "ru" + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not self._locales: + self.load_locales() + + def load_locales(self): + """Загрузка всех файлов локализации""" + locales_dir = Path(__file__).parent + for locale_file in locales_dir.glob("*.json"): + locale_code = locale_file.stem + with open(locale_file, "r", encoding="utf-8") as f: + self._locales[locale_code] = json.load(f) + + def get(self, key: str, locale: str = None, **kwargs) -> str: + """ + Получение локализованной строки + + Args: + key: Ключ строки (например, "welcome", "buttons.buy_subscription") + locale: Код языка (ru, en, kz) + **kwargs: Параметры для форматирования строки + """ + if locale is None: + locale = self._default_locale + + if locale not in self._locales: + locale = self._default_locale + + keys = key.split(".") + value = self._locales.get(locale, {}) + + for k in keys: + if isinstance(value, dict): + value = value.get(k) + else: + value = None + break + + if value is None: + # Пытаемся найти в default locale + value = self._locales.get(self._default_locale, {}) + for k in keys: + if isinstance(value, dict): + value = value.get(k) + else: + value = None + break + + if value is None: + return key + + if kwargs: + try: + value = value.format(**kwargs) + except KeyError: + pass + + return value + + def get_locale_name(self, locale: str) -> str: + """Получение названия языка на родном языке""" + return self.get(f"languages.{locale}", locale) + + def set_default_locale(self, locale: str): + """Установка языка по умолчанию""" + if locale in self._locales: + self._default_locale = locale + + def get_available_locales(self) -> list: + """Получение списка доступных языков""" + return list(self._locales.keys()) + + +# Глобальный экземпляр локализации +localization = Localization() diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..bbd096a --- /dev/null +++ b/locales/en.json @@ -0,0 +1,45 @@ +{ + "welcome": "Hello, {username}!", + "subscription_status": "Your subscription: {status}", + "select_action": "Select an action:", + "subscription_active": "Active", + "subscription_inactive": "Inactive", + "buttons": { + "buy_subscription": "💳 Buy Subscription", + "support": "📞 Support", + "rules": "📜 Service Rules", + "language": "🌐 Language", + "back": "🔙 Back" + }, + "messages": { + "support": "Support: {username}", + "support_title": "📞 Support", + "support_description": "This is the ticket center: create requests, view responses and history.\n\n📝 Create ticket - describe your problem or question.\n📋 My tickets - status and correspondence\n\nTry to use tickets - this way we can help faster and nothing gets lost.", + "rules_title": "📜 VPN Rules", + "rules_text": "1. By using the bot, you agree to the rules and use the service lawfully.\n2. VPN is designed to protect data and secure internet access.\n3. Prohibited: hacking, spam, fraud, viruses and illegal content 🚫\n4. User is solely responsible for their actions.\n5. Technical data may be collected (not shared with third parties, except by law).\n6. Access may be restricted without refund for violations.\n\n⚠️ VPN does not guarantee complete anonymity.", + "select_tariff": "Select tariff:", + "tariff_selected": "You selected: {tariff}", + "select_language": "Выберите язык / Select language:", + "language_changed": "Language changed to {language}", + "payment_instruction_title": "Hello! 👋", + "payment_instruction_text": "To purchase a subscription, follow these steps:\n\n1️⃣ Transfer via SBP to: {phone}\n2️⃣ After payment, send the receipt or screenshot to this chat\n\nAfter payment confirmation, we will activate your subscription.\n\nThank you! 😊", + "payment_receipt_sent": "✅ Receipt received! Wait for payment confirmation." + }, + "subscription": { + "standard_title": "Standard Subscription", + "standard_description": "Basic tariff plan" + }, + "tariffs": { + "14_days": "14 days - 150₽", + "30_days": "30 days - 300₽", + "60_days": "60 days - 600₽", + "90_days": "90 days - 900₽", + "180_days": "180 days - 1800₽", + "360_days": "360 days - 3600₽" + }, + "languages": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "kz": "🇰🇿 Қазақша" + } +} \ No newline at end of file diff --git a/locales/kz.json b/locales/kz.json new file mode 100644 index 0000000..1ed13dd --- /dev/null +++ b/locales/kz.json @@ -0,0 +1,45 @@ +{ + "welcome": "Сәлем, {username}!", + "subscription_status": "Сіздің жазылымыңыз: {status}", + "select_action": "Әрекетті таңдаңыз:", + "subscription_active": "Белсенді", + "subscription_inactive": "Белсенді емес", + "buttons": { + "buy_subscription": "💳 Жазылуды сатып алу", + "support": "📞 Қолдау қызметі", + "rules": "📜 Қызмет ережелері", + "language": "🌐 Тіл", + "back": "🔙 Артқа" + }, + "messages": { + "support": "Қолдау қызметі: {username}", + "support_title": "📞 Қолдау", + "support_description": "Бұл тикет орталығы: өтініштер жасау, жауаптарды және тарихты қарау.\n\n📝 Тикет жасау - мәселеңізді немесе сұрағыңызды сипаттаңыз.\n📋 Менің тикеттерім - мәртебе және хат алмасу\n\nТикеттерді пайдаланыңыз - біз жылдамырақ көмектесеміз және ештеңе жоғалмайды.", + "rules_title": "📜 VPN ережелері", + "rules_text": "1. Ботты пайдалана отырып, сіз ережелермен келісесіз және сервисді заңды түрде пайдаланасыз.\n2. VPN деректерді қорғау және қауіпсіз интернетке қол жеткізу үшін арналған.\n3. Тыйым салынады: бұзу, спам, алаяқтық, вирустар және заңсыз контент 🚫\n4. Пайдаланушы өз әрекеттері үшін дербес жауап береді.\n5. Техникалық деректер жиналуы мүмкін (заңды жағдайларды қоспағанда, үшінші тұлғаларға берілмейді).\n6. Ережелерді бұзған жағдайда қол жеткізу қайтарусыз шектелуі мүмкін.\n\n⚠️ VPN толық анонимділікті кепілдік бермейді.", + "select_tariff": "Тарифті таңдаңыз:", + "tariff_selected": "Сіз таңдадыңыз: {tariff}", + "select_language": "Выберите язык / Select language:", + "language_changed": "Тіл өзгертілді: {language}", + "payment_instruction_title": "Сәлем! 👋", + "payment_instruction_text": "Жазылымды сатып алу үшін мына әрекеттерді орындаңыз:\n\n1️⃣ СБП арқылы {phone} нөміріне аударыңыз\n2️⃣ Төлемнен кейін чекті немесе скриншотты осы чатқа жіберіңіз\n\nТөлем расталғаннан кейін жазылымыңызды белсендіреміз.\n\nРақмет! 😊", + "payment_receipt_sent": "✅ Чек қабылданды! Төлемнің расталуын күтіңіз." + }, + "subscription": { + "standard_title": "Стандартты жазылым", + "standard_description": "Негізгі тарифтік жоспар" + }, + "tariffs": { + "14_days": "14 күн - 150₽", + "30_days": "30 күн - 300₽", + "60_days": "60 күн - 600₽", + "90_days": "90 күн - 900₽", + "180_days": "180 күн - 1800₽", + "360_days": "360 күн - 3600₽" + }, + "languages": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "kz": "🇰🇿 Қазақша" + } +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json new file mode 100644 index 0000000..8a56c5c --- /dev/null +++ b/locales/ru.json @@ -0,0 +1,45 @@ +{ + "welcome": "Привет, {username}!", + "subscription_status": "Твоя подписка: {status}", + "select_action": "Выберите действие:", + "subscription_active": "Активна", + "subscription_inactive": "Не активна", + "buttons": { + "buy_subscription": "💳 Купить подписку", + "support": "📞 Техподдержка", + "rules": "📜 Правила сервиса", + "language": "🌐 Язык", + "back": "🔙 Назад" + }, + "messages": { + "support": "Техподдержка: {username}", + "support_title": "📞 Поддержка", + "support_description": "Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n📝 Создать тикет - опишите проблему или вопрос.\n📋 Мои тикеты - статус и переписка\n\nСтарайтесь использовать тикеты - так мы быстрее поможем и ничего не потеряется.", + "rules_title": "📜 Правила VPN", + "rules_text": "1. Используя бота, вы соглашаетесь с правилами и используете сервис законно.\n2. VPN предназначен для защиты данных и безопасного доступа к интернету.\n3. Запрещены: взломы, спам, мошенничество, вирусы и незаконный контент 🚫\n4. Пользователь сам несёт ответственность за свои действия.\n5. Возможен сбор технических данных (без передачи третьим лицам, кроме закона).\n6. За нарушения доступ может быть ограничен без возврата средств.\n\n⚠️ VPN не гарантирует полной анонимности.", + "select_tariff": "Выберите тариф:", + "tariff_selected": "Вы выбрали тариф: {tariff}", + "select_language": "Выберите язык / Select language:", + "language_changed": "Язык изменён на {language}", + "payment_instruction_title": "Здравствуйте! 👋", + "payment_instruction_text": "Чтобы приобрести подписку, выполните следующие шаги:\n\n1️⃣ Переведите сумму через СБП по номеру: {phone}\n2️⃣ После оплаты отправьте чек или скриншот перевода в этот чат\n\nПосле подтверждения оплаты мы активируем вашу подписку.\n\nСпасибо! 😊", + "payment_receipt_sent": "✅ Чек получен! Ожидайте подтверждения оплаты." + }, + "subscription": { + "standard_title": "Стандартная подписка", + "standard_description": "Базовый тарифный план" + }, + "tariffs": { + "14_days": "14 дней - 150₽", + "30_days": "30 дней - 300₽", + "60_days": "60 дней - 600₽", + "90_days": "90 дней - 900₽", + "180_days": "180 дней - 1800₽", + "360_days": "360 дней - 3600₽" + }, + "languages": { + "ru": "🇷🇺 Русский", + "en": "🇬🇧 English", + "kz": "🇰🇿 Қазақша" + } +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..140e94d --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.client.default import DefaultBotProperties + +from config import config +from core import db +from handlers import main_router, admin_router + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +# Инициализация бота и диспетчера +bot = Bot(token=config.bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) +dp = Dispatcher() + +# Регистрация роутеров +dp.include_router(main_router) +dp.include_router(admin_router) + + +async def on_startup(): + """Инициализация при запуске""" + # Создаём БД и таблицы если нет + await db.initialize() + print("Бот запущен...") + + +async def main(): + """Запуск бота""" + await on_startup() + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3317c8f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +aiogram==3.3.0 +mysql-connector-python==8.3.0 +python-dotenv==1.0.1 +aiohttp==3.9.3