1095 lines
56 KiB
Python
1095 lines
56 KiB
Python
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() |