diff --git a/bot-cat.gif b/bot-cat.gif new file mode 100644 index 0000000..a346be7 Binary files /dev/null and b/bot-cat.gif differ diff --git a/bot-cat.mp4 b/bot-cat.mp4 new file mode 100644 index 0000000..ae7f6e7 Binary files /dev/null and b/bot-cat.mp4 differ diff --git a/config/.env b/config/.env new file mode 100644 index 0000000..febde09 --- /dev/null +++ b/config/.env @@ -0,0 +1,13 @@ +# Файл .env для конфигурации +# Скопируй этот файл в .env.local и заполни своими значениями + +# Telegram Bot Token (получи у @BotFather) +TELEGRAM_BOT_TOKEN=8046228351:AAFhlgWjNo4vn9L_kp4OP5YzZlNa6Vp_lJE + +# Опциональный ID администратора для логирования +ADMIN_ID=1821732339 + +# Дополнительные настройки +DEBUG=False +DEBUG=False +LOG_LEVEL=INFO diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..1cdae8e --- /dev/null +++ b/config/.env.example @@ -0,0 +1,12 @@ +# Файл .env для конфигурации +# Скопируй этот файл в .env.local и заполни своими значениями + +# Telegram Bot Token (получи у @BotFather) +TELEGRAM_BOT_TOKEN=7123456789:ABCDEFGHIJKLMNOP1234567890ABCDEFGH + +# Опциональный ID администратора для логирования +ADMIN_ID=123456789 + +# Дополнительные настройки +DEBUG=False +LOG_LEVEL=INFO diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..6d36e45 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,19 @@ +""" +Конфигурация бота Fly-Fly +""" + +from .config import ( + TELEGRAM_BOT_TOKEN, + POINTS_SCHOOL_COMPLETE, + POINTS_REFERRAL_BONUS, + POINTS_BUTTERFLY_ACTIVATION, + DATABASE_FILE +) + +__all__ = [ + 'TELEGRAM_BOT_TOKEN', + 'POINTS_SCHOOL_COMPLETE', + 'POINTS_REFERRAL_BONUS', + 'POINTS_BUTTERFLY_ACTIVATION', + 'DATABASE_FILE' +] diff --git a/config/config.py b/config/config.py new file mode 100644 index 0000000..341862b --- /dev/null +++ b/config/config.py @@ -0,0 +1,78 @@ +# Конфигурация бота Fly-Fly +import os +from dotenv import load_dotenv + +# Загружаем .env из текущей папки (корень проекта) +# Ищем сначала .env в корневой папке проекта, затем в папке config +base_dir = os.path.dirname(os.path.dirname(__file__)) +env_file = os.path.join(base_dir, '.env') +if not os.path.exists(env_file): + env_file = os.path.join(os.path.dirname(__file__), '.env') +load_dotenv(env_file) + +# Telegram Bot Token +TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') + +# Опциональный прокси для Telegram (например: socks5h://user:pass@host:port) +TELEGRAM_PROXY_URL = os.getenv('TELEGRAM_PROXY_URL') + +# Таймауты запросов к Telegram API (секунды) +TELEGRAM_CONNECT_TIMEOUT = int(os.getenv('TELEGRAM_CONNECT_TIMEOUT', '30')) +TELEGRAM_READ_TIMEOUT = int(os.getenv('TELEGRAM_READ_TIMEOUT', '120')) + +# Пути к файлам данных +DATABASE_FILE = 'data/users.json' +STATS_FILE = 'data/stats.json' + +# Константы системы +POINTS_SCHOOL_COMPLETE = 10 +POINTS_REFERRAL_BONUS = 5 +POINTS_BUTTERFLY_ACTIVATION = 30 + +# Максимум приглашений в месяц для бонуса активации +MAX_ACTIVATIONS_PER_MONTH = 1 + +# Таймауты (в секундах) +SCHOOL_QUESTION_TIMEOUT = 120 + +# ID администратора для логирования (установлен явно) +ADMIN_ID = 7665285886 + +# ID группы для проверки подписки (ссылка: https://t.me/+2RjGyDueqqAyM2Ey) +# Чтобы получить ID, отправьте сообщение в @userinfobot или @RawDataBot +REQUIRED_GROUP_CHAT_ID = os.getenv('REQUIRED_GROUP_CHAT_ID', '-1002395700013') +REQUIRED_GROUP_LINK = "https://t.me/+2RjGyDueqqAyM2Ey" + +# Сообщения +MESSAGES = { + 'welcome_owner': ( + "🦋 **Привет, ловец бабочек!**\n\n" + "Добро пожаловать в мир **Fly-Fly**! 🌟\n\n" + "Ты стал владельцем Бабочкария и готов начать приключение? " + "Давай сначала пройдём школу бабочковода и узнаем, как ухаживать за этими чудесными созданиями!" + ), + 'welcome_referral': ( + "🦋 **Тебя пригласили в стаю!**\n\n" + "Ура! Твой друг пригласил тебя присоединиться к его команде в **Fly-Fly**! 🌟\n\n" + "Сначала пройдём школу бабочковода, а потом ты влишься в стаю!" + ), + 'school_start': ( + "📚 **Школа бабочковода**\n\n" + "Здесь ты узнаешь всё о бабочках и станешь настоящим ловцом! " + "Всего 5 увлекательных вопросов. Готов? 🦋" + ), + 'school_complete': ( + "🎉 **Отлично! Ты окончил школу!**\n\n" + "Ты получил **+{points} очков**! 📈\n\n" + "Теперь ты готов ловить бабочек и создавать свою стаю!" + ), + 'referral_bonus': ( + "✨ **Новый друг присоединился!**\n\n" + "{friend_name} вступил в твою стаю! " + "Ты получил **+{points} очков**! 🎊" + ), +} + +# ID или username требуемой группы (например: -1001234567890 или @my_group) +# Если не указан — проверка членства группы отключена +REQUIRED_GROUP_CHAT_ID = os.getenv('REQUIRED_GROUP_CHAT_ID') diff --git a/main.py b/main.py new file mode 100644 index 0000000..8a41b13 --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +""" +Главный скрипт запуска бота Fly-Fly +Запуск: python main.py +""" + +import sys +import os + +# Добавляем текущую папку в path +sys.path.insert(0, os.path.dirname(__file__)) + +from src.bot import bot + +if __name__ == "__main__": + print("🦋 Бот Fly-Fly запущен!") + print("🤖 @fly_fly_team_bot") + print("Ожидаю сообщений...\n") + + try: + bot.infinity_polling() + except Exception as e: + print(f"❌ Ошибка: {e}") diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..c196406 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,11 @@ +""" +Telegram Bot Fly-Fly - пакет исходного кода +""" + +__version__ = "1.0.0" +__author__ = "Fly-Fly Team" + +from .bot import bot +from .database import Database + +__all__ = ['bot', 'Database'] diff --git a/src/admin_tools.py b/src/admin_tools.py new file mode 100644 index 0000000..dd078be --- /dev/null +++ b/src/admin_tools.py @@ -0,0 +1,166 @@ +# Дополнительные команды администратора и отладки + +import json +from datetime import datetime +from .database import Database +from config import TELEGRAM_BOT_TOKEN + + +class AdminTools: + """Инструменты для администратора""" + + def __init__(self, admin_id: int): + self.admin_id = admin_id + self.db = Database() + + def get_stats(self) -> str: + """Получить общую статистику бота""" + data = self.db.load_data() + + total_users = len(data) + school_completed = sum(1 for u in data.values() if u.get('school_completed', False)) + butterfly_owners = sum(1 for u in data.values() if u.get('is_butterfly_owner', False)) + total_points = sum(u.get('total_points', 0) for u in data.values()) + + stats = ( + f"📊 **Статистика бота**\n\n" + f"👥 Всего пользователей: {total_users}\n" + f"📚 Прошли школу: {school_completed}\n" + f"🦋 Владельцев Бабочкария: {butterfly_owners}\n" + f"⭐ Всего очков в системе: {total_points}\n" + ) + + return stats + + def reset_user_data(self, user_id: int) -> bool: + """Сбросить данные пользователя (для тестирования)""" + try: + data = self.db.load_data() + user_id_str = str(user_id) + + if user_id_str in data: + del data[user_id_str] + self.db.save_data(data) + return True + except: + pass + + return False + + def backup_database(self, filename: str = None) -> bool: + """Создать резервную копию базы данных""" + try: + import shutil + from datetime import datetime + + if filename is None: + filename = f"backup_users_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + + backup_path = f"backups/{filename}" + + import os + os.makedirs("backups", exist_ok=True) + + shutil.copy(self.db.users_file, backup_path) + return True + except: + return False + + def export_leaderboard(self) -> str: + """Экспортировать лидерборд""" + ranking = self.db.get_users_ranking() + teams_ranking = self.db.get_teams_ranking() + + text = "🏆 **ЛИДЕРБОРД**\n\n" + text += "👥 **Топ пользователей:**\n" + + for idx, user in enumerate(ranking, 1): + medal = "🥇" if idx == 1 else "🥈" if idx == 2 else "🥉" if idx == 3 else f"{idx}️⃣" + text += f"{medal} {user['first_name']} — {user['total_points']} ⭐\n" + + text += "\n🏆 **Топ стай:**\n" + + for idx, team in enumerate(teams_ranking, 1): + medal = "🥇" if idx == 1 else "🥈" if idx == 2 else "🥉" if idx == 3 else f"{idx}️⃣" + text += f"{medal} {team['team_name']} — {team['total_points']} ⭐\n" + + return text + + +class DebugTools: + """Инструменты отладки""" + + def __init__(self): + self.db = Database() + + def validate_database(self) -> list: + """Проверить целостность базы данных""" + errors = [] + data = self.db.load_data() + + for user_id_str, user in data.items(): + # Проверяем обязательные поля + if 'telegram_id' not in user: + errors.append(f"User {user_id_str}: missing telegram_id") + + if 'first_name' not in user: + errors.append(f"User {user_id_str}: missing first_name") + + if 'total_points' not in user: + errors.append(f"User {user_id_str}: missing total_points") + + # Проверяем типы данных + if not isinstance(user.get('total_points', 0), int): + errors.append(f"User {user_id_str}: total_points is not int") + + if not isinstance(user.get('school_completed', False), bool): + errors.append(f"User {user_id_str}: school_completed is not bool") + + return errors + + def print_user_data(self, user_id: int): + """Распечатать данные пользователя для отладки""" + user = self.db.get_user(user_id) + if user: + print(json.dumps(user, indent=2, ensure_ascii=False)) + else: + print(f"User {user_id} not found") + + def clean_up_invalid_data(self): + """Очистить некорректные данные""" + data = self.db.load_data() + + for user_id_str, user in data.items(): + # Исправляем типы данных + if 'total_points' in user and not isinstance(user['total_points'], int): + try: + user['total_points'] = int(user['total_points']) + except: + user['total_points'] = 0 + + if 'school_completed' in user and not isinstance(user['school_completed'], bool): + user['school_completed'] = bool(user['school_completed']) + + self.db.save_data(data) + + +# Пример использования (для тестирования) +if __name__ == "__main__": + # Инструменты отладки + debug = DebugTools() + + print("🔍 Проверка целостности базы данных...") + errors = debug.validate_database() + + if errors: + print(f"❌ Найдено {len(errors)} ошибок:") + for error in errors: + print(f" - {error}") + else: + print("✅ База данных в порядке!") + + # Получить общую статистику + print("\n📊 Статистика бота:") + db = Database() + data = db.load_data() + print(f"Всего пользователей: {len(data)}") diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..202baee --- /dev/null +++ b/src/bot.py @@ -0,0 +1,1142 @@ +# Telegram Bot Fly-Fly для детей 9-12 лет +# Игра "Ловец" - сбор бабочек и рейтинги + +import telebot +from telebot import apihelper +from requests.exceptions import RequestException +from telebot import types +from datetime import datetime +import sys +import os + +# Добавляем родительскую папку в path для импортов +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +# Ensure stdout/stderr use UTF-8 on Windows consoles to avoid UnicodeEncodeError +try: + # Python 3.7+: reconfigure allows changing encoding for interactive consoles + sys.stdout.reconfigure(encoding='utf-8') + sys.stderr.reconfigure(encoding='utf-8') +except Exception: + # ignore if reconfigure not available or fails + pass + +from config.config import ( + TELEGRAM_BOT_TOKEN, + TELEGRAM_PROXY_URL, + TELEGRAM_CONNECT_TIMEOUT, + TELEGRAM_READ_TIMEOUT, + POINTS_SCHOOL_COMPLETE, + POINTS_REFERRAL_BONUS, + ADMIN_ID, + REQUIRED_GROUP_CHAT_ID, + REQUIRED_GROUP_LINK, +) +try: + from .database import Database +except Exception: + # allow running file directly (not as package) + from database import Database + +try: + from .school import SCHOOL_CURRICULUM, validate_answer, get_question +except Exception: + from school import SCHOOL_CURRICULUM, validate_answer, get_question + +# ============= ИНИЦИАЛИЗАЦИЯ ============= +# Настройки сети/прокси для Telegram API (полезно на серверах с блокировкой) +if TELEGRAM_PROXY_URL: + apihelper.proxy = {'https': TELEGRAM_PROXY_URL} +apihelper.REQUESTS_TIMEOUT = (TELEGRAM_CONNECT_TIMEOUT, TELEGRAM_READ_TIMEOUT) + +bot = telebot.TeleBot(TELEGRAM_BOT_TOKEN) + +# Безопасные обёртки, чтобы сетевые ошибки не останавливали polling +_orig_send_message = bot.send_message +_orig_send_video = bot.send_video + + +def _safe_call(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except RequestException as e: + print(f"telegram request error: {e}") + return None + except Exception as e: + print(f"telegram send error: {e}") + return None + + +def safe_send_message(*args, **kwargs): + return _safe_call(_orig_send_message, *args, **kwargs) + + +def safe_send_video(*args, **kwargs): + return _safe_call(_orig_send_video, *args, **kwargs) + + +bot.send_message = safe_send_message +bot.send_video = safe_send_video +db = Database() +user_states = {} # {user_id: {'state': 'school', 'question': 1}} + + +def attempt_award_referral(new_user_id: int): + """Проверить и начислить реферальный бонус пригласившему. + Условия: + - Новый пользователь имеет поле `referred_by` (telegram_id пригласителя) + - Пригласитель существует + - Приглашённый ещё не учтён в `invited_friends` пригласителя + - Если в конфиге указан REQUIRED_GROUP_CHAT_ID — приглашённый является членом этой группы + """ + user = db.get_user(new_user_id) + if not user: + return + + referred_by = user.get('referred_by') + if not referred_by: + return + + inviter = db.get_user(referred_by) + if not inviter: + return + + invited = inviter.get('invited_friends', []) or [] + if new_user_id in invited: + return + + # Проверяем членство в группе, если задана + if REQUIRED_GROUP_CHAT_ID: + chat_id = REQUIRED_GROUP_CHAT_ID + # попробуем привести к int если это строка из цифр + try: + if isinstance(chat_id, str) and chat_id.lstrip('-').isdigit(): + chat_id = int(chat_id) + except Exception: + pass + + try: + member = bot.get_chat_member(chat_id, new_user_id) + if member.status not in ['member', 'creator', 'administrator']: + return + except Exception: + # Не можем проверить — не начисляем + return + + # Все условия выполнены — начисляем и помечаем + db.add_points(referred_by, POINTS_REFERRAL_BONUS) + invited.append(new_user_id) + db.update_user(referred_by, invited_friends=invited) + + # Уведомляем пригласителя + try: + bot.send_message(referred_by, f"✨ {user.get('first_name', 'Друг')} присоединился! +{POINTS_REFERRAL_BONUS} очков.") + except Exception: + pass + + +# ============= КЛАВИАТУРЫ ============= + +def get_main_keyboard(user: dict): + """Главное меню в зависимости от статуса пользователя""" + keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True, row_width=2) + team_id = user.get('team_id') + telegram_id = user['telegram_id'] + + added_photo = False + # Школа теперь необязательна — показываем кнопку, если не пройдено, но не блокируем меню + if not user.get('school_completed'): + keyboard.add(types.KeyboardButton("📚 Школа бабочковода флай-флай")) + + if team_id: + # В стае + keyboard.add( + types.KeyboardButton("🦋 Поймать друга"), + types.KeyboardButton("📊 Мои очки") + ) + keyboard.add( + types.KeyboardButton("🏆 Рейтинг"), + types.KeyboardButton("📌 Стая") + ) + if team_id == telegram_id: + # Капитан + keyboard.add(types.KeyboardButton("⚙️ Управлять стаей")) + added_photo = True + else: + # Обычный участник + added_photo = True + else: + # Не в стае + keyboard.add( + types.KeyboardButton("🦋 Поймать друга"), + types.KeyboardButton("📊 Мои очки") + ) + + keyboard.add(types.KeyboardButton("🛒 Купить Бабочкарий")) + keyboard.add(types.KeyboardButton("❓ Справка")) + return keyboard + + +@bot.message_handler(func=lambda msg: msg.text == "🦋 Поймать друга") +def handle_catch_friend(message: types.Message): + """Отправляет GIF при нажатии кнопки 'Поймать друга'""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + # Путь к GIF в корне проекта + gif_path = os.path.join(os.path.dirname(__file__), '..', 'bot-cat.mp4') + + if not os.path.exists(gif_path): + bot.send_message(chat_id, "❌ GIF не найден на сервере.") + return + + try: + # Отправляем первое сообщение с инструкцией + telegram_id = message.from_user.id + referral_link = f"https://t.me/fly_fly_team_bot?start={telegram_id}" + + invite_text = ( + "🦋 Поймай друга в стаю бабочек Флай-Флай\n" + "\n" + "✨ и получи +5 баллов в рейтинге бабочководов Флай-Флай!\n" + "\n" + "📩 Для этого просто перешли следующее сообщение своим друзьям 👇\n\n" + f"{referral_link}" + ) + bot.send_message(chat_id, invite_text) + + # Отправляем второе сообщение с GIF и текстом приглашения + replay_text = ( + "Привет! Приглашаю тебя в стаю бабочек 'Флай-Флай'\n" + "Для этого перейди по ссылке: " + f"{referral_link}" + ) + + # Отправляем GIF с увеличенным таймаутом + gif_file = open(gif_path, 'rb') + bot.send_animation(chat_id, gif_file, caption=replay_text, timeout=180) + gif_file.close() + + except Exception as e: + print(f"send_animation error: {e}") + bot.send_message(chat_id, f"❌ Не удалось отправить GIF. Попробуй позже. Ошибка: {e}") + + +def get_school_keyboard(question_num: int): + """Клавиатура для школы с вариантами ответов""" + keyboard = types.InlineKeyboardMarkup() + question = get_question(question_num) + + for idx, option in enumerate(question.options): + keyboard.add(types.InlineKeyboardButton( + option, + callback_data=f"answer_{question_num}_{idx}" + )) + + return keyboard + + +def get_team_menu_keyboard(): + """Меню информации о стае""" + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("👥 Состав", callback_data="team_members")) + keyboard.add(types.InlineKeyboardButton("📊 Статистика", callback_data="team_stats")) + keyboard.add(types.InlineKeyboardButton("🔗 Ссылка приглашения", callback_data="team_link")) + keyboard.add(types.InlineKeyboardButton("⬅️ Назад", callback_data="back_to_main")) + return keyboard + + +# ============= СТАРТОВАЯ РЕГИСТРАЦИЯ ============= + +@bot.message_handler(commands=['start']) +def handle_start(message: types.Message): + """Обработка команды /start с параметрами. + Работает только в личных сообщениях. + """ + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Игнорируем сообщения из групп + if not is_private_chat(chat_id): + return + + first_name = message.from_user.first_name or "Ловец" + + # Парсим параметры: /start 0 (владелец) или /start (по реферальной ссылке) + args = message.text.split() + referred_by = None + is_owner = False + + if len(args) > 1: + param = args[1] + if param == "0": + is_owner = True + else: + try: + referred_by = int(param) + except ValueError: + pass + + # Проверяем, есть ли пользователь + user = db.get_user(telegram_id) + + if user: + # Пользователь уже зарегистрирован + keyboard = get_main_keyboard(user) + bot.send_message( + chat_id, + f"👋 С возвращением, {user.get('first_name', 'Ловец')}!\n\n" + f"⭐ Твои очки: {user.get('total_points', 0)}", + reply_markup=keyboard + ) + else: + # Новый пользователь + db.create_user(telegram_id, first_name, is_butterfly_owner=is_owner, referred_by=referred_by) + # Попробуем начислить реферальный бонус приглашавшему (если выполнены условия) + try: + attempt_award_referral(telegram_id) + except Exception: + pass + + # Отправляем единое приветственное сообщение для первого запуска + welcome = ( + "Привет! Добро пожаловать в стаю бабочек Флай-Флай 🦋\n" + "Рады видеть тебя среди участников нашего конкурса ✨\n" + "Чтобы начать участие, выполни первое условие:\n\n" + "1️⃣ Подпишись на нашу группу\n" + "👉 https://t.me/+2RjGyDueqqAyM2Ey\n\n" + "2️⃣ После подписки отправь в ответ слово «Готово»\n\n" + "🎁 И выиграй классные призы:\n\n" + "🥇 Умную колонку Алису + настоящий «Бабочкарий Флай-Флай»\n" + "🥈 Фитнес-браслет Xiaomi Smart Band 10 + настоящий «Бабочкарий Флай-Флай»\n" + "🥉 Наушники JBL Tune 520 + настоящий «Бабочкарий Флай-Флай»\n\n" + "Как только получим «Готово» и увидим подписку — подтвердим твоё участие 🏆\n\n" + "Летим к победе вместе! 🦋✨" + ) + + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(chat_id, welcome, reply_markup=keyboard) + + +# ============= ШКОЛА БАБОЧКОВОДА ============= + +@bot.message_handler(func=lambda msg: msg.text == "📚 Школа бабочковода флай-флай") +def start_school(message: types.Message): + """Начало школы""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + user = db.get_user(telegram_id) + if not user: + bot.send_message(chat_id, "❌ Сначала зарегистрируйся с /start") + return + + if user.get('school_completed'): + bot.send_message(chat_id, "✅ Ты уже прошёл Школу! 🎓") + return + + user_states[telegram_id] = {'state': 'school', 'question': 1, 'correct': 0} + question = get_question(1) + + learning_text = f"📖 Урок 1: {question.title}\n\n{question.learning_text}" + bot.send_message(chat_id, learning_text) + + question_text = f"\n\n❓ {question.question_text}" + bot.send_message( + chat_id, + question_text, + reply_markup=get_school_keyboard(1) + ) + + +@bot.callback_query_handler(func=lambda call: call.data.startswith('answer_')) +def handle_school_answer(call: types.CallbackQuery): + """Обработка ответа на вопрос""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + try: + _, question_num, answer_idx = call.data.split('_') + question_num = int(question_num) + answer_idx = int(answer_idx) + + question = get_question(question_num) + is_correct = (answer_idx == question.correct_answer) + + state = user_states.get(telegram_id, {}) + if is_correct: + state['correct'] = state.get('correct', 0) + 1 + + # Показываем объяснение + if is_correct: + feedback = f"✅ Правильно!\n\n{question.explanation}" + else: + feedback = f"❌ Неправильно!\n\n{question.explanation}" + + bot.send_message(call.message.chat.id, feedback) + + # Переходим к следующему вопросу + if question_num < 5: + next_question = get_question(question_num + 1) + state['question'] = question_num + 1 + user_states[telegram_id] = state + + learning = f"📖 Урок {question_num + 1}: {next_question.title}\n\n{next_question.learning_text}" + bot.send_message(call.message.chat.id, learning) + + question_text = f"\n\n❓ {next_question.question_text}" + bot.send_message( + call.message.chat.id, + question_text, + reply_markup=get_school_keyboard(question_num + 1) + ) + else: + # Школа завершена! + db.mark_school_completed(telegram_id) + user = db.get_user(telegram_id) + db.add_points(telegram_id, POINTS_SCHOOL_COMPLETE) + # Реферальный бонус теперь начисляется при условии присоединения к группе при старте, + # поэтому снято автоматическое начисление здесь. + + completion = ( + f"🎓 **Поздравляем!** Ты прошёл Школу бабочковода!\n\n" + f"⭐ +{POINTS_SCHOOL_COMPLETE} очков за школу\n" + f"📊 Всего очков: {user.get('total_points', 0) + POINTS_SCHOOL_COMPLETE}" + ) + + keyboard = get_main_keyboard(db.get_user(telegram_id)) + bot.send_message(call.message.chat.id, completion, reply_markup=keyboard) + + user_states.pop(telegram_id, None) + + bot.answer_callback_query(call.id) + + except Exception as e: + bot.answer_callback_query(call.id, "❌ Ошибка!", show_alert=True) + + +# ============= СТАИ ============= + +# Возможность создания стаи удалена — соответствующие команды отключены + + +@bot.message_handler(func=lambda msg: msg.text == "📌 Стая") +def show_team_menu(message: types.Message): + """Меню информации о стае""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + user = db.get_user(telegram_id) + if not user or not user.get('team_id'): + bot.send_message(chat_id, "❌ Ты не в стае!") + return + + team_id = user['team_id'] + team = db.get_team(team_id) + + if not team: + bot.send_message(chat_id, "❌ Стая не найдена!") + return + + members = db.get_team_members(team_id) + total_points = sum(m.get('total_points', 0) for m in members) + position = db.get_team_ranking_position(team_id) + position_text = f"#{position}" if position else "—" + + team_info = ( + f"🦋 **Стая '{team.get('team_name', 'Без названия')}'**\n\n" + f"👨‍💼 Капитан: {team.get('captain_name', '?')}\n" + f"👥 Участников: {team.get('members_count', 0)}\n" + f"⭐ Очков: {team.get('total_points', 0)}\n" + f"🏅 Место в рейтинге: {position_text}\n\n" + f"🤖 Бот: @fly_fly_team_bot" + ) + + keyboard = get_team_menu_keyboard() + # Кнопка выхода из стаи (только для участников) + keyboard.add(types.InlineKeyboardButton("🚪 Выйти из стаи", callback_data="leave_team")) + + bot.send_message(chat_id, team_info, reply_markup=keyboard) + + +@bot.callback_query_handler(func=lambda call: call.data.startswith('team_')) +def handle_team_menu(call: types.CallbackQuery): + """Обработка меню стаи""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + user = db.get_user(telegram_id) + + if not user or not user.get('team_id'): + bot.answer_callback_query(call.id, "❌ Ты не в стае!", show_alert=True) + return + + if call.data == "team_members": + team_id = user['team_id'] + members = db.get_team_members(team_id) + + members_list = "\n".join([ + f"👤 {m.get('first_name', '?')} — {m.get('total_points', 0)} ⭐" + for m in members + ]) + + text = f"👥 **Участники стаи:**\n\n{members_list}" + bot.send_message(call.message.chat.id, text, reply_markup=get_team_menu_keyboard()) + + elif call.data == "team_stats": + team_id = user['team_id'] + team = db.get_team(team_id) + + if not team: + bot.answer_callback_query(call.id, "❌ Ошибка стаи!", show_alert=True) + return + + team_pos = db.get_team_ranking_position(team_id) + team_pos_text = f"#{team_pos}" if team_pos else "—" + + text = ( + f"📊 **Статистика стаи '{team.get('team_name')}'**\n\n" + f"👥 Участников: {team.get('members_count', 0)}\n" + f"⭐ Очков: {team.get('total_points', 0)}\n" + f"🏅 Место: {team_pos_text}\n\n" + f"🤖 Бот: @fly_fly_team_bot" + ) + bot.send_message(call.message.chat.id, text, reply_markup=get_team_menu_keyboard()) + + elif call.data == "team_link": + team_id = user['team_id'] + referral_link = f"https://t.me/fly_fly_team_bot?start={team_id}" + text = f"🔗 Ссылка приглашения:\n\n{referral_link}\n\n🤖 Бот: @fly_fly_team_bot" + bot.send_message(call.message.chat.id, text, reply_markup=get_team_menu_keyboard()) + + elif call.data == "back_to_main": + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(call.message.chat.id, "⬅️ Вернулись в меню", reply_markup=keyboard) + + bot.answer_callback_query(call.id) + + +@bot.callback_query_handler(func=lambda call: call.data in ['leave_team', 'leave_confirm', 'leave_cancel']) +def handle_leave_team(call: types.CallbackQuery): + """Обработка выхода из стаи""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + user = db.get_user(telegram_id) + + if not user or not user.get('team_id'): + bot.answer_callback_query(call.id, "❌ Ты не в стае!", show_alert=True) + return + + team_id = user['team_id'] + team = db.get_team(team_id) + team_name = team.get('team_name') if team else (db.get_user(team_id) or {}).get('team_name') or 'Без названия' + + if call.data == 'leave_team': + # Капитан не может просто покинуть стаю + if user.get('team_id') == telegram_id: + bot.answer_callback_query(call.id, "❌ Капитан не может покинуть стаю. Используй 'Разойти стаю'", show_alert=True) + return + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("✅ Да, хочу выйти", callback_data='leave_confirm')) + keyboard.add(types.InlineKeyboardButton("❌ Отмена", callback_data='leave_cancel')) + + bot.send_message(call.message.chat.id, f"Ты хочешь покинуть стаю {team_name}", reply_markup=keyboard) + + elif call.data == 'leave_cancel': + keyboard = get_team_menu_keyboard() + bot.send_message(call.message.chat.id, "⬅️ Вернулись в меню стаи", reply_markup=keyboard) + + elif call.data == 'leave_confirm': + # Выполняем выход + success = db.remove_member_from_team(team_id, telegram_id) + if success: + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(call.message.chat.id, f"✅ Ты покинул стаю {team_name}", reply_markup=keyboard) + else: + bot.send_message(call.message.chat.id, "❌ Не удалось выйти из стаи. Попробуй позже.") + + bot.answer_callback_query(call.id) + + +@bot.callback_query_handler(func=lambda call: call.data.startswith('award_')) +def handle_award_callbacks(call: types.CallbackQuery): + """Обработка кнопок начисления очков модератором""" + admin_id = ADMIN_ID + caller_id = call.from_user.id + chat_id = call.message.chat.id + telegram_id = call.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + if caller_id != admin_id: + bot.answer_callback_query(call.id, "❌ Нет доступа", show_alert=True) + return + + # parse callback + try: + parts = call.data.split('_') # e.g. award_15_12345 + action = parts[1] + target_id = int(parts[2]) + except Exception: + bot.answer_callback_query(call.id, "❌ Неправильные данные", show_alert=True) + return + + if action == 'reject': + # Просто уведомляем и убираем кнопки + bot.edit_message_caption(chat_id=call.message.chat.id, message_id=call.message.message_id, caption=(call.message.caption + "\n\n❌ Отклонено")) + bot.send_message(target_id, "❌ Ваше фото не прошло модерацию.") + bot.answer_callback_query(call.id, "Отклонено") + return + + points = 15 if action == '15' else 5 if action == '5' else 0 + if points <= 0: + bot.answer_callback_query(call.id, "❌ Неправильное количество очков", show_alert=True) + return + + # Начисляем очки + db.add_points(target_id, points) + + # Обновляем подписанный пост у модератора + try: + bot.edit_message_caption(chat_id=call.message.chat.id, message_id=call.message.message_id, caption=(call.message.caption + f"\n\n✅ Начислено {points} очков пользователю {target_id}")) + except: + pass + + # Уведомления + bot.send_message(target_id, f"✨ Модератор начислил тебе +{points} очков за фото!") + bot.send_message(call.message.chat.id, f"✅ Пользователю {target_id} начислено +{points} очков.") + bot.answer_callback_query(call.id) + + +@bot.message_handler(func=lambda msg: msg.text == "⚙️ Управлять стаей") +def manage_team(message: types.Message): + """Управление стаей (для капитана)""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + user = db.get_user(telegram_id) + if not user or user.get('team_id') != telegram_id: + bot.send_message(chat_id, "❌ Ты не капитан стаи!") + return + + team = db.get_team(user['team_id']) + text = f"⚙️ **Управление стаей '{team.get('team_name')}'**\n\nВыбери действие:" + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("✏️ Изменить название", callback_data="manage_rename")) + keyboard.add(types.InlineKeyboardButton("👥 Исключить участника", callback_data="manage_remove")) + keyboard.add(types.InlineKeyboardButton("📊 Рейтинг участников", callback_data="manage_rating")) + keyboard.add(types.InlineKeyboardButton("🚫 Разойти стаю", callback_data="manage_disband")) + keyboard.add(types.InlineKeyboardButton("⬅️ Назад", callback_data="back_menu")) + + bot.send_message(chat_id, text, reply_markup=keyboard) + + +@bot.callback_query_handler(func=lambda call: call.data.startswith('manage_') or call.data.startswith('remove_') or call.data == 'disband_confirm' or call.data == 'back_menu') +def handle_manage_team(call: types.CallbackQuery): + """Обработка управления стаей""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + user = db.get_user(telegram_id) + + # Для back_menu не нужна проверка капитана + if call.data == "back_menu": + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(call.message.chat.id, "⬅️ Вернулись в меню", reply_markup=keyboard) + bot.answer_callback_query(call.id) + return + + if not user or user.get('team_id') != telegram_id: + bot.answer_callback_query(call.id, "❌ Ты не капитан!", show_alert=True) + return + + team_id = user['team_id'] + + if call.data == "manage_rename": + msg = bot.send_message(call.message.chat.id, "📝 Введи новое название стаи (максимум 30 символов):") + bot.register_next_step_handler(msg, process_rename_team) + + elif call.data == "manage_remove": + team = db.get_team(team_id) + members = db.get_team_members(team_id) + + if len(members) <= 1: + bot.answer_callback_query(call.id, "❌ В стае только ты!", show_alert=True) + return + + keyboard = types.InlineKeyboardMarkup() + for member in members: + if member['telegram_id'] != telegram_id: + keyboard.add(types.InlineKeyboardButton( + f"{member.get('first_name', '?')} ({member.get('total_points', 0)} ⭐)", + callback_data=f"remove_{member['telegram_id']}" + )) + keyboard.add(types.InlineKeyboardButton("⬅️ Назад", callback_data="back_menu")) + + bot.send_message(call.message.chat.id, "👥 Выбери участника для исключения:", reply_markup=keyboard) + + elif call.data == "manage_rating": + team = db.get_team(team_id) + members = db.get_team_members(team_id) + members_sorted = sorted(members, key=lambda x: x.get('total_points', 0), reverse=True) + + rating_text = f"📊 **Рейтинг участников стаи '{team.get('team_name')}'**\n\n" + for idx, member in enumerate(members_sorted, 1): + is_captain = "👑" if member['telegram_id'] == team_id else "" + rating_text += f"{idx}. {member.get('first_name', '?')} — {member.get('total_points', 0)} ⭐ {is_captain}\n" + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("⬅️ Назад", callback_data="back_menu")) + + bot.send_message(call.message.chat.id, rating_text, reply_markup=keyboard) + + elif call.data == "manage_disband": + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("✅ Да, разойти", callback_data="disband_confirm")) + keyboard.add(types.InlineKeyboardButton("❌ Отмена", callback_data="back_menu")) + + bot.send_message( + call.message.chat.id, + "⚠️ Ты уверен? Все участники выйдут из стаи!", + reply_markup=keyboard + ) + + elif call.data == "disband_confirm": + db.disband_team(team_id) + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + + bot.send_message( + call.message.chat.id, + "🚫 Стая разойдена! Все участники выведены.", + reply_markup=keyboard + ) + + elif call.data.startswith("remove_"): + try: + member_id = int(call.data.split("_")[1]) + db.remove_member_from_team(team_id, member_id) + + member = db.get_user(member_id) + bot.send_message( + call.message.chat.id, + f"✅ {member.get('first_name', '?')} исключён из стаи!" + ) + + # Показываем меню управления заново + team = db.get_team(team_id) + text = f"⚙️ **Управление стаей '{team.get('team_name')}'**\n\nВыбери действие:" + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("✏️ Изменить название", callback_data="manage_rename")) + keyboard.add(types.InlineKeyboardButton("👥 Исключить участника", callback_data="manage_remove")) + keyboard.add(types.InlineKeyboardButton("📊 Рейтинг участников", callback_data="manage_rating")) + keyboard.add(types.InlineKeyboardButton("🚫 Разойти стаю", callback_data="manage_disband")) + keyboard.add(types.InlineKeyboardButton("⬅️ В главное меню", callback_data="back_to_main_menu")) + + bot.send_message(call.message.chat.id, text, reply_markup=keyboard) + except (ValueError, IndexError) as e: + bot.answer_callback_query(call.id, "❌ Ошибка при исключении!", show_alert=True) + + bot.answer_callback_query(call.id) + + +@bot.callback_query_handler(func=lambda call: call.data == "back_menu") +def handle_back_menu(call: types.CallbackQuery): + """Обработка кнопки 'Назад' в меню управления стаей""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + user = db.get_user(telegram_id) + + if user and user.get('team_id') == telegram_id: + # Капитан - показываем управление стаей + team = db.get_team(user['team_id']) + text = f"⚙️ **Управление стаей '{team.get('team_name')}'**\n\nВыбери действие:" + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("✏️ Изменить название", callback_data="manage_rename")) + keyboard.add(types.InlineKeyboardButton("👥 Исключить участника", callback_data="manage_remove")) + keyboard.add(types.InlineKeyboardButton("📊 Рейтинг участников", callback_data="manage_rating")) + keyboard.add(types.InlineKeyboardButton("🚫 Разойти стаю", callback_data="manage_disband")) + keyboard.add(types.InlineKeyboardButton("⬅️ В главное меню", callback_data="back_to_main_menu")) + + bot.send_message(call.message.chat.id, text, reply_markup=keyboard) + else: + # Обычный пользователь - вернись в главное меню + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(call.message.chat.id, "⬅️ Вернулись в меню", reply_markup=keyboard) + + bot.answer_callback_query(call.id) + + +@bot.callback_query_handler(func=lambda call: call.data == "back_to_main_menu") +def handle_back_to_main(call: types.CallbackQuery): + """Обработка кнопки 'В главное меню'""" + telegram_id = call.from_user.id + chat_id = call.message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + bot.answer_callback_query(call.id, "❌ Нужно подписаться на группу!", show_alert=True) + return + + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(call.message.chat.id, "⬅️ Вернулись в меню", reply_markup=keyboard) + bot.answer_callback_query(call.id) + + +def process_rename_team(message: types.Message): + """Обработка переименования стаи""" + chat_id = message.chat.id + telegram_id = message.from_user.id + new_name = message.text.strip() + + if len(new_name) > 30: + msg = bot.send_message(chat_id, "❌ Слишком длинное имя! Попробуй снова:") + bot.register_next_step_handler(msg, process_rename_team) + return + + if len(new_name) < 2: + msg = bot.send_message(chat_id, "❌ Имя слишком короткое! Попробуй снова:") + bot.register_next_step_handler(msg, process_rename_team) + return + + db.update_team_name(telegram_id, new_name) + + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + + bot.send_message( + chat_id, + f"✅ Стая переименована на '{new_name}'!", + reply_markup=keyboard + ) + + + +# ============= ОЧКИ И РЕЙТИНГ ============= + +@bot.message_handler(func=lambda msg: msg.text == "📊 Мои очки") +def show_my_stats(message: types.Message): + """Показать мои очки""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + user = db.get_user(telegram_id) + if not user: + bot.send_message(chat_id, "❌ Сначала зарегистрируйся!") + return + position = db.get_user_ranking_position(telegram_id) + + text = ( + f"📊 **Твои очки**\n\n" + f"👤 Имя: {user.get('first_name')}\n" + f"⭐ Твои очки: {user.get('total_points', 0)}\n" + f"🏅 Место в рейтинге: #{position}\n\n" + f"🤖 Бот: @fly_fly_team_bot" + ) + + keyboard = get_main_keyboard(user) + # Также показываем Топ-10 ловцов под личной статистикой + top_users = db.get_users_ranking() + ranking_text = "\n🏆 Топ-10 ловцов:\n" + for idx, u in enumerate(top_users[:10], 1): + ranking_text += f"{idx}. {u.get('first_name', '?')} — {u.get('total_points', 0)} ⭐\n" + + bot.send_message(chat_id, text + ranking_text, reply_markup=keyboard) + + +@bot.message_handler(func=lambda msg: msg.text == "🏆 Рейтинг") +def show_ranking(message: types.Message): + """Показать рейтинг""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + users = db.get_users_ranking() + ranking_text = "🏆 **Топ-10 ловцов**\n\n" + + for idx, user in enumerate(users[:10], 1): + ranking_text += f"{idx}. {user.get('first_name', '?')} — {user.get('total_points', 0)} ⭐\n" + + user = db.get_user(telegram_id) + position = db.get_user_ranking_position(telegram_id) + + ranking_text += f"\n👤 Твоё место: #{position}\n\n🤖 Бот: @fly_fly_team_bot" + + keyboard = get_main_keyboard(user) + bot.send_message(chat_id, ranking_text, reply_markup=keyboard) + + +@bot.message_handler(content_types=['photo']) +def handle_photo(message: types.Message): + """Обработка присланного фото — пересылка модератору с кнопками для начисления очков""" + telegram_id = message.from_user.id + chat_id = message.chat.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + state = user_states.get(telegram_id, {}) + + # Принимаем фото только если ждём его от пользователя + if not state.get('awaiting_photo'): + return + + # Сохраняем file_id и очищаем состояние + file_id = message.photo[-1].file_id + user_states.pop(telegram_id, None) + + # Кому отправлять — берем ADMIN_ID из конфига или дефолт + admin_id = ADMIN_ID + + caption = f"📸 Фото от {message.from_user.first_name} (ID {telegram_id})\n\nНажми кнопку, чтобы начислить очки." + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("Дать 15 баллов", callback_data=f"award_15_{telegram_id}")) + keyboard.add(types.InlineKeyboardButton("Дать 5 баллов", callback_data=f"award_5_{telegram_id}")) + keyboard.add(types.InlineKeyboardButton("Отклонить", callback_data=f"award_reject_{telegram_id}")) + + try: + bot.send_photo(admin_id, file_id, caption=caption, reply_markup=keyboard) + bot.send_message(telegram_id, "✅ Фото отправлено на проверку модератору.") + except Exception as e: + bot.send_message(telegram_id, "❌ Не удалось отправить фото на проверку. Попробуй позже.") + + +@bot.message_handler(func=lambda msg: msg.text == "🛒 Купить Бабочкарий") +def buy_butterfly_farm(message: types.Message): + """Показать ссылку на покупку Бабочкария""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + url = "https://fly-fly.ru/butterctary?utm_source=tg_team_bot" + + keyboard = types.InlineKeyboardMarkup() + keyboard.add(types.InlineKeyboardButton("Купить Бабочкарий", url=url)) + + bot.send_message(chat_id, "Перейди по ссылке, чтобы приобрести Бабочкарий:", reply_markup=keyboard) + + +# ============= СПРАВКА ============= + +@bot.message_handler(func=lambda msg: msg.text == "❓ Справка") +def show_help(message: types.Message): + """Справка по игре""" + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Проверяем подписку + if not is_user_subscribed(telegram_id, chat_id): + return + + help_text = ( + "❓ **Справка - Fly-Fly**\n\n" + "🦋 Ловец бабочек — ты!\n\n" + "📚 Школа: Пройди 5 уроков → +10 ⭐\n" + "🎪 Стая: Создай команду или присоединись\n" + "🤝 Друзья: Приглашай друзей → +5 ⭐ за каждого\n" + "🏆 Рейтинг: Соревнуйся с другими!\n\n" + "🤖 Бот: @fly_fly_team_bot" + ) + + user = db.get_user(telegram_id) + keyboard = get_main_keyboard(user) + bot.send_message(chat_id, help_text, reply_markup=keyboard) + + +# ============= ПРОВЕРКА ПОДПИСКИ НА ГРУППУ ============= + +def is_private_chat(chat_id: int) -> bool: + """Проверяет, что сообщение пришло из личного чата (не из группы)""" + return chat_id > 0 + + +def check_user_in_group(telegram_id: int) -> bool: + """Проверяет, состоит ли пользователь в требуемой группе""" + if not REQUIRED_GROUP_CHAT_ID: + return True # Если ID группы не настроен — пропускаем проверку + + try: + member = bot.get_chat_member(REQUIRED_GROUP_CHAT_ID, telegram_id) + return member.status in ['member', 'administrator', 'creator'] + except Exception: + # Если ошибка проверки — считаем что не подписан + return False + + +def send_subscription_required(chat_id: int): + """Отправляет сообщение о необходимости подписки""" + bot.send_message( + chat_id, + "🦋 **Доступ только для подписчиков!** 🦋\n\n" + "Чтобы использовать все функции бота, нужно быть подписанным на нашу группу:\n\n" + "👉 " + REQUIRED_GROUP_LINK + "\n\n" + "Подпишись и попробуй снова! ✨", + parse_mode='Markdown' + ) + + +def is_user_subscribed(telegram_id: int, chat_id: int) -> bool: + """Проверяет подписку и отправляет уведомление, если не подписан. + Работает только в личных сообщениях. + """ + # Игнорируем сообщения из групп + if not is_private_chat(chat_id): + return False + + if not check_user_in_group(telegram_id): + send_subscription_required(chat_id) + return False + return True + + +@bot.message_handler(func=lambda msg: msg.text.lower() in ['готово', 'готово!']) +def handle_gotovo(message: types.Message): + """Обработка сообщения «Готово» — проверка подписки на группу. + Работает только в личных сообщениях. + """ + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Игнорируем сообщения из групп + if not is_private_chat(chat_id): + return + + user = db.get_user(telegram_id) + if not user: + bot.send_message(chat_id, "❌ Сначала зарегистрируйся с /start") + return + + # Проверяем подписку + if check_user_in_group(telegram_id): + # Пользователь подписан — подтверждаем участие + keyboard = get_main_keyboard(user) + bot.send_message( + chat_id, + "✨ **Отлично! Мы видели твою подписку!** 🦋\n\n" + "Твоё участие в конкурсе подтверждено! 🏆\n\n" + "Теперь ты можешь:\n" + "• Ловить друзей и приглашать в стаю\n" + "• Выполнять задания и получать очки\n" + "• Соревноваться за классные призы!\n\n" + "Летим к победе вместе! 🦋✨", + reply_markup=keyboard, + parse_mode='Markdown' + ) + else: + # Пользователь не подписан + bot.send_message( + chat_id, + "🦋 **Кажется, мы тебя потеряли** 🦋\n\n" + "Мы получили сообщение «Готово», но пока не видим подписку на нашу группу 🤍\n\n" + "Чтобы участие засчиталось, нужно:\n" + "1️⃣ Подписаться на группу\n" + "👉 " + REQUIRED_GROUP_LINK + "\n" + "2️⃣ И после этого снова отправить слово «Готово»\n\n" + "Как только увидим подписку — сразу подтвердим участие ✨\n\n" + "Ждём тебя в стае Флай-Флай 🦋", + parse_mode='Markdown' + ) + + +# ============= ОБРАБОТЧИК НЕИЗВЕСТНЫХ КОМАНД ============= + +@bot.message_handler(func=lambda msg: True) +def handle_unknown(message: types.Message): + """Обработка неизвестных команд. + Работает только в личных сообщениях. + """ + chat_id = message.chat.id + telegram_id = message.from_user.id + + # Игнорируем сообщения из групп + if not is_private_chat(chat_id): + return + + user = db.get_user(telegram_id) + if user: + keyboard = get_main_keyboard(user) + bot.send_message( + chat_id, + "🤔 Не понимаю. Используй меню 👇", + reply_markup=keyboard + ) + + +# ============= ЗАПУСК БОТА ============= + +if __name__ == "__main__": + print("🦋 Бот Fly-Fly запущен!") + print("🤖 @fly_fly_team_bot") + print("Ожидаю сообщений...\n") + + try: + bot.infinity_polling() + except Exception as e: + print(f"❌ Ошибка: {e}") diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..6750833 --- /dev/null +++ b/src/database.py @@ -0,0 +1,311 @@ +# Модель данных и работа с JSON +import json +import os +from datetime import datetime +from typing import Dict, List, Optional +from dataclasses import dataclass, asdict, field +import hashlib + + +@dataclass +class User: + """Модель пользователя""" + telegram_id: int + first_name: str + username: Optional[str] = None + is_butterfly_owner: bool = False # Владелец Бабочкария + school_completed: bool = False + total_points: int = 0 + team_id: Optional[int] = None # ID капитана стаи + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + last_activation_date: Optional[str] = None # Дата последней активации Бабочкария + invited_friends: List[int] = field(default_factory=list) # IDs приглашённых друзей + referred_by: Optional[int] = None # ID того, кто пригласил + profile_updated: bool = False + + +@dataclass +class Team: + """Модель стаи бабочек""" + team_id: int # ID капитана + team_name: str + members: List[int] = field(default_factory=list) # IDs участников + created_at: str = field(default_factory=lambda: datetime.now().isoformat()) + total_points: int = 0 # Сумма очков всех участников + + def get_points(self, users_data: Dict) -> int: + """Вычислить сумму очков стаи""" + total = 0 + for member_id in self.members: + if member_id in users_data: + total += users_data[member_id].get('total_points', 0) + return total + + +@dataclass +class SchoolQuestion: + """Модель вопроса школы""" + question_number: int + title: str + learning_text: str + question_text: str + options: List[str] + correct_answer: int # Индекс правильного ответа + explanation: str + + +class Database: + """Класс для работы с базой данных""" + + def __init__(self, users_file: str = 'data/users.json'): + self.users_file = users_file + self.ensure_database_exists() + + def ensure_database_exists(self): + """Создать директорию и файлы, если их нет""" + os.makedirs('data', exist_ok=True) + + if not os.path.exists(self.users_file): + self.save_data({}) + + def load_data(self) -> Dict: + """Загрузить все данные пользователей""" + try: + with open(self.users_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Ошибка при загрузке данных: {e}") + return {} + + def save_data(self, data: Dict): + """Сохранить данные пользователей""" + try: + with open(self.users_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Ошибка при сохранении данных: {e}") + + def get_user(self, telegram_id: int) -> Optional[Dict]: + """Получить пользователя по ID""" + data = self.load_data() + user_id_str = str(telegram_id) + return data.get(user_id_str) + + def user_exists(self, telegram_id: int) -> bool: + """Проверить, существует ли пользователь""" + return self.get_user(telegram_id) is not None + + def create_user(self, telegram_id: int, first_name: str, + username: Optional[str] = None, + is_butterfly_owner: bool = False, + referred_by: Optional[int] = None) -> Dict: + """Создать нового пользователя""" + data = self.load_data() + user_id_str = str(telegram_id) + + if user_id_str in data: + return data[user_id_str] + + new_user = { + 'telegram_id': telegram_id, + 'first_name': first_name, + 'username': username, + 'is_butterfly_owner': is_butterfly_owner, + 'school_completed': False, + 'total_points': 0, + 'team_id': None, + 'team_name': None, + 'created_at': datetime.now().isoformat(), + 'last_activation_date': None, + 'invited_friends': [], + 'referred_by': referred_by, + 'profile_updated': False + } + + data[user_id_str] = new_user + self.save_data(data) + return new_user + + def update_user(self, telegram_id: int, **kwargs): + """Обновить данные пользователя""" + data = self.load_data() + user_id_str = str(telegram_id) + + if user_id_str in data: + data[user_id_str].update(kwargs) + self.save_data(data) + return data[user_id_str] + return None + + def add_points(self, telegram_id: int, points: int): + """Добавить очки пользователю""" + user = self.get_user(telegram_id) + if user: + current_points = user.get('total_points', 0) + self.update_user(telegram_id, total_points=current_points + points) + + def get_team_members(self, team_id: int) -> List[Dict]: + """Получить всех членов стаи""" + data = self.load_data() + members = [] + for user_id_str, user in data.items(): + if user.get('team_id') == team_id: + members.append(user) + return members + + def get_teams_ranking(self) -> List[Dict]: + """Получить рейтинг стай (топ-10)""" + data = self.load_data() + teams = {} + + # Собрать стаи + for user_id_str, user in data.items(): + if user.get('is_butterfly_owner'): + team_id = int(user_id_str) + members = self.get_team_members(team_id) + + team_points = sum(m.get('total_points', 0) for m in members) + team_name = f"Стая {user.get('first_name', 'Аноним')}" + + teams[team_id] = { + 'team_id': team_id, + 'team_name': team_name, + 'captain_name': user.get('first_name', 'Аноним'), + 'members_count': len(members), + 'total_points': team_points + } + + # Сортировать по очкам + sorted_teams = sorted(teams.values(), key=lambda x: x['total_points'], reverse=True) + return sorted_teams[:10] + + def get_users_ranking(self) -> List[Dict]: + """Получить рейтинг пользователей (топ-10)""" + data = self.load_data() + users = [] + + for user_id_str, user in data.items(): + users.append({ + 'telegram_id': int(user_id_str), + 'first_name': user.get('first_name', 'Аноним'), + 'total_points': user.get('total_points', 0), + 'team_id': user.get('team_id') + }) + + # Сортировать по очкам + sorted_users = sorted(users, key=lambda x: x['total_points'], reverse=True) + return sorted_users[:10] + + def get_user_ranking_position(self, telegram_id: int) -> int: + """Получить позицию пользователя в общем рейтинге""" + ranking = self.get_users_ranking() + for idx, user in enumerate(ranking, 1): + if user['telegram_id'] == telegram_id: + return idx + + # Если не в топ-10, считаем полный рейтинг + data = self.load_data() + all_users = [] + for user_id_str, user in data.items(): + all_users.append({ + 'telegram_id': int(user_id_str), + 'total_points': user.get('total_points', 0) + }) + + all_users = sorted(all_users, key=lambda x: x['total_points'], reverse=True) + for idx, user in enumerate(all_users, 1): + if user['telegram_id'] == telegram_id: + return idx + return len(all_users) + 1 + + def get_team_ranking_position(self, team_id: int) -> int: + """Получить позицию стаи в рейтинге""" + ranking = self.get_teams_ranking() + for idx, team in enumerate(ranking, 1): + if team['team_id'] == team_id: + return idx + return None + + def create_team(self, captain_id: int, team_name: str) -> Dict: + """Создать новую стаю""" + user = self.get_user(captain_id) + if not user: + return None + + # Обновляем пользователя - он становится капитаном + self.update_user(captain_id, team_id=captain_id, team_name=team_name) + + return { + 'team_id': captain_id, + 'team_name': team_name, + 'members': [captain_id], + 'created_at': datetime.now().isoformat() + } + + def get_team(self, team_id: int) -> Optional[Dict]: + """Получить информацию о стае""" + captain = self.get_user(team_id) + if not captain: + return None + + # Капитан должен иметь team_id == team_id (быть капитаном) + if captain.get('team_id') != team_id: + return None + + members = self.get_team_members(team_id) + total_points = sum(m.get('total_points', 0) for m in members) + + return { + 'team_id': team_id, + 'team_name': captain.get('team_name', f"Стая {captain.get('first_name', 'Аноним')}"), + 'captain_name': captain.get('first_name', 'Аноним'), + 'members': [m['telegram_id'] for m in members], + 'members_count': len(members), + 'total_points': total_points, + 'created_at': captain.get('created_at') + } + + def add_member_to_team(self, team_id: int, user_id: int) -> bool: + """Добавить пользователя в стаю""" + user = self.get_user(user_id) + if not user: + return False + + # Обновляем team_id пользователя + self.update_user(user_id, team_id=team_id) + return True + + def remove_member_from_team(self, team_id: int, user_id: int) -> bool: + """Удалить пользователя из стаи""" + user = self.get_user(user_id) + if not user or user.get('team_id') != team_id: + return False + + # Обновляем team_id пользователя на None и очищаем team_name + self.update_user(user_id, team_id=None, team_name=None) + return True + + def update_team_name(self, team_id: int, new_name: str) -> bool: + """Изменить название стаи""" + captain = self.get_user(team_id) + if not captain or captain.get('team_id') != team_id: + return False + + self.update_user(team_id, team_name=new_name) + return True + + def disband_team(self, team_id: int) -> bool: + """Разойти стаю - удалить всех участников из команды""" + members = self.get_team_members(team_id) + + for member in members: + self.update_user(member['telegram_id'], team_id=None, team_name=None) + + # Капитан тоже выходит + self.update_user(team_id, team_id=None, team_name=None) + return True + + def mark_school_completed(self, telegram_id: int) -> bool: + """Отметить школу как пройденную""" + self.update_user(telegram_id, school_completed=True) + return True diff --git a/src/school.py b/src/school.py new file mode 100644 index 0000000..3b57712 --- /dev/null +++ b/src/school.py @@ -0,0 +1,117 @@ +# Содержание школы бабочковода +from dataclasses import dataclass +from typing import List + + +@dataclass +class Question: + """Класс для вопроса школы""" + number: int + title: str + learning_text: str + question_text: str + options: List[str] + correct_answer: int # 0-3 + explanation: str + + +# Школа бабочковода - 5 вопросов +SCHOOL_CURRICULUM = [ + Question( + number=1, + title="Кто такие бабочки?", + learning_text=( + "🦋 **Интересный факт:**\n\n" + "Бабочки — это насекомые с четырьмя крыльями, покрытыми чешуйками. " + "Их крылья создают прекрасные узоры, которые помогают им общаться с другими бабочками. " + "Есть более 400 000 видов бабочек! Красиво, правда? ✨" + ), + question_text="Сколько крыльев у бабочки?", + options=["2 крыла", "4 крыла", "6 крыльев", "8 крыльев"], + correct_answer=1, + explanation="Правильно! У бабочек 4 крыла, расположенные попарно спереди и сзади. Они используют их, чтобы летать в поисках цветов и друзей." + ), + Question( + number=2, + title="Что едят бабочки?", + learning_text=( + "🌸 **Вкусная жизнь:**\n\n" + "Бабочки — большие любительницы сладкого! Они пьют нектар из цветов, " + "используя свой длинный хоботок (как соломинка). " + "Некоторые бабочки также едят спелые фрукты и грязь для получения минералов." + ), + question_text="Как бабочка пьет нектар из цветка?", + options=["Грызёт цветок", "Использует хоботок как трубочку", "Проглатывает весь цветок", "Не пьет вообще"], + correct_answer=1, + explanation="Умница! Бабочки имеют длинный хоботок (спираль), который они разворачивают и вставляют в цветок, как соломинку, чтобы пить нектар." + ), + Question( + number=3, + title="Жизненный цикл бабочки", + learning_text=( + "🔄 **Волшебное превращение:**\n\n" + "Бабочка проходит четыре стадии жизни:\n" + "1️⃣ Яйцо (самое маленькое)\n" + "2️⃣ Гусеница (очень голодная и прожорливая!)\n" + "3️⃣ Куколка (спит внутри кокона)\n" + "4️⃣ Бабочка (летает и радуется жизни)\n\n" + "Всё это называется метаморфозом — чудесным преобразованием!" + ), + question_text="Сколько стадий в жизни бабочки?", + options=["1 стадия", "2 стадии", "3 стадии", "4 стадии"], + correct_answer=3, + explanation="Отлично! Бабочка проходит 4 стадии: яйцо → гусеница → куколка → бабочка. Это невероятное путешествие длится несколько недель!" + ), + Question( + number=4, + title="Где живут бабочки?", + learning_text=( + "🌍 **По всему миру:**\n\n" + "Бабочки живут почти везде на Земле! Они встречаются в лесах, полях, горах, " + "садах, парках и даже в городах. Одни бабочки любят тепло, другие — прохладу. " + "Больше всего бабочек в тропических лесах, где очень много разных цветов!" + ), + question_text="Где ты можешь встретить бабочку?", + options=["Только в лесу", "Только в пустыне", "Только в горах", "В садах, лесах, полях и парках"], + correct_answer=3, + explanation="Верно! Бабочки встречаются почти везде, особенно там, где растут цветы. Прогулка по парку летом — отличный способ увидеть их!" + ), + Question( + number=5, + title="Как стать ловцом бабочек?", + learning_text=( + "🎯 **Искусство наблюдения:**\n\n" + "Настоящий ловец бабочек — это не тот, кто их ловит! " + "Это человек, который понимает, уважает и защищает бабочек. " + "Ловец наблюдает, учится, делится знаниями и помогает сохранить эти чудесные создания. " + "Присоединись к нашему сообществу Fly-Fly и стань часть большой стаи ловцов!" + ), + question_text="Что нужно ловцу бабочек в первую очередь?", + options=["Сачок и банка", "Терпение и знания", "Огромная коллекция", "Быстрые ноги"], + correct_answer=1, + explanation="Совершенно верно! Настоящий ловец полон терпения и знаний. Он наблюдает, учится, и со временем становится экспертом в мире бабочек!" + ), +] + + +def get_question(number: int) -> Question: + """Получить вопрос по номеру (1-5)""" + if 1 <= number <= 5: + return SCHOOL_CURRICULUM[number - 1] + return None + + +def validate_answer(question_number: int, answer_index: int) -> bool: + """Проверить, правильный ли ответ""" + question = get_question(question_number) + if question: + return answer_index == question.correct_answer + return False + + +def get_answer_explanation(question_number: int) -> str: + """Получить объяснение после ответа""" + question = get_question(question_number) + if question: + return question.explanation + return "" diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..f7dec24 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,203 @@ +# Утилиты и вспомогательные функции +import json +from datetime import datetime, timedelta +from typing import Optional, Dict +from .database import Database + + +class PointsValidator: + """Валидатор для защиты от повторных начислений""" + + VALIDATION_FILE = 'data/points_log.json' + + @staticmethod + def _load_log(): + """Загрузить лог начисленных очков""" + try: + with open(PointsValidator.VALIDATION_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + + @staticmethod + def _save_log(log: Dict): + """Сохранить лог начисленных очков""" + try: + with open(PointsValidator.VALIDATION_FILE, 'w', encoding='utf-8') as f: + json.dump(log, f, ensure_ascii=False, indent=2) + except: + pass + + @staticmethod + def can_complete_school(user_id: int) -> bool: + """Проверить, может ли пользователь пройти школу""" + log = PointsValidator._load_log() + user_id_str = str(user_id) + + if user_id_str not in log: + return True + + user_log = log[user_id_str] + if 'school_completed' in user_log: + return False + + return True + + @staticmethod + def mark_school_completed(user_id: int): + """Отметить школу как пройденную""" + log = PointsValidator._load_log() + user_id_str = str(user_id) + + if user_id_str not in log: + log[user_id_str] = {} + + log[user_id_str]['school_completed'] = datetime.now().isoformat() + PointsValidator._save_log(log) + + @staticmethod + def can_activate_butterfly_farm(user_id: int) -> bool: + """Проверить, может ли пользователь активировать Бабочкарий""" + log = PointsValidator._load_log() + user_id_str = str(user_id) + + if user_id_str not in log: + return True + + user_log = log[user_id_str] + activations = user_log.get('butterfly_activations', []) + + # Проверяем, была ли активация в этом месяце + today = datetime.now() + current_month = today.strftime('%Y-%m') + + for activation_date_str in activations: + try: + activation_date = datetime.fromisoformat(activation_date_str) + if activation_date.strftime('%Y-%m') == current_month: + return False + except: + pass + + return True + + @staticmethod + def mark_butterfly_activation(user_id: int): + """Отметить активацию Бабочкария""" + log = PointsValidator._load_log() + user_id_str = str(user_id) + + if user_id_str not in log: + log[user_id_str] = {} + + if 'butterfly_activations' not in log[user_id_str]: + log[user_id_str]['butterfly_activations'] = [] + + log[user_id_str]['butterfly_activations'].append(datetime.now().isoformat()) + PointsValidator._save_log(log) + + +class TeamManager: + """Менеджер для работы со стаями""" + + TEAMS_FILE = 'data/teams.json' + + @staticmethod + def _load_teams(): + """Загрузить данные о стаях""" + try: + with open(TeamManager.TEAMS_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except: + return {} + + @staticmethod + def _save_teams(teams: Dict): + """Сохранить данные о стаях""" + try: + with open(TeamManager.TEAMS_FILE, 'w', encoding='utf-8') as f: + json.dump(teams, f, ensure_ascii=False, indent=2) + except: + pass + + @staticmethod + def get_team_name(team_id: int) -> Optional[str]: + """Получить название стаи""" + teams = TeamManager._load_teams() + team_id_str = str(team_id) + + if team_id_str in teams: + return teams[team_id_str].get('team_name') + + return None + + @staticmethod + def update_team_name(team_id: int, new_name: str): + """Обновить название стаи""" + teams = TeamManager._load_teams() + team_id_str = str(team_id) + + if team_id_str in teams: + teams[team_id_str]['team_name'] = new_name + TeamManager._save_teams(teams) + + @staticmethod + def add_member(team_id: int, member_id: int): + """Добавить участника в стаю""" + teams = TeamManager._load_teams() + team_id_str = str(team_id) + + if team_id_str not in teams: + teams[team_id_str] = { + 'team_name': f'Стая {team_id}', + 'captain_id': team_id, + 'members': [], + 'created_at': datetime.now().isoformat() + } + + if member_id not in teams[team_id_str]['members']: + teams[team_id_str]['members'].append(member_id) + + TeamManager._save_teams(teams) + + @staticmethod + def remove_member(team_id: int, member_id: int): + """Удалить участника из стаи""" + teams = TeamManager._load_teams() + team_id_str = str(team_id) + + if team_id_str in teams: + if member_id in teams[team_id_str]['members']: + teams[team_id_str]['members'].remove(member_id) + + TeamManager._save_teams(teams) + + +class MessageFormatter: + """Форматтер для сообщений""" + + @staticmethod + def format_points_message(points: int, reason: str) -> str: + """Форматировать сообщение о начислении очков""" + emoji = "⭐" if points > 0 else "❌" + sign = "+" if points > 0 else "" + + return f"{emoji} {sign}{points} очков за {reason}" + + @staticmethod + def format_ranking_row(position: int, name: str, points: int) -> str: + """Форматировать строку рейтинга""" + medals = {1: "🥇", 2: "🥈", 3: "🥉"} + medal = medals.get(position, f"{position}️⃣") + + return f"{medal} {name} — {points} ⭐" + + +# Функции для работы с HTML/Markdown + +def escape_markdown(text: str) -> str: + """Экранировать специальные символы Markdown""" + special_chars = ['*', '_', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] + for char in special_chars: + text = text.replace(char, f'\\{char}') + return text