commit 792f8e00be0ef15321eaac61e35df43730a098fd Author: Алексей Халецкий Date: Thu Jul 9 15:34:29 2026 +0000 Добавить main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..77da135 --- /dev/null +++ b/main.py @@ -0,0 +1,1095 @@ +import os +import json +import logging +import time +import urllib3 +import threading +import uuid +import asyncio +from typing import Dict, List, Optional +import requests +from datetime import datetime, timedelta +from dotenv import load_dotenv +from flask import Flask, request, jsonify +from database.db import db + +# Отключаем предупреждения SSL для разработки +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +load_dotenv() + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# --- Настройка YooKassa --- +YOOKASSA_SHOP_ID = os.getenv('YOOKASSA_SHOP_ID') +YOOKASSA_SECRET_KEY = os.getenv('YOOKASSA_SECRET_KEY') +YOOKASSA_RETURN_URL = os.getenv('YOOKASSA_RETURN_URL', 'https://t.me/ваш_бот') + +USE_YOOKASSA = YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY + +if USE_YOOKASSA: + try: + from yookassa import Configuration, Payment + + Configuration.account_id = YOOKASSA_SHOP_ID + Configuration.secret_key = YOOKASSA_SECRET_KEY + logger.info("✅ YooKassa настроена") + except ImportError: + logger.warning("⚠️ Yookassa SDK не установлен. Установите: pip install yookassa") + USE_YOOKASSA = False +else: + logger.warning("⚠️ YooKassa не настроена. Используется режим имитации платежей") + +# --- Flask приложение --- +webhook_app = Flask(__name__) + + +@webhook_app.route('/webhook/turnstile', methods=['POST']) +def handle_turnstile_event(): + try: + data = request.get_json() + logger.info(f"📥 Получены данные от турникета") + thread = threading.Thread(target=process_turnstile_event_async, args=(data,)) + thread.start() + return jsonify({"status": "success"}), 200 + except Exception as e: + logger.error(f"❌ Ошибка: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + + +@webhook_app.route('/webhook/health', methods=['GET']) +def health_check(): + return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()}), 200 + + +@webhook_app.route('/webhook/yookassa', methods=['POST']) +def handle_yookassa_webhook(): + try: + data = request.get_json() + logger.info(f"📥 Получен вебхук от YooKassa") + thread = threading.Thread(target=process_yookassa_webhook_async, args=(data,)) + thread.start() + return jsonify({"status": "success"}), 200 + except Exception as e: + logger.error(f"❌ Ошибка: {e}") + return jsonify({"status": "error", "message": str(e)}), 500 + + +def process_yookassa_webhook_async(data: Dict): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(process_yookassa_webhook(data)) + loop.close() + except Exception as e: + logger.error(f"❌ Ошибка: {e}") + + +async def process_yookassa_webhook(data: Dict): + """Обработка уведомления от YooKassa""" + try: + event = data.get('event') + obj = data.get('object', {}) + + if event == 'payment.succeeded': + payment_id = obj.get('id') + amount = float(obj.get('amount', {}).get('value', 0)) + metadata = obj.get('metadata', {}) + user_id = int(metadata.get('user_id', 0)) + + if user_id: + # Проверяем существование платежа + existing = await db.get_payment_by_transaction_id(payment_id) + if existing and existing.get('status') == 'success': + logger.info(f"⏭️ Платеж {payment_id} уже обработан") + return + + # Обновляем или создаем платеж + if existing and existing.get('status') == 'pending': + await db.update_payment_status(payment_id, 'success') + else: + await db.record_payment(parent_id=user_id, amount=amount, transaction_id=payment_id, + status='success') + + # Продлеваем подписку + subscription = await db.extend_subscription(user_id) + paid_until = subscription.get('paid_until') + + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=( + f"✅ **Оплата прошла успешно!**\n\n" + f"💳 Сумма: {amount} ₽\n" + f"📅 Подписка продлена до: **{paid_until.strftime('%d.%m.%Y') if paid_until else 'не указано'}**\n" + f"🆔 Транзакция: `{payment_id[:12]}`\n\n" + f"Спасибо за использование сервиса! 🎉" + ), + is_user=True, + parse_mode='markdown' + ) + except Exception as e: + logger.error(f"❌ Ошибка отправки уведомления: {e}") + + elif event == 'payment.canceled': + payment_id = obj.get('id') + metadata = obj.get('metadata', {}) + user_id = int(metadata.get('user_id', 0)) + + if user_id: + await db.update_payment_status(payment_id, 'failed') + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=( + f"❌ **Платеж отменен**\n\n" + f"Платеж на сумму {float(obj.get('amount', {}).get('value', 0))} ₽ был отменен." + ), + is_user=True, + parse_mode='markdown' + ) + except Exception as e: + logger.error(f"❌ Ошибка отправки уведомления: {e}") + + except Exception as e: + logger.error(f"❌ Ошибка обработки вебхука YooKassa: {e}") + + +def process_turnstile_event_async(data: Dict): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(process_turnstile_event(data)) + loop.close() + except Exception as e: + logger.error(f"❌ Ошибка: {e}") + + +async def process_turnstile_event(data: Dict): + event_id = data.get('id') + time_label = data.get('time_label') + user_id = data.get('user_id') + resource_number = data.get('resource_number') + + try: + event_time = datetime.strptime(time_label, '%Y-%m-%d %H:%M:%S') + except: + event_time = datetime.now() + + event_type_text = "вошел" if resource_number == 1 else "вышел" + event_type_db = "enter" if resource_number == 1 else "exit" + + existing = await db.get_access_event_by_id(event_id) + if existing: + return + + event = await db.save_access_event( + event_id=event_id, user_id=user_id, resource_number=resource_number, event_time=event_time + ) + + child = await db.get_child_by_card_number(user_id) + if child: + parent = await db.get_parent(child['parent_id']) + if parent: + subscription = await db.check_subscription_status(parent['user_id']) + if subscription.get('status') in ['trial', 'active']: + school_info = await db.get_school_by_id(child['school_id']) + school_name = school_info.get('school_name', 'школа') if school_info else 'школа' + message = f"🔔 **{child['student_name']}** {event_type_text} из школы **{school_name}**\n🕐 {event_time.strftime('%H:%M')}" + try: + if bot_instance: + bot_instance.send_message( + recipient_id=parent['user_id'], + text=message, + is_user=True, + parse_mode='markdown' + ) + await db.save_notification(parent_id=parent['user_id'], child_id=child['id'], message=message, + event_type=event_type_db) + await db.mark_event_processed(event['id']) + except Exception as e: + logger.error(f"❌ Ошибка отправки уведомления: {e}") + + +# --- Бот MAX --- +class MAXBot: + def __init__(self, token: str, verify_ssl: bool = False): + self.token = token + self.base_url = "https://platform-api2.max.ru" + self.headers = { + "Authorization": self.token, + "Content-Type": "application/json" + } + self.session = requests.Session() + self.session.headers.update(self.headers) + self.session.verify = verify_ssl + self.last_update_id = 0 + + def _make_request(self, method: str, endpoint: str, params: Optional[Dict] = None, + data: Optional[Dict] = None) -> Dict: + url = f"{self.base_url}/{endpoint.lstrip('/')}" + try: + response = self.session.request(method, url, params=params, json=data) + response.raise_for_status() + return response.json() if response.content else {} + except requests.exceptions.RequestException as e: + logger.error(f"❌ Ошибка запроса: {e}") + if hasattr(e, 'response') and e.response: + logger.error(f"Статус: {e.response.status_code}, Ответ: {e.response.text}") + raise + + def get_me(self) -> Dict: + return self._make_request("GET", "me") + + def send_message(self, recipient_id: int, text: str, is_user: bool = True, + parse_mode: Optional[str] = None, attachments: Optional[List[Dict]] = None) -> Dict: + params = {} + if is_user: + params['user_id'] = recipient_id + else: + params['chat_id'] = recipient_id + + body = {'text': text} + if parse_mode: + body['format'] = parse_mode + if attachments: + body['attachments'] = attachments + + logger.info(f"📤 Отправка в {'user' if is_user else 'chat'} {recipient_id}") + return self._make_request("POST", "messages", params=params, data=body) + + def get_updates(self, offset: Optional[int] = None, limit: int = 100, timeout: int = 30) -> List[Dict]: + params = {"limit": limit, "timeout": timeout} + if offset is not None: + params["offset"] = offset + url = f"{self.base_url}/updates" + try: + response = self.session.get(url, params=params, timeout=timeout + 5) + response.raise_for_status() + result = response.json() + if isinstance(result, list): + return result + elif isinstance(result, dict): + return result.get("result", result.get("updates", [])) + return [] + except requests.exceptions.Timeout: + return [] + except requests.exceptions.ConnectionError as e: + logger.warning(f"⚠️ Ошибка соединения: {e}") + return [] + except Exception as e: + logger.error(f"❌ Ошибка получения обновлений: {e}") + raise + + @staticmethod + def make_inline_keyboard(buttons: List[List[Dict]]) -> Dict: + return { + "type": "inline_keyboard", + "payload": { + "buttons": buttons + } + } + + +bot_instance = None + +# --- ФОННЫЙ МОНИТОРИНГ ПЛАТЕЖЕЙ --- +# Глобальный словарь для отслеживания активных задач мониторинга +active_monitoring = {} + + +async def monitor_payment(payment_id: str, user_id: int, timeout_minutes: int = 15): + """ + Фоновая задача для мониторинга статуса платежа. + Проверяет статус каждые 5 секунд в течение timeout_minutes минут. + """ + start_time = datetime.now() + check_interval = 5 + timeout_seconds = timeout_minutes * 60 + + logger.info(f"🔍 [МОНИТОРИНГ] Начат мониторинг платежа {payment_id} для пользователя {user_id}") + logger.info(f"⏱️ [МОНИТОРИНГ] Интервал проверки: {check_interval} сек, таймаут: {timeout_minutes} мин") + + # Сохраняем задачу в активные мониторинги + active_monitoring[payment_id] = True + + while True: + # Проверяем, не был ли мониторинг остановлен извне + if not active_monitoring.get(payment_id, True): + logger.info(f"⏹️ [МОНИТОРИНГ] Мониторинг платежа {payment_id} остановлен извне") + break + + elapsed = (datetime.now() - start_time).total_seconds() + + # Проверка таймаута + if elapsed > timeout_seconds: + logger.warning(f"⏰ [МОНИТОРИНГ] Мониторинг платежа {payment_id} истек (прошло {timeout_minutes} мин)") + await db.update_payment_status(payment_id, 'expired') + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=( + f"⏰ **Время оплаты истекло**\n\n" + f"Платеж на сумму 50 ₽ не был завершен в течение {timeout_minutes} минут.\n" + f"Если вы хотите оплатить подписку, нажмите /pay снова." + ), + is_user=True, + parse_mode='markdown' + ) + logger.info(f"📨 [МОНИТОРИНГ] Уведомление об истечении отправлено пользователю {user_id}") + except Exception as e: + logger.error(f"❌ [МОНИТОРИНГ] Ошибка отправки уведомления: {e}") + # Удаляем из активных мониторингов + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + + try: + if USE_YOOKASSA: + # Получаем статус платежа через API ЮKassa + payment = Payment.find_one(payment_id) + status = payment.status + logger.info(f"📊 [МОНИТОРИНГ] Статус платежа {payment_id}: {status} (прошло {int(elapsed)} сек)") + + if status == 'succeeded': + logger.info(f"✅ [МОНИТОРИНГ] Платеж {payment_id} УСПЕШЕН для пользователя {user_id}") + + # Проверяем, не обработан ли уже платеж + existing = await db.get_payment_by_transaction_id(payment_id) + if existing and existing.get('status') == 'success': + logger.info(f"⏭️ [МОНИТОРИНГ] Платеж {payment_id} уже обработан, пропускаем") + # Удаляем из активных мониторингов + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + + # Получаем сумму платежа + amount = float(payment.amount.value) + + # Если платеж существует и статус pending - обновляем + if existing and existing.get('status') == 'pending': + logger.info(f"🔄 [МОНИТОРИНГ] Обновление статуса платежа {payment_id} с pending на success") + await db.update_payment_status(payment_id, 'success') + else: + # Платежа нет в БД - создаем новый + logger.info(f"🆕 [МОНИТОРИНГ] Создание нового платежа {payment_id} в БД") + await db.record_payment(parent_id=user_id, amount=amount, transaction_id=payment_id, + status='success') + + # Продлеваем подписку + logger.info(f"🔄 [МОНИТОРИНГ] Продление подписки для пользователя {user_id}") + subscription = await db.extend_subscription(user_id) + paid_until = subscription.get('paid_until') + + # Отправляем уведомление пользователю + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=( + f"✅ **Оплата прошла успешно!**\n\n" + f"💳 Сумма: {amount} ₽\n" + f"📅 Подписка продлена до: **{paid_until.strftime('%d.%m.%Y') if paid_until else 'не указано'}**\n" + f"🆔 Транзакция: `{payment_id[:12]}`\n\n" + f"Спасибо за использование сервиса! 🎉" + ), + is_user=True, + parse_mode='markdown' + ) + logger.info( + f"📨 [МОНИТОРИНГ] Уведомление об успешной оплате отправлено пользователю {user_id}") + except Exception as e: + logger.error(f"❌ [МОНИТОРИНГ] Ошибка отправки уведомления: {e}") + else: + logger.warning(f"⚠️ [МОНИТОРИНГ] bot_instance не инициализирован, уведомление не отправлено") + + logger.info(f"✅ [МОНИТОРИНГ] Мониторинг платежа {payment_id} завершен (успешно)") + # Удаляем из активных мониторингов + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + + elif status in ['canceled', 'expired']: + logger.info(f"❌ [МОНИТОРИНГ] Платеж {payment_id} {status} для пользователя {user_id}") + await db.update_payment_status(payment_id, status) + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=( + f"❌ **Платеж {status}**\n\n" + f"Платеж на сумму 50 ₽ был {status}.\n" + f"Если вы хотите оплатить подписку, нажмите /pay снова." + ), + is_user=True, + parse_mode='markdown' + ) + logger.info(f"📨 [МОНИТОРИНГ] Уведомление об отмене отправлено пользователю {user_id}") + except Exception as e: + logger.error(f"❌ [МОНИТОРИНГ] Ошибка отправки уведомления: {e}") + # Удаляем из активных мониторингов + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + + elif status == 'pending': + # Платеж еще в обработке, продолжаем мониторинг + logger.debug(f"⏳ [МОНИТОРИНГ] Платеж {payment_id} в статусе pending, продолжаем ожидание...") + + else: + logger.info(f"ℹ️ [МОНИТОРИНГ] Неизвестный статус платежа {payment_id}: {status}") + # Удаляем из активных мониторингов при неизвестном статусе + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + else: + # В тестовом режиме просто выходим + logger.info(f"ℹ️ [МОНИТОРИНГ] Тестовый режим, мониторинг завершен") + if payment_id in active_monitoring: + del active_monitoring[payment_id] + break + + except Exception as e: + logger.error(f"❌ [МОНИТОРИНГ] Ошибка при проверке статуса платежа {payment_id}: {e}") + # Не прерываем мониторинг при ошибке, продолжаем проверять + + # Ждем перед следующей проверкой + await asyncio.sleep(check_interval) + + logger.info(f"🏁 [МОНИТОРИНГ] Мониторинг платежа {payment_id} завершен") + + +# --- ФОННАЯ ЗАДАЧА ДЛЯ ПРОВЕРКИ ПОДПИСОК --- +async def check_expiring_subscriptions(): + """ + Фоновая задача, которая проверяет подписки раз в час + и отправляет уведомления пользователям, у которых до окончания осталось 3 дня или меньше. + """ + # Интервал проверки - 1 час + check_interval = 3600 # 3600 секунд = 1 час + + # Для теста можно уменьшить до 60 секунд + # check_interval = 60 + + logger.info("🔔 [ПОДПИСКИ] Запущена фоновая задача проверки подписок") + logger.info(f"⏱️ [ПОДПИСКИ] Интервал проверки: {check_interval // 60} минут") + + while True: + try: + # Проверяем подписки, у которых осталось 3 дня или меньше + expiring = await db.get_expiring_subscriptions(days_threshold=3) + + if expiring: + logger.info(f"📋 [ПОДПИСКИ] Найдено {len(expiring)} подписок с истекающим сроком") + + for sub in expiring: + parent_id = sub.get('parent_id') + user_id = sub.get('user_id') + username = sub.get('username', 'Пользователь') + status = sub.get('status') + + # Вычисляем, сколько дней осталось + if status == 'trial': + end_date = sub.get('trial_end') + subscription_type = 'trial' + else: + end_date = sub.get('paid_until') + subscription_type = 'paid' + + if not end_date: + continue + + days_left = (end_date - datetime.now()).days + + # Проверяем, отправляли ли уже уведомление для этого количества дней + notification_sent = await db.get_subscription_notification_sent(parent_id, days_left) + + if not notification_sent and days_left <= 3 and days_left >= 0: + # --- ФОРМИРУЕМ РАЗНЫЕ УВЕДОМЛЕНИЯ ДЛЯ ТРИАЛА И ПЛАТНОЙ ПОДПИСКИ --- + + if subscription_type == 'trial': + # Уведомление для триал-периода + if days_left == 3: + title = "🎁 **Триал-период заканчивается через 3 дня!**" + description = ( + "Ваш бесплатный триал-период подходит к концу.\n" + "После окончания триала подписка будет стоить **50 ₽/месяц**." + ) + elif days_left == 2: + title = "⚡ **Триал-период заканчивается через 2 дня!**" + description = ( + "Осталось всего 2 дня бесплатного пользования.\n" + "Не упустите возможность продлить доступ к сервису." + ) + elif days_left == 1: + title = "🔥 **Триал-период заканчивается ЗАВТРА!**" + description = ( + "Завтра ваш бесплатный триал-период закончится.\n" + "После этого подписка будет стоить **50 ₽/месяц**.\n" + "Оплатите подписку сейчас, чтобы не потерять доступ к сервису." + ) + else: + title = f"🎁 **Триал-период заканчивается через {days_left} дня(ей)!**" + description = ( + f"Ваш бесплатный период заканчивается через {days_left} дня(ей).\n" + "Подписка будет стоить **50 ₽/месяц**." + ) + + action_text = "💳 **Оплатить подписку:** /pay" + + else: + # Уведомление для платной подписки + if days_left == 3: + title = "⏰ **Подписка заканчивается через 3 дня!**" + description = ( + "Ваша платная подписка скоро закончится.\n" + "Продлите её сейчас, чтобы продолжить получать уведомления о детях." + ) + elif days_left == 2: + title = "⚡ **Подписка заканчивается через 2 дня!**" + description = ( + "Осталось всего 2 дня активной подписки.\n" + "Продлите доступ к сервису, чтобы не пропустить важные уведомления." + ) + elif days_left == 1: + title = "🔥 **Подписка заканчивается ЗАВТРА!**" + description = ( + "Завтра ваша подписка закончится!\n" + "Продлите её сейчас, чтобы не потерять доступ к сервису." + ) + else: + title = f"⏰ **Подписка заканчивается через {days_left} дня(ей)!**" + description = ( + f"Ваша подписка заканчивается через {days_left} дня(ей).\n" + "Продлите доступ к сервису." + ) + + action_text = "💳 **Продлить подписку:** /pay" + + # Формируем полное сообщение + message = ( + f"{title}\n\n" + f"👤 Пользователь: {username}\n" + f"📅 Осталось дней: **{days_left}**\n" + f"📆 Дата окончания: **{end_date.strftime('%d.%m.%Y')}**\n\n" + f"{description}\n\n" + f"{action_text}" + ) + + # Отправляем уведомление + if bot_instance: + try: + bot_instance.send_message( + recipient_id=user_id, + text=message, + is_user=True, + parse_mode='markdown' + ) + logger.info( + f"📨 [ПОДПИСКИ] Уведомление отправлено пользователю {username} (ID: {user_id}) — осталось {days_left} дн. (тип: {subscription_type})") + + # Отмечаем, что уведомление отправлено + await db.mark_subscription_notification_sent(parent_id, days_left) + except Exception as e: + logger.error(f"❌ [ПОДПИСКИ] Ошибка отправки уведомления пользователю {user_id}: {e}") + else: + logger.warning(f"⚠️ [ПОДПИСКИ] bot_instance не инициализирован") + + # Очищаем старые записи уведомлений (старше 30 дней) + cleaned = await db.clean_old_notifications(days=30) + if cleaned: + logger.info(f"🧹 [ПОДПИСКИ] Удалено {cleaned} старых записей уведомлений") + else: + logger.debug("📭 [ПОДПИСКИ] Подписок с истекающим сроком не найдено") + + # Ждем до следующей проверки + await asyncio.sleep(check_interval) + + except Exception as e: + logger.error(f"❌ [ПОДПИСКИ] Ошибка в фоновой задаче: {e}") + # В случае ошибки ждем минуту и продолжаем + await asyncio.sleep(60) + + +async def start_subscription_checker(): + """Запуск фоновой задачи проверки подписок""" + asyncio.create_task(check_expiring_subscriptions()) + logger.info("✅ [ПОДПИСКИ] Фоновая задача проверки подписок запущена") + + +# --- ОБРАБОТЧИК БОТА --- +class MAXBotHandler: + def __init__(self, bot: MAXBot): + self.bot = bot + self.running = True + self.reconnect_delay = 1 + + def extract_message_data(self, update: Dict) -> Optional[Dict]: + if update.get("update_type") == "bot_started": + user = update.get("user", {}) + user_id = user.get("user_id") + username = user.get("name", "Пользователь") + chat_id = update.get("chat_id") + return { + "chat_id": chat_id, + "user_id": user_id, + "username": username, + "text": "/start", + "is_callback": False, + "is_bot_started": True + } + + if "callback" in update: + callback = update["callback"] + payload = callback.get("payload") + user_id = callback.get("user", {}).get("user_id") + username = callback.get("user", {}).get("name", "Пользователь") + message = update.get("message", {}) + recipient = message.get("recipient", {}) + chat_id = recipient.get("chat_id") + + if payload: + return { + "chat_id": chat_id, + "user_id": user_id, + "username": username, + "text": payload, + "is_callback": True + } + return None + + message = update.get("message") + if message: + recipient = message.get("recipient", {}) + sender = message.get("sender", {}) + body = message.get("body", {}) + chat_id = recipient.get("chat_id") + user_id = sender.get("user_id") + username = sender.get("name", "Пользователь") + text = body.get("text", "") + if chat_id and user_id: + return { + "chat_id": chat_id, + "user_id": user_id, + "username": username, + "text": text, + "is_callback": False + } + return None + + async def handle_message(self, update: Dict) -> bool: + msg_data = self.extract_message_data(update) + if not msg_data: + return False + + chat_id = msg_data["chat_id"] + user_id = msg_data["user_id"] + username = msg_data["username"] + text = msg_data["text"] + is_callback = msg_data.get("is_callback", False) + is_bot_started = msg_data.get("is_bot_started", False) + + try: + parent = await db.get_parent(user_id) + is_new_user = parent is None + + if is_new_user: + await db.create_parent(user_id, username) + await db.create_subscription(user_id) + logger.info(f"🆕 Новый пользователь: {username} (ID: {user_id})") + + # --- КЛАВИАТУРЫ --- + main_menu_keyboard = self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "👨‍👩‍👧‍👦 Мои дети", "payload": "/list"}, + {"type": "callback", "text": "➕ Добавить", "payload": "/add"}], + [{"type": "callback", "text": "🏫 Школы", "payload": "/schools"}, + {"type": "callback", "text": "🔍 Поиск", "payload": "/search"}], + [{"type": "callback", "text": "📊 Подписка", "payload": "/subscribe"}, + {"type": "callback", "text": "💳 Оплатить", "payload": "/pay"}], + [{"type": "callback", "text": "🔔 Уведомления", "payload": "/notify"}, + {"type": "callback", "text": "❓ Помощь", "payload": "/help"}] + ]) + + start_button_keyboard = self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "🚀 Старт", "payload": "/start"}] + ]) + + children_menu = self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "➕ Добавить", "payload": "/add"}, + {"type": "callback", "text": "📋 Список", "payload": "/list"}], + [{"type": "callback", "text": "🗑️ Удалить", "payload": "/delete"}, + {"type": "callback", "text": "🔙 Главное меню", "payload": "/start"}] + ]) + + schools_menu = self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "📋 Все школы", "payload": "/schools"}, + {"type": "callback", "text": "🔍 Поиск", "payload": "/search"}], + [{"type": "callback", "text": "🔙 Главное меню", "payload": "/start"}] + ]) + + sub_menu = self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "📊 Статус", "payload": "/subscribe"}, + {"type": "callback", "text": "💳 Оплатить", "payload": "/pay"}], + [{"type": "callback", "text": "📜 История", "payload": "/history"}, + {"type": "callback", "text": "🔙 Главное меню", "payload": "/start"}] + ]) + + # --- ОБРАБОТКА КОМАНД --- + response_text = "" + attachments = None + command = text + args = None + + if " " in text and text.startswith("/"): + parts = text.split(" ", 1) + command = parts[0] + args = parts[1].strip() if len(parts) > 1 else None + + if is_bot_started or (is_new_user and command != "/start"): + response_text = ( + "👋 **Добро пожаловать!**\n\n" + "Я бот для родителей, который помогает отслеживать посещаемость детей в школе.\n\n" + "Нажмите кнопку **🚀 Старт**, чтобы начать пользоваться ботом 👇" + ) + attachments = [start_button_keyboard] + + elif command == "/start": + sub = await db.check_subscription_status(user_id) + status = sub.get('status') + if status == 'trial': + days_left = (sub.get('trial_end') - datetime.now()).days if sub.get('trial_end') else 0 + status_text = f"🎁 Триал (осталось {days_left} дн.)" + elif status == 'active': + days_left = (sub.get('paid_until') - datetime.now()).days if sub.get('paid_until') else 0 + status_text = f"✅ Активна (осталось {days_left} дн.)" + elif status == 'expired': + status_text = "❌ Истекла" + else: + status_text = f"❓ {status}" + + response_text = ( + f"👋 **Добро пожаловать!**\n\n" + f"📊 Статус подписки: {status_text}\n" + f"🆔 Ваш ID: `{user_id}`\n\n" + "Используйте кнопки ниже 👇" + ) + attachments = [main_menu_keyboard] + + elif command == "/list": + children = await db.get_children(user_id) + if not children: + response_text = "📭 У вас пока нет детей.\n\nИспользуйте /add или кнопку '➕ Добавить'" + else: + response_text = "📋 **Ваши дети:**\n\n" + for child in children: + response_text += f"**ID:** {child['id']}\n👤 {child['student_name']}\n🏫 {child.get('school_number', '')}\n💳 {child['card_number'][:4]} **** **** {child['card_number'][-4:]}\n\n" + attachments = [children_menu] + + elif command == "/add": + sub = await db.check_subscription_status(user_id) + if sub.get('status') not in ['trial', 'active']: + response_text = "❌ **Подписка неактивна**\n\nИспользуйте /pay или кнопку '💳 Оплатить' для оплаты 50 ₽/месяц." + attachments = [sub_menu] + else: + response_text = ( + "➕ **Добавление ребенка**\n\n" + "Введите данные в формате:\n" + "`Имя|НомерШколы|НомерКарты`\n\n" + "Пример:\n" + "`Иван Петров|101|1234567890123456`\n\n" + "Список школ можно посмотреть через /schools" + ) + attachments = [self.bot.make_inline_keyboard([ + [{"type": "callback", "text": "📋 Список школ", "payload": "/schools"}], + [{"type": "callback", "text": "🔙 Главное меню", "payload": "/start"}] + ])] + + elif command == "/add" and args: + sub = await db.check_subscription_status(user_id) + if sub.get('status') not in ['trial', 'active']: + response_text = "❌ Подписка неактивна. Используйте /pay" + else: + try: + parts = args.split("|") + if len(parts) != 3: + response_text = "❌ Неверный формат.\n\nИспользуйте: `/add Имя|Школа|Карта`" + else: + name, school_num, card = parts + card_clean = card.replace(" ", "").replace("-", "") + if not card_clean.isdigit() or len(card_clean) != 16: + response_text = "❌ Номер карты должен содержать 16 цифр." + else: + child = await db.add_child(user_id, card_clean, name.strip(), school_num.strip()) + response_text = f"✅ **Ребёнок добавлен!**\n\n👤 Имя: {child['student_name']}\n🏫 Школа: {school_num}\n💳 Карта: {child['card_number'][:4]} **** **** {child['card_number'][-4:]}" + attachments = [children_menu] + except Exception as e: + response_text = f"❌ Ошибка: {e}" + + elif command == "/schools": + schools = await db.get_all_schools() + if not schools: + response_text = "📭 В справочнике пока нет школ." + else: + response_text = "🏫 **Список школ:**\n\n" + for school in schools[:10]: + response_text += f"**{school['school_number']}** - {school['school_name']}\n📍 {school.get('city', '')}\n\n" + if len(schools) > 10: + response_text += f"... и еще {len(schools) - 10} школ.\nИспользуйте /search для поиска." + attachments = [schools_menu] + + elif command == "/search": + if args: + schools = await db.search_schools(args) + if not schools: + response_text = f"❌ Школы по запросу '{args}' не найдены." + else: + response_text = f"🔍 **Результаты поиска:**\n\n" + for school in schools[:5]: + response_text += f"**{school['school_number']}** - {school['school_name']}\n📍 {school.get('city', '')}\n\n" + if len(schools) > 5: + response_text += f"... и еще {len(schools) - 5} школ." + attachments = [schools_menu] + else: + response_text = "🔍 **Поиск школы**\n\nВведите: `/search 101` или `/search гимназия`" + attachments = [schools_menu] + + elif command == "/subscribe": + sub = await db.check_subscription_status(user_id) + status = sub.get('status') + if status == 'trial': + days_left = (sub.get('trial_end') - datetime.now()).days if sub.get('trial_end') else 0 + response_text = f"🎁 **Триал-период**\nОсталось дней: **{days_left}**\n\nПосле окончания - 50 ₽/месяц.\nИспользуйте /pay для оплаты." + elif status == 'active': + paid_until = sub.get('paid_until') + days_left = (paid_until - datetime.now()).days if paid_until else 0 + response_text = f"✅ **Подписка активна**\nДо: **{paid_until.strftime('%d.%m.%Y') if paid_until else 'не указано'}**\nОсталось дней: **{days_left}**" + elif status == 'expired': + response_text = "❌ **Подписка истекла**\n\nИспользуйте /pay или кнопку '💳 Оплатить' для оплаты 50 ₽/месяц." + else: + response_text = f"❓ Статус: {status}" + attachments = [sub_menu] + + elif command == "/pay": + if USE_YOOKASSA: + try: + payment = Payment.create({ + "amount": {"value": "50.00", "currency": "RUB"}, + "confirmation": {"type": "redirect", "return_url": YOOKASSA_RETURN_URL}, + "capture": True, + "description": f"Подписка MAX Bot для пользователя {user_id}", + "metadata": {"user_id": str(user_id)} + }, uuid.uuid4()) + + await db.record_payment( + parent_id=user_id, + amount=50.00, + transaction_id=payment.id, + status='pending' + ) + + # Запускаем фоновый мониторинг платежа + asyncio.create_task(monitor_payment(payment.id, user_id, timeout_minutes=15)) + + response_text = ( + f"💳 **Оплата подписки**\n\n" + f"Сумма: **50 ₽/месяц**\n\n" + f"🔗 **Ссылка для оплаты:**\n" + f"{payment.confirmation.confirmation_url}\n\n" + f"⏳ У вас есть **15 минут** на оплату.\n" + f"Статус платежа будет отслеживаться автоматически." + ) + attachments = [sub_menu] + + except Exception as e: + logger.error(f"❌ Ошибка создания платежа YooKassa: {e}") + response_text = "❌ **Ошибка создания платежа**\n\nПопробуйте позже или обратитесь в поддержку." + attachments = [sub_menu] + else: + # Тестовый режим + tid = f"pay_{uuid.uuid4().hex[:12]}" + await db.record_payment(parent_id=user_id, amount=50.00, transaction_id=tid, status='success') + subscription = await db.extend_subscription(user_id) + paid_until = subscription.get('paid_until') + + response_text = ( + f"✅ **Оплата прошла успешно!** (тестовый режим)\n\n" + f"💳 Сумма: 50 ₽\n" + f"📅 Подписка продлена до: **{paid_until.strftime('%d.%m.%Y') if paid_until else 'не указано'}**\n" + f"🆔 Транзакция: `{tid}`\n\n" + f"В реальном режиме здесь будет ссылка на оплату." + ) + attachments = [sub_menu] + + elif command == "/history": + payments = await db.get_payment_history(user_id, limit=10) + if not payments: + response_text = "📭 История платежей пуста." + else: + response_text = "💳 **История платежей:**\n\n" + for p in payments: + date = p.get('payment_date', datetime.now()).strftime('%d.%m.%Y') + amount = p.get('amount', 0) + status = p.get('status', 'unknown') + icon = "✅" if status == 'success' else "❌" if status == 'failed' else "⏳" + response_text += f"{icon} {date} - {amount} ₽ ({status})\n" + attachments = [sub_menu] + + elif command == "/delete" and args: + sub = await db.check_subscription_status(user_id) + if sub.get('status') not in ['trial', 'active']: + response_text = "❌ Подписка неактивна. Используйте /pay" + else: + try: + child_id = int(args) + success = await db.delete_child(user_id, child_id) + if success: + response_text = f"✅ Ребёнок с ID {child_id} удалён." + else: + response_text = f"❌ Ребёнок с ID {child_id} не найден." + except ValueError: + response_text = "❌ Неверный формат. Используйте: `/delete ID`" + attachments = [children_menu] + + elif command == "/delete": + response_text = "🗑️ **Удаление ребенка**\n\nВведите: `/delete ID`\n\nГде ID - номер ребенка из списка (/list)" + attachments = [children_menu] + + elif command == "/notify": + notifs = await db.get_unread_notifications(user_id) + if not notifs: + response_text = "📭 Новых уведомлений нет." + else: + response_text = "🔔 **Уведомления:**\n\n" + for n in notifs: + response_text += f"• {n['message']}\n" + await db.mark_notification_as_read(n['id']) + response_text += "\n✅ Уведомления отмечены как прочитанные." + attachments = [main_menu_keyboard] + + elif command == "/help": + response_text = ( + "📖 **Доступные команды:**\n\n" + "/start - Главное меню\n" + "/add Имя|Школа|Карта - Добавить ребёнка\n" + "/list - Список детей\n" + "/delete ID - Удалить ребёнка\n" + "/schools - Список школ\n" + "/search - Поиск школ\n" + "/subscribe - Статус подписки\n" + "/pay - Оплатить подписку (50 ₽/месяц)\n" + "/history - История платежей\n" + "/notify - Уведомления\n" + "/help - Помощь\n\n" + "💡 У вас есть 7 дней бесплатного триал-периода!" + ) + attachments = [main_menu_keyboard] + + else: + if not is_new_user: + response_text = "❓ Неизвестная команда.\n\nИспользуйте кнопки меню или введите /help для списка команд." + attachments = [main_menu_keyboard] + else: + response_text = ( + "👋 **Добро пожаловать!**\n\n" + "Я бот для родителей, который помогает отслеживать посещаемость детей в школе.\n\n" + "Нажмите кнопку **🚀 Старт**, чтобы начать пользоваться ботом 👇" + ) + attachments = [start_button_keyboard] + + self.bot.send_message(chat_id, response_text, is_user=False, parse_mode='markdown', attachments=attachments) + return True + + except Exception as e: + logger.error(f"❌ Ошибка обработки: {e}") + try: + self.bot.send_message(chat_id, f"❌ Произошла ошибка: {e}", is_user=False) + except: + pass + return False + + async def run(self): + logger.info("🤖 Бот запущен") + while self.running: + try: + updates = self.bot.get_updates( + offset=self.bot.last_update_id + 1 if self.bot.last_update_id else None, + timeout=30 + ) + if updates: + for update in updates: + update_id = update.get("update_id") + if update_id and isinstance(update_id, int) and update_id > self.bot.last_update_id: + self.bot.last_update_id = update_id + await self.handle_message(update) + self.reconnect_delay = 1 + await asyncio.sleep(0.5) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e: + logger.warning(f"⚠️ Ошибка соединения: {e}") + self.reconnect_delay = min(self.reconnect_delay * 2, 60) + await asyncio.sleep(self.reconnect_delay) + except KeyboardInterrupt: + self.running = False + break + except Exception as e: + logger.error(f"❌ Ошибка в цикле: {e}") + self.reconnect_delay = min(self.reconnect_delay * 2, 60) + await asyncio.sleep(self.reconnect_delay) + + +def run_webhook(): + port = int(os.getenv('WEBHOOK_PORT', 5000)) + webhook_app.run(host='0.0.0.0', port=port, debug=False, use_reloader=False) + + +async def main_async(): + global bot_instance + await db.connect() + + BOT_TOKEN = os.getenv('BOT_TOKEN') + if not BOT_TOKEN: + logger.error("❌ Токен не найден") + await db.disconnect() + return + + bot_instance = MAXBot(BOT_TOKEN, verify_ssl=False) + handler = MAXBotHandler(bot_instance) + + try: + logger.info("🔍 Проверка подключения...") + bot_info = bot_instance.get_me() + logger.info(f"✅ Бот авторизован: @{bot_info.get('username')}") + + webhook_port = int(os.getenv('WEBHOOK_PORT', 5000)) + logger.info(f"🌐 Вебхук: http://localhost:{webhook_port}/webhook/turnstile") + logger.info(f"💳 Вебхук YooKassa: http://localhost:{webhook_port}/webhook/yookassa") + + webhook_thread = threading.Thread(target=run_webhook, daemon=True) + webhook_thread.start() + + # ЗАПУСКАЕМ ФОНОВУЮ ЗАДАЧУ ПРОВЕРКИ ПОДПИСОК + await start_subscription_checker() + + await handler.run() + except Exception as e: + logger.error(f"❌ Критическая ошибка: {e}") + finally: + await db.disconnect() + + +def main(): + try: + asyncio.run(main_async()) + except KeyboardInterrupt: + logger.info("👋 Бот остановлен") + + +if __name__ == "__main__": + main() \ No newline at end of file