Files
2026-07-09 15:34:29 +00:00

1095 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()