From 4c7d663e63870004507809a846d6b7f896764967 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 01:29:41 +0300 Subject: [PATCH 1/9] Refactor telegram bot workflow and admin tools --- README.md | 60 +- bot.py | 1879 ++++++++++++++++++++++++++++------------------ requirements.txt | 8 +- 3 files changed, 1196 insertions(+), 751 deletions(-) diff --git a/README.md b/README.md index 52e7b02..6319852 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ -# gipsrbot \ No newline at end of file +# Gipsr Bot + +Телеграм-бот для оформления учебных работ с расширенным функционалом: + +- интерактивное меню с заказом в несколько шагов и доп. услугами; +- динамический калькулятор стоимости с учетом сроков и сложности; +- личный кабинет с историей заказов и реферальной ссылкой; +- раздел FAQ и сбор отзывов; +- админ-панель с экспортом заказов, статистикой и переключением режима ценообразования. + +## Быстрый старт + +1. Установите зависимости: + + ```bash + pip install -r requirements.txt + ``` + +2. Создайте файл `.env` рядом с `bot.py` и укажите переменные: + + ```env + TELEGRAM_BOT_TOKEN=ваш_токен_бота + ADMIN_CHAT_ID=ваш_telegram_id + ``` + +3. Запустите бота: + + ```bash + python bot.py + ``` + +Бот автоматически создаст служебные директории `data/`, `logs/` и `clients/`, сохранит данные о ценах, заказах и логах в JSON и будет писать технические сообщения в `logs/bot.log`. + +## Основные сценарии + +- `/start` — приветствие и переход в главное меню. +- «📝 Сделать заказ» — пошаговое оформление заказа с выбором доп. услуг и итоговым подтверждением. +- «💲 Прайс-лист» и «🧮 Калькулятор» — просмотр тарифов и мгновенный расчет стоимости. +- «👤 Профиль» — статистика по заказам, рефералам и быстрый доступ к отзывам. +- «❓ FAQ» — ответы на популярные вопросы. +- `/admin` — админ-панель (доступна только указанному `ADMIN_CHAT_ID`). + +## Структура данных + +- `data/prices.json` — базовые цены на работы. +- `data/orders.json` — оформленные заказы пользователей. +- `data/referrals.json` — связи реферер → приглашенные пользователи. +- `data/feedbacks.json` — отзывы клиентов. +- `data/user_logs.json` — история действий пользователей. + +## Тестирование + +Проверить корректность синтаксиса можно командой: + +```bash +python -m compileall bot.py +``` + +Для интеграционного тестирования используйте тестовый токен бота и локальный запуск. diff --git a/bot.py b/bot.py index c561518..b7cee93 100644 --- a/bot.py +++ b/bot.py @@ -1,746 +1,1133 @@ -import os -import sys -import logging -import json -from datetime import datetime, timedelta -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ( - ApplicationBuilder, CommandHandler, ContextTypes, - CallbackQueryHandler, MessageHandler, filters, ConversationHandler -) -from telegram.constants import ParseMode -from telegram.error import TelegramError -from dotenv import load_dotenv -import pandas as pd - -# Загрузка переменных окружения -load_dotenv() -TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') -ADMIN_CHAT_ID = int(os.getenv('ADMIN_CHAT_ID', 0)) - -# Директории -BASE_DIR = os.path.join(os.getcwd(), 'clients') -DATA_DIR = os.path.join(os.getcwd(), 'data') -LOGS_DIR = os.path.join(os.getcwd(), 'logs') -for directory in [BASE_DIR, DATA_DIR, LOGS_DIR]: - os.makedirs(directory, exist_ok=True) - -# Настройка логирования -logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO -) -logger = logging.getLogger(__name__) -file_handler = logging.FileHandler(os.path.join(LOGS_DIR, 'bot.log')) -file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) -logger.addHandler(file_handler) - -# Файлы данных -PRICES_FILE = os.path.join(DATA_DIR, 'prices.json') -REFERRALS_FILE = os.path.join(DATA_DIR, 'referrals.json') -ORDERS_FILE = os.path.join(DATA_DIR, 'orders.json') -FEEDBACKS_FILE = os.path.join(DATA_DIR, 'feedbacks.json') -USER_LOGS_FILE = os.path.join(DATA_DIR, 'user_logs.json') - -# Функции загрузки/сохранения с обработкой ошибок -def load_json(file_path, default=None): - try: - if os.path.exists(file_path): - with open(file_path, 'r', encoding='utf-8') as f: - return json.load(f) - return default or {} - except Exception as e: - logger.error(f"Ошибка загрузки {file_path}: {e}") - return default or {} - -def save_json(file_path, data): - try: - with open(file_path, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка сохранения {file_path}: {e}") - -# Глобальные данные -PRICES = load_json(PRICES_FILE, { - 'samostoyatelnye': {'base': 2000, 'min': 2000, 'max': 5000}, - 'kursovaya_teoreticheskaya': {'base': 8000, 'min': 8000, 'max': 12000}, - 'kursovaya_s_empirikov': {'base': 12000, 'min': 12000, 'max': 18000}, - 'diplomnaya': {'base': 35000, 'min': 35000, 'max': 50000}, - 'magisterskaya': {'base': 35000, 'min': 35000, 'max': 60000} -}) -REFERALS = load_json(REFERRALS_FILE) -ORDERS = load_json(ORDERS_FILE) -FEEDBACKS = load_json(FEEDBACKS_FILE) -USER_LOGS = load_json(USER_LOGS_FILE) - -ORDER_TYPES = { - 'samostoyatelnye': { - 'name': 'Самостоятельные, контрольные, эссе', - 'icon': '📝', - 'description': 'Быстрые задания: эссе, контрольные, рефераты. Идеально для студентов! Уже 5000+ выполнено 🔥', - 'details': 'Объем до 20 страниц. Быстрое выполнение с гарантией качества.', - 'examples': ['Эссе по литературе', 'Контрольная по математике', 'Реферат по истории'] - }, - 'kursovaya_teoreticheskaya': { - 'name': 'Курсовая теоретическая', - 'icon': '📘', - 'description': 'Глубокий анализ литературы. Получите отличную оценку без стресса! 📈', - 'details': 'Теоретическая основа, анализ источников, структура по ГОСТ.', - 'examples': ['Теория маркетинга', 'Обзор психологии развития'] - }, - 'kursovaya_s_empirikov': { - 'name': 'Курсовая с эмпирикой', - 'icon': '📊', - 'description': 'Теория + данные, анализ. Клиенты говорят: "Лучшая помощь!" ⭐', - 'details': 'Включает опросы, статистику, рекомендации.', - 'examples': ['Исследование рынка', 'Анализ поведения потребителей'] - }, - 'diplomnaya': { - 'name': 'Дипломная работа', - 'icon': '🎓', - 'description': 'Полный цикл для успешной защиты. Скидка 10% на первый диплом! 💼', - 'details': 'Глубокий анализ, эмпирика, презентация.', - 'examples': ['Социальная адаптация', 'Экономический анализ компании'] - }, - 'magisterskaya': { - 'name': 'Магистерская диссертация', - 'icon': '🔍', - 'description': 'Инновационное исследование. 100% оригинальность гарантирована! 🌟', - 'details': 'Научная новизна, методология, публикации.', - 'examples': ['Разработка моделей AI', 'Комплексные исследования экологии'] - } -} - -FAQ_ITEMS = [ - {'question': 'Как сделать заказ?', 'answer': 'Выберите "Сделать заказ" и следуйте шагам. Можно заказать несколько работ сразу!'}, - {'question': 'Как рассчитывается стоимость?', 'answer': 'Зависит от типа, срочности и сложности. Используйте калькулятор для точной цены!'}, - {'question': 'Как работает реферальная программа?', 'answer': 'Поделитесь ссылкой — получите 5% от заказов друзей как бонус.'}, - {'question': 'Гарантии качества?', 'answer': 'Антиплагиат, правки бесплатно 14 дней, поддержка до защиты.'}, - {'question': 'Скидки?', 'answer': '5-15% для постоянных, 10% на первый, рефералы.'}, - {'question': 'Отслеживание заказа?', 'answer': 'В профиле статусы, уведомления от менеджера.'} -] - -current_pricing_mode = 'light' - -# Состояния -SELECT_MAIN_MENU, SELECT_ORDER_TYPE, VIEW_ORDER_DETAILS, INPUT_TOPIC, SELECT_DEADLINE, INPUT_REQUIREMENTS, ADD_UPSSELL, ADD_ANOTHER_ORDER, CONFIRM_CART, ADMIN_MENU, PROFILE_MENU, SHOW_PRICE_LIST, PRICE_CALCULATOR, SELECT_CALC_DEADLINE, SELECT_CALC_COMPLEXITY, SHOW_FAQ, FAQ_DETAILS, SHOW_ORDERS, LEAVE_FEEDBACK, INPUT_FEEDBACK = range(20) - -# Логирование действий пользователя -def log_user_action(user_id, username, action): - timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') - USER_LOGS.setdefault(str(user_id), []).append({'timestamp': timestamp, 'action': action, 'username': username}) - save_json(USER_LOGS_FILE, USER_LOGS) - logger.info(f"Пользователь {user_id} ({username}): {action}") - -# Расчет цены -def calculate_price(order_type_key, days_left, complexity_factor=1.0): - if order_type_key not in PRICES: - logger.error(f"Неизвестный тип: {order_type_key}") - return 0 - base = PRICES[order_type_key]['base'] - price = int(base * complexity_factor) - if current_pricing_mode == 'hard': - if days_left < 7: - price *= 1.3 - elif days_left < 15: - price *= 1.15 - else: - if days_left < 3: - price *= 1.3 - elif days_left < 7: - price *= 1.15 - return int(price) - -# Обработчик ошибок -async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - logger.error(f"Ошибка: {context.error}") - if ADMIN_CHAT_ID: - await context.bot.send_message(ADMIN_CHAT_ID, f"Ошибка: {context.error}") - -# Команда /start -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - user = update.effective_user - log_user_action(user.id, user.username, "/start") - args = update.message.text.split() - bot_username = (await context.bot.get_me()).username - ref_link = f"https://t.me/{bot_username}?start={user.id}" - context.user_data['ref_link'] = ref_link - if len(args) > 1 and args[1].isdigit(): - referrer_id = int(args[1]) - if referrer_id != user.id: - REFERALS.setdefault(str(referrer_id), []).append(user.id) - save_json(REFERRALS_FILE, REFERALS) - await context.bot.send_message(referrer_id, f"🎉 Новый реферал: {user.first_name}") - welcome = f"👋 Добро пожаловать, {user.first_name}! Заказывайте работы. Уже 5000+ клиентов! 10% скидка на первый заказ 🔥\nПоделитесь ссылкой для бонусов: {ref_link}" - await main_menu(update, context, welcome) - -# Главное меню -async def main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, message=None): - user = update.effective_user - log_user_action(user.id, user.username, "Главное меню") - text = message or "Выберите раздел:" - keyboard = [ - [InlineKeyboardButton("📝 Сделать заказ", callback_data='make_order')], - [InlineKeyboardButton("💲 Прайс-лист", callback_data='price_list'), InlineKeyboardButton("🧮 Калькулятор", callback_data='price_calculator')], - [InlineKeyboardButton("👤 Профиль", callback_data='profile'), InlineKeyboardButton("❓ FAQ", callback_data='faq')], - [InlineKeyboardButton("📞 Администратор", url='https://t.me/Thisissaymoon')] - ] - reply_markup = InlineKeyboardMarkup(keyboard) - if update.callback_query: - query = update.callback_query - await query.answer() - try: - await query.edit_message_text(text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN) - except TelegramError as e: - if "message is not modified" in str(e).lower(): - pass - else: - logger.error(f"Ошибка редактирования сообщения: {e}") - else: - await update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN) - return SELECT_MAIN_MENU - -# Обработчик главного меню -async def main_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - user = update.effective_user - log_user_action(user.id, user.username, f"Выбор в меню: {data}") - if data == 'make_order': - return await select_order_type(update, context) - elif data == 'price_list': - return await show_price_list(update, context) - elif data == 'price_calculator': - return await price_calculator(update, context) - elif data == 'profile': - return await show_profile(update, context) - elif data == 'faq': - return await show_faq(update, context) - await query.edit_message_text("Неизвестная команда. Возвращаюсь в главное меню.") - return await main_menu(update, context) - return SELECT_MAIN_MENU - -# Выбор типа заказа -async def select_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data if query else None - user = update.effective_user - log_user_action(user.id, user.username, "Выбор типа заказа") - if data == 'back_to_main': - return await main_menu(update, context) - text = "Выберите тип работы (добавьте несколько в корзину для скидки!):" - keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()] - keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')]) - reply_markup = InlineKeyboardMarkup(keyboard) - try: - await query.edit_message_text(text, reply_markup=reply_markup) - except TelegramError as e: - if "message is not modified" in str(e).lower(): - pass - else: - raise - return SELECT_ORDER_TYPE - -# Подробности о типе заказа -async def view_order_details(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('order_'): - key = data[6:] - context.user_data['current_order_type'] = key - await query.edit_message_text("Введите тему:") - return INPUT_TOPIC - elif data == 'select_order_type': - return await select_order_type(update, context) - elif data.startswith('type_'): - key = data[5:] - if key not in ORDER_TYPES: - await query.edit_message_text("Ошибка: неизвестный тип.") - return SELECT_ORDER_TYPE - val = ORDER_TYPES[key] - text = f"{val['icon']} *{val['name']}*\n\n{val['description']}\n{val['details']}\nПримеры: {', '.join(val['examples'])}\n\nЗаказать? (Добавьте презентацию/речь для полного пакета!)" - keyboard = [ - [InlineKeyboardButton("✅ Заказать", callback_data=f'order_{key}')], - [InlineKeyboardButton("Назад", callback_data='select_order_type')] - ] - await query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return VIEW_ORDER_DETAILS - -# Ввод темы -async def input_topic(update: Update, context: ContextTypes.DEFAULT_TYPE): - context.user_data['topic'] = update.message.text - user = update.effective_user - log_user_action(user.id, user.username, f"Тема: {update.message.text}") - text = "Выберите срок сдачи (дольше = дешевле + бонус!):" - today = datetime.now() - keyboard = [] - for i in range(1, 31, 5): - row = [] - for j in range(i, min(i+5, 31)): - date = today + timedelta(days=j) - button_text = f"{date.day} {date.strftime('%b')} ({j} дней)" - row.append(InlineKeyboardButton(button_text, callback_data=f'deadline_{j}')) - keyboard.append(row) - keyboard.append([InlineKeyboardButton("Назад", callback_data=f'type_{context.user_data["current_order_type"]}')]) - await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SELECT_DEADLINE - -# Выбор срока -async def select_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('deadline_'): - days = int(data[9:]) - context.user_data['days_left'] = days - await query.edit_message_text("Введите дополнительные требования (или /skip):") - return INPUT_REQUIREMENTS - elif data.startswith('type_'): - return await view_order_details(update, context) - return SELECT_DEADLINE - -# Ввод требований -async def input_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE): - context.user_data['requirements'] = update.message.text - return await add_upsell(update, context) - -async def skip_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE): - context.user_data['requirements'] = 'Нет' - return await add_upsell(update, context) - -# Добавление допуслуг -async def add_upsell(update: Update, context: ContextTypes.DEFAULT_TYPE): - text = "Добавить услуги? (Клиенты, добавляющие, получают 5% скидки на следующий заказ!)" - keyboard = [ - [InlineKeyboardButton("Презентация (+2000₽)", callback_data='add_prez')], - [InlineKeyboardButton("Речь (+1000₽)", callback_data='add_speech')], - [InlineKeyboardButton("Без допов", callback_data='no_upsell')] - ] - if update.message: - await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - else: - query = update.callback_query - await query.answer() - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return ADD_UPSSELL - -# Обработчик допуслуг -async def upsell_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - upsells = context.user_data.setdefault('upsells', set()) - added = False - if data == 'add_prez': - if 'prez' not in upsells: - upsells.add('prez') - added = True - elif data == 'add_speech': - if 'speech' not in upsells: - upsells.add('speech') - added = True - elif data == 'no_upsell': - return await process_order(update, context) - text = "Добавить еще? (Полный пакет экономит время!)" if added else "Уже добавлено. Добавить еще?" - keyboard = [ - [InlineKeyboardButton("Презентация (+2000₽)", callback_data='add_prez')], - [InlineKeyboardButton("Речь (+1000₽)", callback_data='add_speech')], - [InlineKeyboardButton("Продолжить", callback_data='no_upsell')] - ] - try: - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - except TelegramError as e: - if "message is not modified" in str(e).lower(): - pass - return ADD_UPSSELL - -async def process_order(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - type_key = context.user_data.get('current_order_type') - if not type_key or type_key not in ORDER_TYPES: - await query.edit_message_text("Ошибка: неверный тип заказа.") - return ConversationHandler.END - topic = context.user_data.get('topic', 'Без темы') - days_left = context.user_data.get('days_left', 14) - requirements = context.user_data.get('requirements', 'Нет') - upsells = list(context.user_data.get('upsells', set())) - price = calculate_price(type_key, days_left) - extra = len(upsells) * 1000 - price += extra - order = { - 'type': type_key, - 'topic': topic, - 'deadline_days': days_left, - 'requirements': requirements, - 'upsells': upsells, - 'price': price, - 'status': 'новый' - } - context.user_data.setdefault('cart', []).append(order) - context.user_data.pop('upsells', None) - context.user_data.pop('requirements', None) - context.user_data.pop('days_left', None) - context.user_data.pop('topic', None) - context.user_data.pop('current_order_type', None) - return await add_another_order(update, context) - -# Добавить еще заказ -async def add_another_order(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - text = "Добавить еще заказ? (Несколько заказов = 10% скидка!)" - keyboard = [ - [InlineKeyboardButton("Да", callback_data='add_another_yes')], - [InlineKeyboardButton("Нет, оформить", callback_data='confirm_cart')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return ADD_ANOTHER_ORDER - -async def add_another_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data == 'add_another_yes': - return await select_order_type(update, context) - elif data == 'confirm_cart': - return await confirm_cart(update, context) - return ADD_ANOTHER_ORDER - -# Подтверждение корзины -async def confirm_cart(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - cart = context.user_data.get('cart', []) - if not cart: - await query.edit_message_text("Корзина пуста.") - return await main_menu(update, context) - text = "Ваша корзина (подтвердите для специального бонуса!):\n" - total = 0 - for i, order in enumerate(cart, 1): - order_name = ORDER_TYPES.get(order['type'], {}).get('name', 'Неизвестно') - text += f"{i}. {order_name} - {order['topic']} - {order['price']} ₽\n" - total += order['price'] - if len(cart) > 1: - discount = total * 0.1 - total -= discount - text += f"Скидка за несколько заказов: -{discount} ₽\n" - text += f"Итого: {total} ₽\nПодтвердить?" - keyboard = [ - [InlineKeyboardButton("Подтвердить", callback_data='place_order')], - [InlineKeyboardButton("Отменить", callback_data='cancel_cart')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return CONFIRM_CART - -async def confirm_cart_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data == 'place_order': - user_id = str(update.effective_user.id) - user_orders = ORDERS.setdefault(user_id, []) - order_id = len(user_orders) + 1 - for order in context.user_data['cart']: - order['order_id'] = order_id - user_orders.append(order) - order_id += 1 - save_json(ORDERS_FILE, ORDERS) - text = "Заказ оформлен! Менеджер свяжется с вами. Проверьте профиль для статуса." - await query.edit_message_text(text) - if ADMIN_CHAT_ID: - await context.bot.send_message(ADMIN_CHAT_ID, f"Новый заказ от {user_id}: {len(context.user_data['cart'])} позиций") - context.user_data.pop('cart', None) - return await main_menu(update, context, "Спасибо! Хотите заказать еще?") - elif data == 'cancel_cart': - context.user_data.pop('cart', None) - return await main_menu(update, context, "Корзина отменена. Посмотрите еще?") - return CONFIRM_CART - -# Показ прайс-листа -async def show_price_list(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('price_detail_'): - key = data[13:] - val = ORDER_TYPES.get(key, {}) - if not val: - await query.edit_message_text("Ошибка: неизвестный тип.") - return SHOW_PRICE_LIST - prices = PRICES.get(key, {'min': 0, 'max': 0}) - text = f"{val.get('icon', '')} *{val.get('name', '')}*\n\n{val.get('description', '')}\n{val.get('details', '')}\nПримеры: {', '.join(val.get('examples', []))}\nЦена: {prices['min']}-{prices['max']} ₽\n\nЗакажите со скидкой!" - keyboard = [ - [InlineKeyboardButton("Рассчитать", callback_data='price_calculator')], - [InlineKeyboardButton("Заказать", callback_data=f'type_{key}')], - [InlineKeyboardButton("Назад", callback_data='price_list')] - ] - await query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_PRICE_LIST - elif data == 'price_calculator': - return await price_calculator(update, context) - elif data == 'back_to_main': - return await main_menu(update, context) - user = update.effective_user - log_user_action(user.id, user.username, "Прайс-лист") - text = "💲 Прайс-лист (10% скидка сегодня! 🔥):\n\n" - for key, val in ORDER_TYPES.items(): - prices = PRICES.get(key, {'base': 0}) - text += f"{val['icon']} *{val['name']}* — от {prices['base']} ₽\n" - keyboard = [[InlineKeyboardButton(f"Подробности {val['name']}", callback_data=f'price_detail_{key}')] for key, val in ORDER_TYPES.items()] - keyboard.append([InlineKeyboardButton("🧮 Рассчитать цену", callback_data='price_calculator')]) - keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')]) - await query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_PRICE_LIST - -# Калькулятор цен -async def price_calculator(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('calc_type_'): - key = data[10:] - context.user_data['calc_type'] = key - text = f"Тип: {ORDER_TYPES.get(key, {}).get('name', 'Неизвестно')}\nВыберите срок (дольше = дешевле):" - keyboard = [ - [InlineKeyboardButton("3 дня (+30%)", callback_data='calc_dead_3')], - [InlineKeyboardButton("7 дней (+15%)", callback_data='calc_dead_7'), InlineKeyboardButton("14 дней (базовая)", callback_data='calc_dead_14')], - [InlineKeyboardButton("30 дней (скидка!)", callback_data='calc_dead_30')], - [InlineKeyboardButton("Назад", callback_data='price_calculator')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SELECT_CALC_DEADLINE - elif data == 'back_to_main': - return await main_menu(update, context) - user = update.effective_user - log_user_action(user.id, user.username, "Калькулятор") - text = "🧮 Выберите тип:" - keyboard = [[InlineKeyboardButton(f"{v['icon']} {v['name']}", callback_data=f'calc_type_{k}')] for k, v in ORDER_TYPES.items()] - keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')]) - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return PRICE_CALCULATOR - -async def calc_select_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('calc_dead_'): - days = int(data[10:]) - context.user_data['calc_days'] = days - text = f"Срок: {days} дней\nВыберите сложность:" - keyboard = [ - [InlineKeyboardButton("Простая (базовая)", callback_data='calc_comp_1.0')], - [InlineKeyboardButton("Средняя (+10%)", callback_data='calc_comp_1.1'), InlineKeyboardButton("Сложная (+30%)", callback_data='calc_comp_1.3')], - [InlineKeyboardButton("Назад", callback_data=f'calc_type_{context.user_data["calc_type"]}')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SELECT_CALC_COMPLEXITY - return SELECT_CALC_DEADLINE - -async def calc_select_complexity(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('calc_comp_'): - comp = float(data[10:]) - key = context.user_data.get('calc_type') - days = context.user_data.get('calc_days', 14) - price = calculate_price(key, days, comp) - name = ORDER_TYPES.get(key, {}).get('name', 'Неизвестно') - text = f"Расчет: {name}\nСрок: {days} дней\nСложность: {int((comp-1)*100)}%\nЦена: {price} ₽ (Скидка сегодня!)\n\nЗаказать?" - keyboard = [ - [InlineKeyboardButton("📝 Заказать", callback_data=f'type_{key}')], - [InlineKeyboardButton("Пересчитать", callback_data='price_calculator'), InlineKeyboardButton("Меню", callback_data='back_to_main')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return PRICE_CALCULATOR - return SELECT_CALC_COMPLEXITY - -# Показ профиля -async def show_profile(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - user = update.effective_user - user_id = str(user.id) - if data == 'my_orders': - return await show_orders(update, context) - elif data == 'leave_feedback': - await query.edit_message_text("Введите ваш отзыв:") - return INPUT_FEEDBACK - elif data == 'back_to_main': - return await main_menu(update, context) - log_user_action(user.id, user.username, "Профиль") - orders_count = len(ORDERS.get(user_id, [])) - feedbacks_count = len(FEEDBACKS.get(user_id, [])) - refs_count = len(REFERALS.get(user_id, [])) - ref_link = context.user_data.get('ref_link', 'Нет ссылки') - text = f"👤 Профиль {user.first_name}\n\nЗаказов: {orders_count}\nОтзывов: {feedbacks_count}\nРефералов: {refs_count}\nРеф. ссылка: {ref_link}\n\nПриглашайте друзей за бонусы!" - keyboard = [ - [InlineKeyboardButton("📋 Мои заказы", callback_data='my_orders')], - [InlineKeyboardButton("⭐ Оставить отзыв", callback_data='leave_feedback')], - [InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')] - ] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return PROFILE_MENU - -# Показ заказов -async def show_orders(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data == 'profile': - return await show_profile(update, context) - user_id = str(update.effective_user.id) - user_orders = ORDERS.get(user_id, []) - if not user_orders: - text = "Пока нет заказов. Сделайте заказ сейчас!" - else: - text = "Ваши заказы:\n" - for order in user_orders: - name = ORDER_TYPES.get(order.get('type'), {}).get('name', 'Неизвестно') - text += f"#{order.get('order_id', 'N/A')}: {name} - {order.get('status', 'новый')}\n" - keyboard = [[InlineKeyboardButton("Назад", callback_data='profile')]] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_ORDERS - -# Ввод отзыва -async def input_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = str(update.effective_user.id) - feedback = update.message.text - FEEDBACKS.setdefault(user_id, []).append(feedback) - save_json(FEEDBACKS_FILE, FEEDBACKS) - await update.message.reply_text("Спасибо за отзыв! Добавлены бонусные баллы.") - return await show_profile(update, context) - -# Показ FAQ -async def show_faq(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data.startswith('faq_'): - idx = int(data[4:]) - item = FAQ_ITEMS[idx] - text = f"❓ {item['question']}\n\n{item['answer']}" - keyboard = [[InlineKeyboardButton("Назад к FAQ", callback_data='faq')], - [InlineKeyboardButton("Меню", callback_data='back_to_main')]] - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return FAQ_DETAILS - elif data == 'back_to_main': - return await main_menu(update, context) - user = update.effective_user - log_user_action(user.id, user.username, "FAQ") - text = "❓ FAQ: Выберите вопрос" - keyboard = [[InlineKeyboardButton(item['question'], callback_data=f'faq_{i}')] for i, item in enumerate(FAQ_ITEMS)] - keyboard.append([InlineKeyboardButton("⬅️ Меню", callback_data='back_to_main')]) - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_FAQ - -# Показ админ меню -async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE): - keyboard = [ - [InlineKeyboardButton("📋 Заказы", callback_data='admin_orders')], - [InlineKeyboardButton("👥 Пользователи", callback_data='admin_users'), InlineKeyboardButton("📊 Логи", callback_data='admin_logs')], - [InlineKeyboardButton("💲 Цены", callback_data='admin_prices')], - [InlineKeyboardButton("📤 Экспорт", callback_data='admin_export')], - [InlineKeyboardButton("⬅️ Выход", callback_data='back_to_main')] - ] - text = "🔐 Админ-панель" - if update.callback_query: - query = update.callback_query - await query.answer() - await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - else: - await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return ADMIN_MENU - -# Админ старт -async def admin_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.effective_user.id != ADMIN_CHAT_ID: - await update.message.reply_text("Доступ запрещен!") - return - user = update.effective_user - log_user_action(user.id, user.username, "Админ-панель") - return await show_admin_menu(update, context) - -# Обработчик админ-меню -async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = query.data - if data == 'admin_menu': - return await show_admin_menu(update, context) - text = "" - keyboard = [[InlineKeyboardButton("Назад", callback_data='admin_menu')]] - if data == 'admin_orders': - text = "📋 Заказы:\n" - for uid, ords in ORDERS.items(): - for ord in ords: - text += f"#{ord['order_id']} от {uid}: {ord['status']}\n" - elif data == 'admin_users': - text = "👥 Пользователи:\n" + "\n".join(f"ID: {uid}" for uid in ORDERS.keys()) - elif data == 'admin_logs': - text = "📊 Логи (последние 10):\n" - for uid, logs in list(USER_LOGS.items())[-10:]: - if logs: - text += f"Пользователь {uid}: {logs[-1]['action']}\n" - elif data == 'admin_prices': - text = f"Текущий режим: {current_pricing_mode}\nВведите новый режим (hard/light):" - context.user_data['admin_state'] = 'change_mode' - elif data == 'admin_export': - df = pd.DataFrame([{'user_id': uid, **ord} for uid, ords in ORDERS.items() for ord in ords]) - export_file = os.path.join(DATA_DIR, 'orders_export.csv') - df.to_csv(export_file, index=False) - await context.bot.send_document(ADMIN_CHAT_ID, open(export_file, 'rb')) - os.remove(export_file) - text = "📤 Экспорт отправлен!" - elif data == 'back_to_main': - return await main_menu(update, context) - await query.edit_message_text(text or "Неизвестная команда. Возвращаюсь в админ-меню.", reply_markup=InlineKeyboardMarkup(keyboard)) - return ADMIN_MENU - -# Обработчик сообщений админа -async def admin_message(update: Update, context: ContextTypes.DEFAULT_TYPE): - state = context.user_data.get('admin_state') - if state == 'change_mode': - global current_pricing_mode - current_pricing_mode = update.message.text.lower() - await update.message.reply_text("Режим изменен!") - context.user_data.pop('admin_state') - return await show_admin_menu(update, context) - return ADMIN_MENU - -# Основная функция -def main(): - application = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() - conv_handler = ConversationHandler( - entry_points=[CommandHandler('start', start), CommandHandler('admin', admin_start)], - states={ - SELECT_MAIN_MENU: [CallbackQueryHandler(main_menu_handler)], - SELECT_ORDER_TYPE: [CallbackQueryHandler(select_order_type)], - VIEW_ORDER_DETAILS: [CallbackQueryHandler(view_order_details)], - INPUT_TOPIC: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_topic)], - SELECT_DEADLINE: [CallbackQueryHandler(select_deadline)], - INPUT_REQUIREMENTS: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_requirements), CommandHandler('skip', skip_requirements)], - ADD_UPSSELL: [CallbackQueryHandler(upsell_handler)], - ADD_ANOTHER_ORDER: [CallbackQueryHandler(add_another_handler)], - CONFIRM_CART: [CallbackQueryHandler(confirm_cart_handler)], - ADMIN_MENU: [CallbackQueryHandler(admin_menu_handler), MessageHandler(filters.TEXT & ~filters.COMMAND, admin_message)], - PROFILE_MENU: [CallbackQueryHandler(show_profile)], - SHOW_PRICE_LIST: [CallbackQueryHandler(show_price_list)], - PRICE_CALCULATOR: [CallbackQueryHandler(price_calculator)], - SELECT_CALC_DEADLINE: [CallbackQueryHandler(calc_select_deadline)], - SELECT_CALC_COMPLEXITY: [CallbackQueryHandler(calc_select_complexity)], - SHOW_FAQ: [CallbackQueryHandler(show_faq)], - FAQ_DETAILS: [CallbackQueryHandler(show_faq)], - SHOW_ORDERS: [CallbackQueryHandler(show_orders)], - INPUT_FEEDBACK: [MessageHandler(filters.TEXT & ~filters.COMMAND, input_feedback)], - }, - fallbacks=[CommandHandler('start', start)], - ) - application.add_handler(conv_handler) - application.add_error_handler(error_handler) - application.run_polling(drop_pending_updates=True) - -if __name__ == '__main__': - main() \ No newline at end of file +from __future__ import annotations + +import html +import json +import logging +import os +from dataclasses import dataclass, asdict +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, Iterable, List, Optional + +import pandas as pd +from dotenv import load_dotenv +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.constants import ParseMode +from telegram.error import BadRequest +from telegram.ext import ( + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + ContextTypes, + ConversationHandler, + MessageHandler, + filters, +) + +load_dotenv() + +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") +ADMIN_CHAT_ID = int(os.getenv("ADMIN_CHAT_ID", "0")) + +if not TELEGRAM_BOT_TOKEN: + raise RuntimeError( + "TELEGRAM_BOT_TOKEN is not set. Create a .env file with TELEGRAM_BOT_TOKEN=." + ) + +BASE_DIR = Path(__file__).resolve().parent +DATA_DIR = BASE_DIR / "data" +LOGS_DIR = BASE_DIR / "logs" +CLIENTS_DIR = BASE_DIR / "clients" + +for directory in (DATA_DIR, LOGS_DIR, CLIENTS_DIR): + directory.mkdir(parents=True, exist_ok=True) + +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, +) +logger = logging.getLogger(__name__) +file_handler = logging.FileHandler(LOGS_DIR / "bot.log") +file_handler.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +) +logger.addHandler(file_handler) + +DEFAULT_PRICES: Dict[str, Dict[str, int]] = { + "self": {"base": 1500, "min": 1500, "max": 3500}, + "course_theory": {"base": 7000, "min": 6000, "max": 11000}, + "course_empirical": {"base": 11000, "min": 9500, "max": 16000}, + "vkr": {"base": 32000, "min": 28000, "max": 45000}, + "master": {"base": 42000, "min": 36000, "max": 60000}, +} + +ORDER_TYPES: Dict[str, Dict[str, object]] = { + "self": { + "name": "Самостоятельная работа", + "icon": "📝", + "description": "Быстрые задания: эссе, контрольные, отчеты. Выполняем качественно и в срок.", + "details": "Лучший выбор для работ до 20 страниц. Подбираем автора по предмету и требованиям.", + "examples": [ + "Эссе по философии", + "Контрольная по экономике", + "Реферат по истории", + ], + }, + "course_theory": { + "name": "Курсовая (теория)", + "icon": "📘", + "description": "Теоретическая курсовая с глубоким обзором литературы и четкой структурой.", + "details": "Формируем оглавление, методологию и источники по стандартам вашего вуза.", + "examples": [ + "История экономических учений", + "Методики преподавания", + "Психология личности", + ], + }, + "course_empirical": { + "name": "Курсовая (теория + эмпирика)", + "icon": "📊", + "description": "Курсовая с практической частью, опросами, анализом данных и выводами.", + "details": "Подготовим инструментарий, соберем данные и оформим аналитическую главу.", + "examples": [ + "Опрос удовлетворенности клиентов", + "Анализ HR-процессов", + "Исследование маркетинговых стратегий", + ], + }, + "vkr": { + "name": "Дипломная работа (ВКР)", + "icon": "🎓", + "description": "Полный цикл подготовки выпускной квалификационной работы.", + "details": "План, теория, эмпирика, презентация и речь. Поддержка до защиты.", + "examples": [ + "Социально-психологическая адаптация", + "Бизнес-план компании", + "Маркетинговая стратегия бренда", + ], + }, + "master": { + "name": "Магистерская диссертация", + "icon": "🔍", + "description": "Продвинутое исследование с научной новизной и публикациями.", + "details": "Разработаем методологию, проведем исследование, подготовим статьи и презентацию.", + "examples": [ + "Data-driven подходы в образовании", + "Инновации в социальной работе", + "Комплексные исследования экологии", + ], + }, +} + +FAQ_ITEMS: List[Dict[str, str]] = [ + { + "question": "Как сделать заказ?", + "answer": "Выберите '📝 Сделать заказ', укажите тип работы, тему, срок и требования. Менеджер свяжется для подтверждения.", + }, + { + "question": "Как рассчитывается стоимость?", + "answer": "Стоимость зависит от типа, срочности и сложности. Воспользуйтесь разделом '🧮 Калькулятор' для точного расчета.", + }, + { + "question": "Какие гарантии предоставляете?", + "answer": "Проверяем работы на антиплагиат, делаем бесплатные правки 14 дней и сопровождаем до успешной защиты.", + }, + { + "question": "Есть ли скидки?", + "answer": "Первые клиенты получают -10%, за несколько заказов действуют дополнительные скидки и бонусы.", + }, + { + "question": "Как работает реферальная программа?", + "answer": "Поделитесь персональной ссылкой из профиля. За каждого приглашенного друга — 5% бонусов.", + }, + { + "question": "Как отслеживать статус заказа?", + "answer": "Все статусы видны в профиле, плюс менеджер отправляет промежуточные отчеты и уведомления.", + }, +] + +UPSELL_OPTIONS: Dict[str, Dict[str, int]] = { + "prez": {"title": "Презентация", "price": 2000}, + "speech": {"title": "Речь для защиты", "price": 1000}, +} + +SETTINGS_FILE = DATA_DIR / "settings.json" +PRICES_FILE = DATA_DIR / "prices.json" +REFERRALS_FILE = DATA_DIR / "referrals.json" +ORDERS_FILE = DATA_DIR / "orders.json" +FEEDBACKS_FILE = DATA_DIR / "feedbacks.json" +USER_LOGS_FILE = DATA_DIR / "user_logs.json" + + +@dataclass +class OrderRecord: + order_id: int + type_key: str + topic: str + deadline_days: int + deadline_date: str + requirements: str + upsells: List[str] + price: int + status: str + created_at: str + + +class DataStore: + def __init__(self) -> None: + self.settings: Dict[str, str] = self._load_json(SETTINGS_FILE, {"pricing_mode": "light"}) + self.prices: Dict[str, Dict[str, int]] = self._load_json(PRICES_FILE, DEFAULT_PRICES) + self.referrals: Dict[str, List[int]] = self._load_json(REFERRALS_FILE, {}) + self.orders: Dict[str, List[Dict[str, object]]] = self._load_json(ORDERS_FILE, {}) + self.feedbacks: Dict[str, List[str]] = self._load_json(FEEDBACKS_FILE, {}) + self.user_logs: Dict[str, List[Dict[str, str]]] = self._load_json(USER_LOGS_FILE, {}) + + @staticmethod + def _load_json(path: Path, default) -> object: + if path.exists(): + try: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + except json.JSONDecodeError as exc: + logger.warning("Failed to decode %s (%s). Restoring defaults.", path, exc) + DataStore._save_json(path, default) + return json.loads(json.dumps(default)) + + @staticmethod + def _save_json(path: Path, data) -> None: + with path.open("w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + + def get_pricing_mode(self) -> str: + return self.settings.get("pricing_mode", "light") + + def set_pricing_mode(self, mode: str) -> None: + self.settings["pricing_mode"] = mode + self._save_json(SETTINGS_FILE, self.settings) + + def add_referral(self, referrer_id: int, new_user_id: int) -> bool: + referrer_key = str(referrer_id) + referred_list = self.referrals.setdefault(referrer_key, []) + if new_user_id in referred_list: + return False + referred_list.append(new_user_id) + self._save_json(REFERRALS_FILE, self.referrals) + return True + + def log_action(self, user_id: int, username: Optional[str], action: str) -> None: + entry = { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "action": action, + "username": username or "", + } + user_key = str(user_id) + self.user_logs.setdefault(user_key, []).append(entry) + self._save_json(USER_LOGS_FILE, self.user_logs) + + def add_feedback(self, user_id: int, feedback: str) -> None: + user_key = str(user_id) + self.feedbacks.setdefault(user_key, []).append(feedback) + self._save_json(FEEDBACKS_FILE, self.feedbacks) + + def next_order_id(self, user_id: int) -> int: + user_key = str(user_id) + orders = self.orders.get(user_key, []) + if not orders: + return 1 + last_id = max(int(order.get("order_id", 0)) for order in orders) + return last_id + 1 + + def add_order(self, user_id: int, order: OrderRecord) -> OrderRecord: + user_key = str(user_id) + orders = self.orders.setdefault(user_key, []) + orders.append(asdict(order)) + self._save_json(ORDERS_FILE, self.orders) + return order + + def get_orders(self, user_id: int) -> List[Dict[str, object]]: + return self.orders.get(str(user_id), []) + + def total_spent(self, user_id: int) -> int: + return int(sum(int(order.get("price", 0)) for order in self.get_orders(user_id))) + + def get_referrals(self, user_id: int) -> List[int]: + return self.referrals.get(str(user_id), []) + + def export_orders(self) -> Optional[Path]: + records: List[Dict[str, object]] = [] + for user_id, orders in self.orders.items(): + for order in orders: + records.append({"user_id": user_id, **order}) + if not records: + return None + df = pd.DataFrame(records) + export_path = DATA_DIR / "orders_export.xlsx" + df.to_excel(export_path, index=False) + return export_path + + def get_statistics(self) -> Dict[str, int]: + orders_flat = [order for orders in self.orders.values() for order in orders] + total_orders = len(orders_flat) + total_revenue = int(sum(int(order.get("price", 0)) for order in orders_flat)) + active_orders = sum(1 for order in orders_flat if order.get("status", "").lower() not in {"готов", "завершен"}) + unique_users = len(self.orders) + return { + "orders": total_orders, + "revenue": total_revenue, + "active": active_orders, + "users": unique_users, + } + + +store = DataStore() + + +STATE_NAVIGATION, STATE_ORDER_TOPIC, STATE_ORDER_REQUIREMENTS, STATE_FEEDBACK, STATE_ADMIN = range(5) + +def log_user_action(update: Update, action: str) -> None: + user = update.effective_user + if not user: + return + store.log_action(user.id, user.username, action) + + +def calculate_price(order_type: str, days_left: int, complexity: float = 1.0, upsells: Iterable[str] = ()) -> int: + price_info = store.prices.get(order_type) or DEFAULT_PRICES.get(order_type) + if not price_info: + logger.warning("Unknown order type for pricing: %s", order_type) + return 0 + price = int(price_info.get("base", 0) * complexity) + mode = store.get_pricing_mode() + if mode == "hard": + if days_left < 7: + price = int(price * 1.3) + elif days_left < 15: + price = int(price * 1.15) + else: + if days_left < 3: + price = int(price * 1.3) + elif days_left < 7: + price = int(price * 1.15) + for upsell in upsells: + option = UPSELL_OPTIONS.get(upsell) + if option: + price += option["price"] + return price + + +def build_main_menu_keyboard() -> InlineKeyboardMarkup: + keyboard = [ + [InlineKeyboardButton("📝 Сделать заказ", callback_data="main:order")], + [ + InlineKeyboardButton("💲 Прайс-лист", callback_data="main:prices"), + InlineKeyboardButton("🧮 Калькулятор", callback_data="main:calculator"), + ], + [ + InlineKeyboardButton("👤 Профиль", callback_data="main:profile"), + InlineKeyboardButton("❓ FAQ", callback_data="main:faq"), + ], + [InlineKeyboardButton("📞 Администратор", url="https://t.me/Thisissaymoon")], + ] + return InlineKeyboardMarkup(keyboard) + + +def order_type_name_from_key(type_key: Optional[str]) -> str: + if type_key and type_key in ORDER_TYPES: + return ORDER_TYPES[type_key]["name"] + return str(type_key or "Неизвестный тип") + + +def order_type_name_from_record(order: Dict[str, object]) -> str: + type_key = order.get("type_key") + if type_key in ORDER_TYPES: + return ORDER_TYPES[type_key]["name"] + legacy_name = order.get("type") + if legacy_name: + return str(legacy_name) + return order_type_name_from_key(type_key if isinstance(type_key, str) else None) + + +def format_order_summary(draft: Dict[str, object], price: int) -> str: + order_type = ORDER_TYPES.get(draft.get("type_key")) or {} + upsells = draft.get("upsells", []) + upsell_lines = [] + for upsell in upsells: + info = UPSELL_OPTIONS.get(upsell) + if info: + upsell_lines.append(f"• {info['title']} (+{info['price']} ₽)") + upsell_text = "\n".join(upsell_lines) if upsell_lines else "—" + deadline_days = int(draft.get("deadline_days", 0)) + deadline_date = (datetime.now() + timedelta(days=deadline_days)).strftime("%d.%m.%Y") + return ( + f"Проверим данные перед оформлением:\n\n" + f"Тип: {order_type.get('icon', '')} {order_type.get('name', 'Неизвестно')}\n" + f"Тема: {html.escape(str(draft.get('topic', 'не указана')))}\n" + f"Срок: {deadline_days} дн. (до {deadline_date})\n" + f"Требования: {html.escape(str(draft.get('requirements', 'не указаны')))}\n" + f"Доп. услуги: {upsell_text}\n\n" + f"Итого к оплате: {price} ₽" + ) + + +async def get_bot_username(context: ContextTypes.DEFAULT_TYPE) -> str: + username = context.application.bot_data.get("bot_username") + if username: + return username + me = await context.bot.get_me() + username = me.username or "" + context.application.bot_data["bot_username"] = username + return username + + +async def show_main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, text: Optional[str] = None) -> int: + markup = build_main_menu_keyboard() + message_text = text or "Выберите нужный раздел 👇" + if update.callback_query: + query = update.callback_query + try: + await query.edit_message_text(message_text, reply_markup=markup, parse_mode=ParseMode.HTML) + except BadRequest as exc: + if "message is not modified" not in str(exc): + raise + elif update.message: + await update.message.reply_text(message_text, reply_markup=markup, parse_mode=ParseMode.HTML) + return STATE_NAVIGATION + +def get_order_draft(context: ContextTypes.DEFAULT_TYPE) -> Dict[str, object]: + draft = context.user_data.setdefault("order_draft", {}) + draft.setdefault("upsells", set()) + return draft + + +async def show_order_types(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + log_user_action(update, "open_order_types") + keyboard = [ + [ + InlineKeyboardButton( + f"{info['icon']} {info['name']}", callback_data=f"order:type:{key}" + ) + ] + for key, info in ORDER_TYPES.items() + ] + keyboard.append([InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")]) + markup = InlineKeyboardMarkup(keyboard) + text = "Выберите тип работы. Можно оформить несколько заказов подряд — будет дополнительная скидка!" + query = update.callback_query + try: + await query.edit_message_text(text, reply_markup=markup) + except BadRequest as exc: + if "message is not modified" not in str(exc): + raise + return STATE_NAVIGATION + + +async def show_order_details(update: Update, key: str) -> int: + query = update.callback_query + info = ORDER_TYPES.get(key) + if not info: + await query.edit_message_text("Неизвестный тип работы. Попробуйте снова.") + return STATE_NAVIGATION + examples = ", ".join(info.get("examples", [])) + text = ( + f"{info['icon']} {info['name']}\n\n" + f"{info['description']}\n\n" + f"Что включено: {info['details']}\n" + f"Примеры: {html.escape(examples)}" + ) + keyboard = [ + [InlineKeyboardButton("✅ Выбрать", callback_data=f"order:new:{key}")], + [InlineKeyboardButton("⬅️ Назад", callback_data="order:list")], + ] + await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) + return STATE_NAVIGATION + + +async def prompt_order_topic(update: Update, context: ContextTypes.DEFAULT_TYPE, key: str) -> int: + draft = get_order_draft(context) + draft.clear() + draft.update({"type_key": key, "upsells": set()}) + query = update.callback_query + await query.edit_message_text( + "Напишите тему работы сообщением. Можно прикрепить требования позже. Для отмены /cancel", + parse_mode=ParseMode.HTML, + ) + return STATE_ORDER_TOPIC + + +async def receive_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + topic = update.message.text.strip() + if not topic: + await update.message.reply_text("Пожалуйста, отправьте тему текстом.") + return STATE_ORDER_TOPIC + draft["topic"] = topic + log_user_action(update, f"order_topic:{topic}") + return await prompt_deadline(update, context) + + +async def prompt_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + text = "Выберите срок сдачи. Чем больше времени — тем выгоднее стоимость." + today = datetime.now() + keyboard: List[List[InlineKeyboardButton]] = [] + for days in (3, 7, 14, 21, 30): + deadline = today + timedelta(days=days) + keyboard.append( + [InlineKeyboardButton(f"{deadline:%d.%m} ({days} дн.)", callback_data=f"order:deadline:{days}")] + ) + keyboard.append([InlineKeyboardButton("⬅️ Отмена", callback_data="order:cancel")]) + markup = InlineKeyboardMarkup(keyboard) + await update.message.reply_text(text, reply_markup=markup) + return STATE_NAVIGATION + + +async def handle_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> int: + draft = get_order_draft(context) + draft["deadline_days"] = days + query = update.callback_query + await query.edit_message_text( + "Расскажите дополнительные требования и пожелания сообщением. Если их нет — нажмите /skip", + ) + return STATE_ORDER_REQUIREMENTS + + +async def receive_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + draft["requirements"] = update.message.text.strip() + log_user_action(update, "order_requirements_set") + return await show_upsell_menu(update, context) + + +async def skip_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + draft["requirements"] = "Нет" + return await show_upsell_menu(update, context) + + +def build_upsell_keyboard(selected: Iterable[str]) -> InlineKeyboardMarkup: + keyboard: List[List[InlineKeyboardButton]] = [] + selected_set = set(selected) + for key, info in UPSELL_OPTIONS.items(): + prefix = "✅" if key in selected_set else "➕" + keyboard.append( + [InlineKeyboardButton(f"{prefix} {info['title']} (+{info['price']} ₽)", callback_data=f"order:upsell:{key}")] + ) + keyboard.append([InlineKeyboardButton("Продолжить", callback_data="order:summary")]) + keyboard.append([InlineKeyboardButton("Отменить", callback_data="order:cancel")]) + return InlineKeyboardMarkup(keyboard) + + +async def show_upsell_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + raw_selected = draft.get("upsells", set()) + selected = raw_selected if isinstance(raw_selected, set) else set(raw_selected) + draft["upsells"] = selected + if update.message: + await update.message.reply_text( + "Хотите добавить дополнительные материалы? Презентация или речь экономят время на подготовке!", + reply_markup=build_upsell_keyboard(selected), + ) + else: + query = update.callback_query + await query.edit_message_text( + "Хотите добавить дополнительные материалы?", + reply_markup=build_upsell_keyboard(selected), + ) + return STATE_NAVIGATION + + +async def toggle_upsell(update: Update, context: ContextTypes.DEFAULT_TYPE, key: str) -> int: + draft = get_order_draft(context) + selected_raw = draft.get("upsells", set()) + selected = selected_raw if isinstance(selected_raw, set) else set(selected_raw) + if key in selected: + selected.remove(key) + else: + selected.add(key) + draft["upsells"] = selected + query = update.callback_query + await query.edit_message_text( + "Отлично! Можно добавить еще или перейти к подтверждению.", + reply_markup=build_upsell_keyboard(selected), + ) + return STATE_NAVIGATION + + +async def show_order_summary_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + if "topic" not in draft or "deadline_days" not in draft: + return await show_main_menu(update, context, "Начните оформление заказа заново.") + upsells = list(draft.get("upsells", set())) + draft["upsells"] = upsells + price = calculate_price(draft["type_key"], int(draft["deadline_days"]), 1.0, upsells) + draft["price"] = price + text = format_order_summary(draft, price) + keyboard = [ + [InlineKeyboardButton("✅ Подтвердить заказ", callback_data="order:confirm")], + [InlineKeyboardButton("✏️ Изменить допы", callback_data="order:upsell")], + [InlineKeyboardButton("❌ Отменить", callback_data="order:cancel")], + ] + query = update.callback_query + await query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard)) + return STATE_NAVIGATION + + +async def cancel_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data.pop("order_draft", None) + if update.callback_query: + await update.callback_query.edit_message_text("Оформление отменено. Можно начать заново из меню.") + return await show_main_menu(update, context) + if update.message: + await update.message.reply_text("Оформление заказа отменено.") + return await show_main_menu(update, context) + + +async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user: + return STATE_NAVIGATION + draft = get_order_draft(context) + if not draft: + return await show_main_menu(update, context, "Корзина пуста. Попробуйте оформить заказ заново.") + order_id = store.next_order_id(user.id) + deadline_days = int(draft.get("deadline_days", 0)) + deadline_date = (datetime.now() + timedelta(days=deadline_days)).strftime("%d.%m.%Y") + order = OrderRecord( + order_id=order_id, + type_key=str(draft.get("type_key")), + topic=str(draft.get("topic", "")), + deadline_days=deadline_days, + deadline_date=deadline_date, + requirements=str(draft.get("requirements", "")), + upsells=list(draft.get("upsells", [])), + price=int(draft.get("price", 0)), + status="новый", + created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + ) + store.add_order(user.id, order) + log_user_action(update, f"order_confirmed:{order.order_id}") + context.user_data.pop("order_draft", None) + text = ( + f"Спасибо! Заказ #{order.order_id} оформлен.\n" + f"Менеджер свяжется с вами в ближайшее время. Статус можно отслеживать в профиле." + ) + query = update.callback_query + await query.edit_message_text(text) + if ADMIN_CHAT_ID: + order_type = order_type_name_from_key(order.type_key) + admin_text = ( + f"Новый заказ #{order.order_id}\n" + f"Пользователь: {user.id} ({user.username or user.full_name})\n" + f"Тип: {order_type}\n" + f"Тема: {order.topic}\n" + f"Срок: {order.deadline_days} дн. (до {order.deadline_date})\n" + f"Сумма: {order.price} ₽" + ) + try: + await context.bot.send_message(ADMIN_CHAT_ID, admin_text) + except Exception as exc: # pragma: no cover - depends on Telegram API + logger.error("Failed to notify admin: %s", exc) + return await show_main_menu(update, context, "Хотите оформить еще одну работу?") + +async def show_price_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + log_user_action(update, "open_price_list") + rows = [] + for key, info in ORDER_TYPES.items(): + price_info = store.prices.get(key, DEFAULT_PRICES[key]) + rows.append(f"{info['icon']} {info['name']} — от {price_info['base']} ₽") + text = "💰 Прайс-лист\n\n" + "\n".join(rows) + keyboard = [ + [ + InlineKeyboardButton(f"Подробнее: {info['name']}", callback_data=f"prices:detail:{key}") + ] + for key, info in ORDER_TYPES.items() + ] + keyboard.append([InlineKeyboardButton("🧮 Калькулятор", callback_data="main:calculator")]) + keyboard.append([InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")]) + query = update.callback_query + await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.HTML) + return STATE_NAVIGATION + + +async def show_price_detail(update: Update, key: str) -> int: + info = ORDER_TYPES.get(key) + price_info = store.prices.get(key, DEFAULT_PRICES.get(key, {})) + if not info: + await update.callback_query.edit_message_text("Неизвестный тип работы.") + return STATE_NAVIGATION + text = ( + f"{info['icon']} {info['name']}\n\n" + f"{info['description']}\n\n" + f"Диапазон цен: {price_info.get('min', price_info.get('base', 0))}–{price_info.get('max', price_info.get('base', 0))} ₽\n" + f"Что входит: {info['details']}\n" + f"Примеры: {html.escape(', '.join(info.get('examples', [])))}" + ) + keyboard = [ + [InlineKeyboardButton("📝 Оформить", callback_data=f"order:type:{key}")], + [InlineKeyboardButton("⬅️ Назад", callback_data="main:prices")], + ] + await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard)) + return STATE_NAVIGATION + + +async def show_calculator(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + log_user_action(update, "open_calculator") + keyboard = [ + [InlineKeyboardButton(f"{info['icon']} {info['name']}", callback_data=f"calc:type:{key}")] + for key, info in ORDER_TYPES.items() + ] + keyboard.append([InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")]) + await update.callback_query.edit_message_text( + "Выберите тип работы для расчета стоимости:", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return STATE_NAVIGATION + + +async def calculator_select_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE, key: str) -> int: + context.user_data.setdefault("calculator", {})["type"] = key + keyboard = [] + for days in (3, 7, 14, 21, 30): + keyboard.append([InlineKeyboardButton(f"{days} дней", callback_data=f"calc:deadline:{days}")]) + keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="main:calculator")]) + await update.callback_query.edit_message_text( + "Выберите срок выполнения:", reply_markup=InlineKeyboardMarkup(keyboard) + ) + return STATE_NAVIGATION + + +async def calculator_select_complexity(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> int: + context.user_data.setdefault("calculator", {})["deadline"] = days + keyboard = [ + [InlineKeyboardButton("Базовая", callback_data="calc:complexity:1.0")], + [ + InlineKeyboardButton("Средняя (+10%)", callback_data="calc:complexity:1.1"), + InlineKeyboardButton("Сложная (+30%)", callback_data="calc:complexity:1.3"), + ], + [InlineKeyboardButton("⬅️ Назад", callback_data="main:calculator")], + ] + await update.callback_query.edit_message_text( + "Выберите сложность темы:", reply_markup=InlineKeyboardMarkup(keyboard) + ) + return STATE_NAVIGATION + + +async def show_calculation_result(update: Update, context: ContextTypes.DEFAULT_TYPE, complexity: float) -> int: + data = context.user_data.get("calculator", {}) + type_key = data.get("type") + days = int(data.get("deadline", 14)) + if not type_key: + return await show_calculator(update, context) + price = calculate_price(type_key, days, complexity) + info = ORDER_TYPES.get(type_key, {}) + text = ( + f"Расчет для {info.get('icon', '')} {info.get('name', 'работы')}\n" + f"Срок: {days} дней\n" + f"Сложность: {int((complexity - 1) * 100)}%\n\n" + f"Примерная стоимость: {price} ₽\n\n" + f"Хотите оформить заказ прямо сейчас?" + ) + keyboard = [ + [InlineKeyboardButton("📝 Сделать заказ", callback_data=f"order:type:{type_key}")], + [InlineKeyboardButton("🔁 Пересчитать", callback_data="main:calculator")], + [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], + ] + await update.callback_query.edit_message_text( + text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard) + ) + return STATE_NAVIGATION + + +async def show_profile(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user: + return STATE_NAVIGATION + log_user_action(update, "open_profile") + orders = store.get_orders(user.id) + total_orders = len(orders) + total_spent = store.total_spent(user.id) + referrals = store.get_referrals(user.id) + bot_username = await get_bot_username(context) + ref_link = f"https://t.me/{bot_username}?start={user.id}" if bot_username else "—" + text = ( + f"👤 Профиль {html.escape(user.first_name or user.full_name or '')}\n\n" + f"Заказов: {total_orders}\n" + f"На сумму: {total_spent} ₽\n" + f"Рефералов: {len(referrals)}\n" + f"Реферальная ссылка: {ref_link}\n\n" + "Приглашайте друзей и получайте бонусы!" + ) + keyboard = [ + [InlineKeyboardButton("📋 Мои заказы", callback_data="profile:orders")], + [InlineKeyboardButton("⭐ Оставить отзыв", callback_data="profile:feedback")], + [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], + ] + await update.callback_query.edit_message_text( + text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(keyboard) + ) + return STATE_NAVIGATION + + +async def show_user_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user: + return STATE_NAVIGATION + orders = store.get_orders(user.id) + if not orders: + text = "Пока нет заказов. Самое время оформить первый!" + else: + lines = [] + for order in orders[-10:]: + name = order_type_name_from_record(order) + lines.append( + f"#{order.get('order_id')} — {name}\n" + f"Тема: {order.get('topic')}\n" + f"Срок: {order.get('deadline_date')}\n" + f"Статус: {order.get('status', 'в работе')}\n" + ) + text = "\n".join(lines) + keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data="main:profile")]] + await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) + return STATE_NAVIGATION + + +async def request_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + await update.callback_query.edit_message_text( + "Поделитесь впечатлениями о нашей работе. Отзыв увидит администратор и подарит бонусы!" + ) + return STATE_FEEDBACK + + +async def receive_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + feedback = update.message.text.strip() + user = update.effective_user + if not feedback: + await update.message.reply_text("Отзыв не может быть пустым. Попробуйте еще раз.") + return STATE_FEEDBACK + store.add_feedback(user.id, feedback) + log_user_action(update, "feedback_left") + await update.message.reply_text("Спасибо! Мы ценим обратную связь.") + if ADMIN_CHAT_ID: + try: + await context.bot.send_message( + ADMIN_CHAT_ID, + f"Новый отзыв от {user.id} ({user.username or user.full_name}):\n{feedback}", + ) + except Exception as exc: # pragma: no cover + logger.error("Failed to send feedback to admin: %s", exc) + return await show_main_menu(update, context) + + +async def show_faq(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + keyboard = [ + [InlineKeyboardButton(item["question"], callback_data=f"faq:item:{idx}")] + for idx, item in enumerate(FAQ_ITEMS) + ] + keyboard.append([InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")]) + await update.callback_query.edit_message_text( + "Частые вопросы. Выберите интересующий пункт:", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return STATE_NAVIGATION + + +async def show_faq_item(update: Update, idx: int) -> int: + if idx < 0 or idx >= len(FAQ_ITEMS): + await update.callback_query.edit_message_text("Вопрос не найден.") + return STATE_NAVIGATION + item = FAQ_ITEMS[idx] + keyboard = [ + [InlineKeyboardButton("⬅️ Назад", callback_data="main:faq")], + [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], + ] + await update.callback_query.edit_message_text( + f"❓ {item['question']}\n\n{item['answer']}", + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return STATE_NAVIGATION + +async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + if not user or user.id != ADMIN_CHAT_ID: + if update.message: + await update.message.reply_text("Админ-панель доступна только владельцу бота.") + elif update.callback_query: + await update.callback_query.answer("Нет доступа", show_alert=True) + return STATE_NAVIGATION + keyboard = [ + [InlineKeyboardButton("📊 Статистика", callback_data="admin:stats")], + [InlineKeyboardButton("📋 Последние заказы", callback_data="admin:orders")], + [InlineKeyboardButton("💰 Режим ценообразования", callback_data="admin:pricing")], + [InlineKeyboardButton("📤 Экспорт в Excel", callback_data="admin:export")], + [InlineKeyboardButton("🗂 Последние действия", callback_data="admin:logs")], + [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], + ] + text = "🔐 Админ-панель. Выберите действие:" + if update.message: + await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) + else: + await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) + return STATE_NAVIGATION + + +async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + log_user_action(update, "admin_command") + return await show_admin_menu(update, context) + + +async def admin_show_stats(update: Update) -> int: + stats = store.get_statistics() + text = ( + "📊 Сводка по заказам\n\n" + f"Всего заказов: {stats['orders']}\n" + f"Активных заказов: {stats['active']}\n" + f"Выручка: {stats['revenue']} ₽\n" + f"Клиентов: {stats['users']}" + ) + await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]])) + return STATE_NAVIGATION + + +async def admin_show_orders(update: Update) -> int: + records = [order for orders in store.orders.values() for order in orders] + records = sorted(records, key=lambda item: item.get("created_at", ""), reverse=True)[:10] + if not records: + text = "Заказов пока нет." + else: + lines = [] + for record in records: + name = order_type_name_from_record(record) + lines.append( + f"#{record.get('order_id')} — {name}\n" + f"Тема: {record.get('topic')}\n" + f"Статус: {record.get('status', 'в работе')}\n" + f"Цена: {record.get('price')} ₽\n" + ) + text = "\n".join(lines) + await update.callback_query.edit_message_text( + text, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]) + ) + return STATE_NAVIGATION + + +async def admin_request_pricing_mode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + context.user_data["admin_state"] = "pricing_mode" + await update.callback_query.edit_message_text( + f"Текущий режим: {store.get_pricing_mode()}. Введите hard или light:", + ) + return STATE_ADMIN + + +async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + path = store.export_orders() + if not path: + await update.callback_query.edit_message_text( + "Нет данных для экспорта.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]) + ) + return STATE_NAVIGATION + await update.callback_query.edit_message_text("Отправляю файл с заказами…") + try: + await context.bot.send_document(ADMIN_CHAT_ID, document=path.open("rb")) + finally: + path.unlink(missing_ok=True) + await update.callback_query.edit_message_text( + "Экспорт заказов успешно отправлен ✅", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]), + ) + return STATE_NAVIGATION + + +async def admin_show_logs(update: Update) -> int: + last_logs = [] + for user_id, logs in store.user_logs.items(): + if logs: + last_logs.append((user_id, logs[-1])) + if not last_logs: + text = "Логи пока пусты." + else: + lines = [ + f"{user_id}: {entry['action']} ({entry['timestamp']})" + for user_id, entry in last_logs[-10:] + ] + text = "\n".join(lines) + await update.callback_query.edit_message_text( + text, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]) + ) + return STATE_NAVIGATION + + +async def handle_admin_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + state = context.user_data.pop("admin_state", None) + if state == "pricing_mode": + choice = update.message.text.strip().lower() + if choice not in {"hard", "light"}: + context.user_data["admin_state"] = "pricing_mode" + await update.message.reply_text("Пожалуйста, введите hard или light.") + return STATE_ADMIN + store.set_pricing_mode(choice) + await update.message.reply_text(f"Режим установлен: {choice}") + return await show_admin_menu(update, context) + await update.message.reply_text("Команда не распознана. Используйте кнопки в меню.") + return STATE_ADMIN + +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + query = update.callback_query + await query.answer() + data = query.data + log_user_action(update, f"callback:{data}") + if data == "main:root": + return await show_main_menu(update, context) + if data == "main:order" or data == "order:list": + return await show_order_types(update, context) + if data.startswith("order:type:"): + key = data.split(":", maxsplit=2)[2] + return await show_order_details(update, key) + if data.startswith("order:new:"): + key = data.split(":", maxsplit=2)[2] + return await prompt_order_topic(update, context, key) + if data.startswith("order:deadline:"): + days = int(data.split(":", maxsplit=2)[2]) + return await handle_deadline(update, context, days) + if data.startswith("order:upsell:"): + key = data.split(":", maxsplit=2)[2] + if key: + return await toggle_upsell(update, context, key) + if data == "order:upsell": + return await show_upsell_menu(update, context) + if data == "order:summary": + return await show_order_summary_step(update, context) + if data == "order:confirm": + return await confirm_order(update, context) + if data == "order:cancel": + return await cancel_order(update, context) + if data == "main:prices": + return await show_price_list(update, context) + if data.startswith("prices:detail:"): + key = data.split(":", maxsplit=2)[2] + return await show_price_detail(update, key) + if data == "main:calculator": + return await show_calculator(update, context) + if data.startswith("calc:type:"): + key = data.split(":", maxsplit=2)[2] + return await calculator_select_deadline(update, context, key) + if data.startswith("calc:deadline:"): + days = int(data.split(":", maxsplit=2)[2]) + return await calculator_select_complexity(update, context, days) + if data.startswith("calc:complexity:"): + complexity = float(data.split(":", maxsplit=2)[2]) + return await show_calculation_result(update, context, complexity) + if data == "main:profile" or data == "profile:back": + return await show_profile(update, context) + if data == "profile:orders": + return await show_user_orders(update, context) + if data == "profile:feedback": + return await request_feedback(update, context) + if data == "main:faq": + return await show_faq(update, context) + if data.startswith("faq:item:"): + idx = int(data.split(":", maxsplit=2)[2]) + return await show_faq_item(update, idx) + if data == "admin:menu": + return await show_admin_menu(update, context) + if data == "admin:stats": + return await admin_show_stats(update) + if data == "admin:orders": + return await admin_show_orders(update) + if data == "admin:pricing": + return await admin_request_pricing_mode(update, context) + if data == "admin:export": + return await admin_export(update, context) + if data == "admin:logs": + return await admin_show_logs(update) + await query.edit_message_text("Команда не распознана. Возвращаюсь в меню.") + return await show_main_menu(update, context) + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + log_user_action(update, "start") + user = update.effective_user + context.user_data.pop("order_draft", None) + if context.args: + payload = context.args[0] + if payload.isdigit(): + referrer_id = int(payload) + if referrer_id != user.id and store.add_referral(referrer_id, user.id): + if ADMIN_CHAT_ID: + try: + await context.bot.send_message( + ADMIN_CHAT_ID, + f"Новый реферал: {user.id} (пригласил {referrer_id})", + ) + except Exception as exc: # pragma: no cover + logger.error("Failed to notify admin about referral: %s", exc) + greeting = ( + f"👋 Привет, {html.escape(user.first_name or user.full_name or 'друг')}!\n\n" + "Я помогу оформить любую учебную работу: от самостоятельной до магистерской." + ) + if update.message: + await update.message.reply_text(greeting) + return await show_main_menu(update, context, "Готовы сделать заказ?") + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + text = ( + "Используйте /start для перехода к главному меню.\n" + "Кнопка '📝 Сделать заказ' запустит пошаговое оформление.\n" + "Команда /cancel завершит текущий процесс." + ) + await update.message.reply_text(text) + + +async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + return await cancel_order(update, context) + + +async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: + logger.exception("Unhandled error: %s", context.error) + if ADMIN_CHAT_ID: + try: + await context.bot.send_message(ADMIN_CHAT_ID, f"Ошибка в боте: {context.error}") + except Exception: # pragma: no cover + pass + + +def build_application(): + application = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() + conv_handler = ConversationHandler( + entry_points=[CommandHandler("start", start)], + states={ + STATE_NAVIGATION: [CallbackQueryHandler(handle_callback)], + STATE_ORDER_TOPIC: [ + CallbackQueryHandler(handle_callback), + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_topic), + ], + STATE_ORDER_REQUIREMENTS: [ + CallbackQueryHandler(handle_callback), + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_requirements), + CommandHandler("skip", skip_requirements), + ], + STATE_FEEDBACK: [ + CallbackQueryHandler(handle_callback), + MessageHandler(filters.TEXT & ~filters.COMMAND, receive_feedback), + ], + STATE_ADMIN: [ + CallbackQueryHandler(handle_callback), + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_admin_text), + ], + }, + fallbacks=[CommandHandler("cancel", cancel_command), CommandHandler("start", start)], + allow_reentry=True, + ) + application.add_handler(conv_handler) + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler("admin", admin_command)) + application.add_error_handler(error_handler) + return application + + +def main() -> None: + application = build_application() + application.run_polling(drop_pending_updates=True) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 6f138ad..74e55a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -python-dotenv==1.0.1 -python-telegram-bot==21.4 -pandas==2.2.3 -openpyxl==3.1.5 \ No newline at end of file +python-dotenv==1.0.1 +python-telegram-bot==21.4 +pandas==2.2.3 +openpyxl==3.1.5 From d9b8b2d67aaec6fdee1c9c9b0fb52cf91f3a6a60 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 02:14:27 +0300 Subject: [PATCH 2/9] Add deployment scripts and cleanup repository --- .env | 2 - .env.example | 3 + .gitignore | 14 + README.md | 71 +- bot1.py | 1908 ---------------------------------------- deploy/supervisor.conf | 12 + orders.json | 18 - orders.xlsx | Bin 5318 -> 0 bytes prices.json | 17 - scripts/install.sh | 16 + scripts/run.sh | 11 + user_logs.json | 1059 ---------------------- 12 files changed, 118 insertions(+), 3013 deletions(-) delete mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore delete mode 100644 bot1.py create mode 100644 deploy/supervisor.conf delete mode 100644 orders.json delete mode 100644 orders.xlsx delete mode 100644 prices.json create mode 100755 scripts/install.sh create mode 100755 scripts/run.sh delete mode 100644 user_logs.json diff --git a/.env b/.env deleted file mode 100644 index 9e8058a..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -TELEGRAM_BOT_TOKEN=7387413773:AAFgKfuf-REN5cn1ZXmCQgHHPaVJhY8RLZw -ADMIN_CHAT_ID=872379852 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ac06c5 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Скопируйте этот файл в .env и заполните реальными данными. +TELEGRAM_BOT_TOKEN=0000000000:REPLACE_ME +ADMIN_CHAT_ID=123456789 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66bba8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] +*.log +.env +venv/ +data/ +logs/ +clients/ +*.sqlite3 +orders.json +orders.xlsx +user_logs.json +prices.json +*.pyc diff --git a/README.md b/README.md index 6319852..8508bb5 100644 --- a/README.md +++ b/README.md @@ -8,29 +8,82 @@ - раздел FAQ и сбор отзывов; - админ-панель с экспортом заказов, статистикой и переключением режима ценообразования. -## Быстрый старт +## Быстрый старт (локально) -1. Установите зависимости: +1. Скопируйте `.env.example` в `.env` и укажите реальные значения: ```bash - pip install -r requirements.txt + cp .env.example .env + # отредактируйте файл и впишите TELEGRAM_BOT_TOKEN и ADMIN_CHAT_ID ``` -2. Создайте файл `.env` рядом с `bot.py` и укажите переменные: +2. Установите зависимости через подготовленный скрипт (создает виртуальное окружение `venv/`): - ```env - TELEGRAM_BOT_TOKEN=ваш_токен_бота - ADMIN_CHAT_ID=ваш_telegram_id + ```bash + ./scripts/install.sh ``` -3. Запустите бота: +3. Запустите бота из виртуального окружения: ```bash - python bot.py + ./scripts/run.sh ``` Бот автоматически создаст служебные директории `data/`, `logs/` и `clients/`, сохранит данные о ценах, заказах и логах в JSON и будет писать технические сообщения в `logs/bot.log`. +## Что загрузить на сервер + +Передача файлов через SFTP/FTP (например, FileZilla) сводится к загрузке директории проекта. Минимальный набор: + +- `bot.py` — основной код бота; +- `requirements.txt` — список зависимостей; +- `scripts/install.sh` и `scripts/run.sh` — автоматическая установка и запуск в виртуальном окружении; +- `deploy/supervisor.conf` — конфигурация службы `supervisor` для автозапуска; +- `.env` (на основе `.env.example`) — секреты и параметры бота. + +Дополнительно можно загрузить `README.md`, чтобы под рукой была инструкция. + +## Настройка сервера через SSH + +После загрузки файлов подключитесь по SSH и выполните: + +```bash +cd /root/gipsr_bot +chmod +x scripts/install.sh scripts/run.sh +./scripts/install.sh +``` + +Скрипт создаст окружение `venv/` и установит зависимости. При необходимости повторите запуск после обновления `requirements.txt`. + +## Настройка Supervisor + +1. Скопируйте конфиг в систему (обновите `directory` и `command`, если путь отличается): + + ```bash + sudo cp /root/gipsr_bot/deploy/supervisor.conf /etc/supervisor/conf.d/gipsr_bot.conf + ``` + +2. Примените конфигурацию и проверьте статус: + + ```bash + sudo supervisorctl reread + sudo supervisorctl update + sudo supervisorctl status gipsr_bot + ``` + +Логи `supervisor` сохраняются в `logs/supervisor.out.log` и `logs/supervisor.err.log`, а рабочие сообщения бота — в `logs/bot.log`. + +## Резервный запуск вручную + +Для диагностики можно запускать бота напрямую: + +```bash +source venv/bin/activate +python bot.py +``` + +Выход из режима — `Ctrl+C`. + ## Основные сценарии - `/start` — приветствие и переход в главное меню. diff --git a/bot1.py b/bot1.py deleted file mode 100644 index 85c1a8a..0000000 --- a/bot1.py +++ /dev/null @@ -1,1908 +0,0 @@ -import os -import sys -import logging -import json -from datetime import datetime, timedelta -from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup -from telegram.ext import ( - ApplicationBuilder, CommandHandler, ContextTypes, - CallbackQueryHandler, MessageHandler, filters, ConversationHandler -) -from telegram.constants import ParseMode -from dotenv import load_dotenv -import re - -# Загрузка переменных окружения -load_dotenv() - -TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN') -ADMIN_CHAT_ID = os.getenv('ADMIN_CHAT_ID') - -# Проверка обязательных переменных окружения -if not TELEGRAM_BOT_TOKEN: - print("ОШИБКА: Не найден TELEGRAM_BOT_TOKEN в переменных окружения!") - print("Создайте файл .env и добавьте в него:") - print("TELEGRAM_BOT_TOKEN=ваш_токен_бота") - print("ADMIN_CHAT_ID=ваш_telegram_id") - sys.exit(1) - -if not ADMIN_CHAT_ID: - print("ОШИБКА: Не найден ADMIN_CHAT_ID в переменных окружения!") - print("Добавьте в файл .env строку:") - print("ADMIN_CHAT_ID=ваш_telegram_id") - sys.exit(1) - -try: - ADMIN_CHAT_ID = int(ADMIN_CHAT_ID) -except ValueError: - print(f"ОШИБКА: ADMIN_CHAT_ID должен быть числом, получено: {ADMIN_CHAT_ID}") - sys.exit(1) - -# Настройка базовых директорий -# Используем относительные пути от текущей директории -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -BASE_DIR = os.path.join(SCRIPT_DIR, "gipsr_bot", "clients") -DATA_DIR = os.path.join(SCRIPT_DIR, "gipsr_bot", "data") -LOGS_DIR = os.path.join(SCRIPT_DIR, "gipsr_bot", "logs") -ASSETS_DIR = os.path.join(SCRIPT_DIR, "gipsr_bot", "assets") - -# Настройка логирования -logging.basicConfig( - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level=logging.INFO -) -logger = logging.getLogger(__name__) - -# Создание необходимых директорий -for directory in [BASE_DIR, os.path.join(BASE_DIR, 'feedbacks'), DATA_DIR, LOGS_DIR, ASSETS_DIR]: - try: - os.makedirs(directory, exist_ok=True) - logger.info(f"Создана директория: {directory}") - except Exception as e: - logger.error(f"Ошибка при создании директории {directory}: {e}") - -# Логирование в файл -file_handler = logging.FileHandler(os.path.join(LOGS_DIR, 'bot.log')) -file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) -logger.addHandler(file_handler) - -# Файлы для хранения данных -PRICES_FILE = os.path.join(DATA_DIR, 'prices.json') -REFERRALS_FILE = os.path.join(DATA_DIR, 'referrals.json') -ORDERS_FILE = os.path.join(DATA_DIR, 'orders.json') -FEEDBACKS_FILE = os.path.join(DATA_DIR, 'feedbacks.json') - -# Типы заказов -ORDER_TYPES = { - 'self': { - 'name': 'Самостоятельная работа', - 'icon': '📝', - 'description': 'Идеально для небольших заданий, эссе, контрольных работ. Выполняется быстро и качественно.', - 'details': 'Самостоятельные работы включают небольшие эссе, задания по предметам, тесты и контрольные. Обычно объемом до 20 страниц.', - 'examples': ['Эссе по философии', 'Контрольная по экономике', 'Реферат по истории'] - }, - 'course_theory': { - 'name': 'Курсовая работа (теоретическая)', - 'icon': '📘', - 'description': 'Теоретическая курсовая работа с глубоким анализом литературы и источников.', - 'details': 'Теоретическая курсовая включает анализ научной литературы, построение теоретических моделей без эмпирической части.', - 'examples': ['Анализ теоретических подходов к социальной работе', 'Обзор методологий исследования', 'Теоретические основы психологии развития'] - }, - 'course_empirical': { - 'name': 'Курсовая работа (теория + эмпирика)', - 'icon': '📊', - 'description': 'Курсовая с эмпирической частью, анализом данных и практическими рекомендациями.', - 'details': 'Эмпирическая курсовая работа включает как теоретическую часть, так и практическое исследование с анализом полученных данных.', - 'examples': ['Исследование социальной адаптации мигрантов', 'Анализ эффективности методов психотерапии', 'Опрос по уровню удовлетворенности клиентов'] - }, - 'vkr': { - 'name': 'ВКР', - 'icon': '🎓', - 'description': 'Выпускная квалификационная работа для завершения обучения с полным циклом исследования.', - 'details': 'ВКР - это итоговая научная работа для получения диплома бакалавра. Включает глубокий теоретический анализ и объемное эмпирическое исследование.', - 'examples': ['Комплексное исследование социально-психологической адаптации', 'Проектирование системы социальной поддержки', 'Анализ эффективности социальных программ'] - }, - 'master': { - 'name': 'Магистерская диссертация', - 'icon': '🔍', - 'description': 'Глубокое научное исследование для магистратуры с инновационным подходом.', - 'details': 'Магистерская диссертация - это научное исследование высокого уровня, демонстрирующее способность к самостоятельной научной работе.', - 'examples': ['Разработка методологии оценки эффективности социальных программ', 'Комплексное исследование психологических аспектов', 'Проектирование инновационных методов работы'] - } -} - -# Режимы ценообразования -PRICING_MODES = { - 'hard': { - 'name': 'Hard Mode', - 'icon': '💲', - 'description': '<7 дней: +30%, 8-14 дней: +15%, >14 дней: базовая цена.' - }, - 'light': { - 'name': 'Light Mode', - 'icon': '💰', - 'description': '<3 дней: +30%, >=7 дней: базовая цена.' - } -} - -# FAQ -FAQ_ITEMS = [ - { - 'question': 'Как сделать заказ?', - 'answer': 'Для заказа работы выберите пункт "📝 Сделать заказ" в главном меню. Затем следуйте инструкциям: выберите тип работы, введите тему, укажите дедлайн и дополнительные требования. После подтверждения заказа с вами свяжется менеджер для уточнения деталей.' - }, - { - 'question': 'Как рассчитывается стоимость?', - 'answer': 'Стоимость зависит от трех основных факторов: тип работы, срочность выполнения и сложность темы. Базовые цены указаны в прайс-листе. При срочном выполнении (менее 7 дней) применяется наценка 15-30%. Для получения точной стоимости воспользуйтесь калькулятором цен в разделе "Прайс-лист".' - }, - { - 'question': 'Как работает реферальная программа?', - 'answer': 'В разделе "Мой профиль" вы найдете вашу персональную реферальную ссылку. Поделитесь ею с друзьями, и когда они закажут работу по вашей ссылке, вы получите бонус в размере 5% от стоимости их заказа. Бонусы можно использовать для оплаты своих заказов.' - }, - { - 'question': 'Какие гарантии качества работы?', - 'answer': 'Мы гарантируем высокое качество всех работ. Каждая работа проходит проверку на плагиат и соответствие требованиям. После выполнения работы предоставляется бесплатное внесение правок в течение 14 дней. В случае необходимости мы обеспечиваем полную поддержку до успешной защиты работы.' - }, - { - 'question': 'Могу ли я получить скидку?', - 'answer': 'Да, у нас действует система скидок: 5% при заказе от 10 000 руб., 10% при заказе от 20 000 руб. Постоянные клиенты получают персональные скидки до 15%. Также действуют сезонные акции и специальные предложения, о которых вы можете узнать у менеджера.' - }, - { - 'question': 'Как отслеживать процесс выполнения заказа?', - 'answer': 'В разделе "Мой профиль" вы можете видеть статус всех ваших заказов. Также наш менеджер будет регулярно информировать вас о ходе выполнения работы. Вы всегда можете запросить промежуточные результаты для оценки работы.' - } -] - -# Глобальные переменные -current_pricing_mode = 'light' -user_orders = {} -user_ids = set() -user_feedbacks = {} - -# Состояния для ConversationHandler -( - START, - SELECT_MAIN_MENU, - SELECT_ORDER_TYPE, - VIEW_ORDER_DETAILS, - INPUT_TOPIC, - INPUT_DEADLINE, - INPUT_REQUIREMENTS, - CALCULATE_PRICE, - CONFIRM_ORDER, - ADMIN_MENU, - PROFILE_MENU, - SHOW_PRICE_LIST, - PRICE_CALCULATOR, - SHOW_FAQ, - FAQ_DETAILS, - SHOW_ORDERS, - LEAVE_FEEDBACK, - INPUT_FEEDBACK -) = range(18) - -# Функции для работы с ценами -def load_prices(): - try: - if os.path.exists(PRICES_FILE): - with open(PRICES_FILE, 'r', encoding='utf-8') as f: - return json.load(f) - default_prices = { - 'self': {'base': 1500, 'min': 1000, 'max': 3000}, - 'course_theory': {'base': 7000, 'min': 5000, 'max': 10000}, - 'course_empirical': {'base': 11000, 'min': 8000, 'max': 15000}, - 'vkr': {'base': 32000, 'min': 25000, 'max': 45000}, - 'master': {'base': 42000, 'min': 35000, 'max': 60000} - } - save_prices(default_prices) - return default_prices - except Exception as e: - logger.error(f"Ошибка при загрузке цен: {e}") - return { - 'self': {'base': 1500, 'min': 1000, 'max': 3000}, - 'course_theory': {'base': 7000, 'min': 5000, 'max': 10000}, - 'course_empirical': {'base': 11000, 'min': 8000, 'max': 15000}, - 'vkr': {'base': 32000, 'min': 25000, 'max': 45000}, - 'master': {'base': 42000, 'min': 35000, 'max': 60000} - } - -def save_prices(prices): - try: - with open(PRICES_FILE, 'w', encoding='utf-8') as f: - json.dump(prices, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка при сохранении цен: {e}") - -PRICES = load_prices() - -# Функции для работы с рефералами -def load_referrals(): - try: - if os.path.exists(REFERRALS_FILE): - with open(REFERRALS_FILE, 'r', encoding='utf-8') as f: - return json.load(f) - return {} - except Exception as e: - logger.error(f"Ошибка при загрузке рефералов: {e}") - return {} - -def save_referrals(data): - try: - with open(REFERRALS_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка при сохранении рефералов: {e}") - -referrals = load_referrals() - -# Функции для работы с заказами -def load_orders(): - try: - if os.path.exists(ORDERS_FILE): - with open(ORDERS_FILE, 'r', encoding='utf-8') as f: - return json.load(f) - return {} - except Exception as e: - logger.error(f"Ошибка при загрузке заказов: {e}") - return {} - -def save_orders(data): - try: - with open(ORDERS_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка при сохранении заказов: {e}") - -# Функции для работы с отзывами -def load_feedbacks(): - try: - if os.path.exists(FEEDBACKS_FILE): - with open(FEEDBACKS_FILE, 'r', encoding='utf-8') as f: - return json.load(f) - return {} - except Exception as e: - logger.error(f"Ошибка при загрузке отзывов: {e}") - return {} - -def save_feedbacks(data): - try: - with open(FEEDBACKS_FILE, 'w', encoding='utf-8') as f: - json.dump(data, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка при сохранении отзывов: {e}") - -user_feedbacks = load_feedbacks() - -# Расчет цены -def calculate_price(order_type_key, days_left, complexity_factor=1.0): - try: - prices = PRICES.get(order_type_key, {'base': 0}) - base_price = prices.get('base', 0) - base_price = int(base_price * complexity_factor) - - if current_pricing_mode == 'hard': - if days_left <= 7: - return int(base_price * 1.3) - elif 8 <= days_left <= 14: - return int(base_price * 1.15) - else: - return base_price - else: - if days_left <= 3: - return int(base_price * 1.3) - elif 4 <= days_left <= 6: - return int(base_price * 1.15) - else: - return base_price - except Exception as e: - logger.error(f"Ошибка при расчете цены: {e}") - return PRICES.get(order_type_key, {'base': 0}).get('base', 0) - -# Стилизованное сообщение -def generate_styled_message(title, content, footer=None): - message = f"*{title}*\n\n{content}" - if footer: - message += f"\n\n_{footer}_" - return message - -# Обработчик ошибок -async def error_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - logger.error(f"Ошибка при обработке обновления: {context.error}") - try: - error_message = f"⚠️ *Ошибка в боте*\n\nError: {context.error}\n" - if update and update.effective_user: - error_message += f"User: @{update.effective_user.username} (ID: {update.effective_user.id})\n" - if update and update.effective_message: - error_message += f"Message: {update.effective_message.text[:50]}...\n" - - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=error_message, - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Не удалось отправить сообщение об ошибке админу: {e}") - - try: - if update and update.effective_chat: - await context.bot.send_message( - chat_id=update.effective_chat.id, - text="Извините, произошла ошибка. Попробуйте снова или используйте /start." - ) - except: - pass - -# Команда /start -async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): - user = update.effective_user - user_ids.add(user.id) - - # Отправляем уведомление админу о новом пользователе - try: - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=f"👤 *Новый пользователь в боте*\n\n" - f"Имя: {user.first_name} {user.last_name or ''}\n" - f"Username: @{user.username or 'отсутствует'}\n" - f"ID: `{user.id}`\n" - f"Время: {datetime.now().strftime('%d.%m.%Y %H:%M')}", - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Ошибка уведомления админа о новом пользователе: {e}") - - text = update.message.text - args = text.split() - - try: - bot = await context.bot.get_me() - bot_username = bot.username - except Exception as e: - logger.error(f"Ошибка получения данных бота: {e}") - bot_username = "Kladovaya_GIPSR_bot" - - if len(args) > 1 and args[1].isdigit(): - referrer_id = int(args[1]) - if referrer_id != user.id: - referrals.setdefault(str(referrer_id), []) - if user.id not in referrals[str(referrer_id)]: - referrals[str(referrer_id)].append(user.id) - save_referrals(referrals) - try: - await context.bot.send_message( - chat_id=referrer_id, - text=f"🎉 Ваш реферал {user.first_name} (@{user.username or 'без имени'}) присоединился!" - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления рефереру: {e}") - - ref_link = f"https://t.me/{bot_username}?start={user.id}" - context.user_data['ref_link'] = ref_link - - welcome_message = ( - f"🎓 *Добро пожаловать в Кладовую ГИПСР, {user.first_name}!*\n\n" - f"✨ *Что мы предлагаем:*\n" - f"• Качественные академические работы любой сложности\n" - f"• Гарантия уникальности от 75%\n" - f"• Бесплатные правки в течение 14 дней\n" - f"• Поддержка до успешной защиты\n" - f"• Конфиденциальность и безопасность\n\n" - f"💡 *Как сделать заказ:*\n" - f"1️⃣ Нажмите «Сделать заказ»\n" - f"2️⃣ Выберите тип работы\n" - f"3️⃣ Укажите тему и срок\n" - f"4️⃣ Получите точную стоимость\n" - f"5️⃣ Менеджер свяжется с вами\n\n" - f"🎁 *Бонусы:*\n" - f"• Скидка 10% на первый заказ\n" - f"• Реферальная программа - 5% от заказов друзей\n" - f"• Накопительные скидки для постоянных клиентов\n\n" - f"📱 *Выберите действие:*" - ) - return await main_menu(update, context, welcome_message) - -# Команда /help -async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - text = ( - "📋 *Основные команды:*\n\n" - "/start - Главное меню\n" - "/help - Список команд\n" - "/order - Новый заказ\n" - "/profile - Личный кабинет\n" - "/price - Прайс-лист\n" - "/faq - Часто задаваемые вопросы\n" - "/admin - Панель администратора\n\n" - "Свяжитесь с менеджером через главное меню при необходимости." - ) - await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN) - -# Команда /price -async def price_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - user_id = str(update.effective_user.id) - orders_count = len(user_orders.get(user_id, [])) - discount = 0 - if orders_count >= 5: - discount = 15 - elif orders_count >= 3: - discount = 10 - elif orders_count >= 1: - discount = 5 - - text = "🎓 *ПРАЙС-ЛИСТ КЛАДОВОЙ ГИПСР*\n\n" - - if discount > 0: - text += f"🎉 *Ваша персональная скидка: {discount}%*\n\n" - - text += "📍 *Актуальные цены на 2024 год:*\n\n" - - for key, val in PRICES.items(): - order_type = ORDER_TYPES.get(key, {}) - base_price = val.get('base', 0) - if discount > 0: - discounted_price = int(base_price * (1 - discount/100)) - text += f"{order_type.get('icon', '')} *{order_type.get('name', key)}*\n" - text += f" ├ ~{base_price:,}~ *{discounted_price:,} руб.*\n" - text += f" └ 🎯 Срок: от 3 дней\n\n" - else: - text += f"{order_type.get('icon', '')} *{order_type.get('name', key)}*\n" - text += f" ├ 💰 *{base_price:,} руб.*\n" - text += f" └ 🎯 Срок: от 3 дней\n\n" - - text += "🎁 *Специальные предложения:*\n" - text += "• Скидка 10% на первый заказ\n" - text += "• Приведи друга - получи 500₽ бонус\n" - text += "• Заказ от 2 работ = скидка 15%\n" - - keyboard = [ - [InlineKeyboardButton("🧮 Рассчитать точную цену", callback_data='price_calculator')], - [InlineKeyboardButton("🎯 Быстрый заказ", callback_data='make_order')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_PRICE_LIST - -# Команда /faq -async def faq_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - text = "❓ *Часто задаваемые вопросы*\n\nВыберите вопрос:\n" - keyboard = [[InlineKeyboardButton(f"{idx+1}. {item['question']}", callback_data=f'faq_{idx}')] for idx, item in enumerate(FAQ_ITEMS)] - keyboard.append([InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')]) - await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_FAQ - -# Команда /order -async def order_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - return await select_order_type(update, context) - -# Команда /profile -async def profile_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - user = update.effective_user - orders = user_orders.get(str(user.id), []) or load_orders().get(str(user.id), []) - - try: - bot = await context.bot.get_me() - bot_username = bot.username - except Exception as e: - logger.error(f"Ошибка получения данных бота: {e}") - bot_username = "Kladovaya_GIPSR_bot" - - ref_link = f"https://t.me/{bot_username}?start={user.id}" - context.user_data['ref_link'] = ref_link - - ref_count = len(referrals.get(str(user.id), [])) - bonus = sum(int(order.get('price', 0) * 0.05) for ref_id in referrals.get(str(user.id), []) for order in user_orders.get(str(ref_id), [])) - - text = ( - f"👤 *Личный кабинет*\n\n" - f"*Имя:* {user.first_name}\n" - f"*Username:* @{user.username if user.username else 'отсутствует'}\n" - f"*ID:* `{user.id}`\n\n" - f"*Реферальная программа:*\n" - f"- Приглашено: {ref_count}\n" - f"- Бонус: {bonus} руб.\n" - f"- Ссылка: `{ref_link}`\n\n" - ) - - if orders: - text += "*Ваши заказы:*\n" - recent_orders = sorted(orders, key=lambda x: x.get('date', ''), reverse=True)[:3] - for o in recent_orders: - text += f"- Заказ #{o.get('order_id', 'N/A')}: {o.get('type')} | Статус: {o.get('status')}\n" - if len(orders) > 3: - text += f"\n_...и еще {len(orders) - 3} заказов_\n" - else: - text += "У вас пока нет заказов." - - keyboard = [ - [InlineKeyboardButton("📋 Все заказы", callback_data='show_all_orders')], - [InlineKeyboardButton("📝 Новый заказ", callback_data='make_order')], - [InlineKeyboardButton("✍️ Оставить отзыв", callback_data='leave_feedback')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - - await update.message.reply_text( - text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return PROFILE_MENU - -# Главное меню -async def main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, custom_message=None): - try: - user = update.effective_user - keyboard = [ - [InlineKeyboardButton("📝 Сделать заказ", callback_data='make_order')], - [InlineKeyboardButton("💲 Прайс-лист", callback_data='price_list'), - InlineKeyboardButton("👤 Мой профиль", callback_data='profile')], - [InlineKeyboardButton("❓ FAQ", callback_data='faq')], - [InlineKeyboardButton("📞 Администратор", url='https://t.me/Thisissaymoon')] - ] - reply_markup = InlineKeyboardMarkup(keyboard) - text = custom_message or f"👋 *Привет, {user.first_name}!*\n\nВыберите раздел:" - - if update.callback_query: - await update.callback_query.message.edit_text(text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN) - else: - await update.message.reply_text(text, reply_markup=reply_markup, parse_mode=ParseMode.MARKDOWN) - return SELECT_MAIN_MENU - except Exception as e: - logger.error(f"Ошибка в main_menu: {e}") - return SELECT_MAIN_MENU - -# Обработчик главного меню -async def main_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - choice = query.data - - if choice == 'make_order': - return await select_order_type(update, context) - elif choice == 'price_list': - return await show_price_list(update, context) - elif choice == 'profile': - return await show_profile(update, context) - elif choice == 'faq': - return await show_faq(update, context) - elif choice == 'back_to_main': - return await main_menu(update, context) - - await query.message.reply_text("Неизвестный выбор.") - return SELECT_MAIN_MENU - -# Прайс-лист -async def show_price_list(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - - # Подсчет скидки для постоянного клиента - user_id = str(update.effective_user.id) - orders_count = len(user_orders.get(user_id, [])) - discount = 0 - if orders_count >= 5: - discount = 15 - elif orders_count >= 3: - discount = 10 - elif orders_count >= 1: - discount = 5 - - text = "🎓 *ПРАЙС-ЛИСТ КЛАДОВОЙ ГИПСР*\n\n" - - if discount > 0: - text += f"🎉 *Ваша персональная скидка: {discount}%*\n\n" - - text += "📍 *Актуальные цены на 2024 год:*\n\n" - - for key, val in PRICES.items(): - order_type = ORDER_TYPES.get(key, {}) - base_price = val.get('base', 0) - if discount > 0: - discounted_price = int(base_price * (1 - discount/100)) - text += f"{order_type.get('icon', '')} *{order_type.get('name', key)}*\n" - text += f" ├ ~{base_price:,}~ *{discounted_price:,} руб.*\n" - text += f" └ 🎯 Срок: от 3 дней\n\n" - else: - text += f"{order_type.get('icon', '')} *{order_type.get('name', key)}*\n" - text += f" ├ 💰 *{base_price:,} руб.*\n" - text += f" └ 🎯 Срок: от 3 дней\n\n" - - text += "🎁 *Специальные предложения:*\n" - text += "• Скидка 10% на первый заказ\n" - text += "• Приведи друга - получи 500₽ бонус\n" - text += "• Заказ от 2 работ = скидка 15%\n\n" - text += "🔥 *Почему выбирают нас:*\n" - text += "• 100% гарантия сдачи\n" - text += "• Бесплатная доработка\n" - text += "• Антиплагиат от 75%\n" - - keyboard = [ - [InlineKeyboardButton("🧮 Рассчитать точную цену", callback_data='price_calculator')], - [InlineKeyboardButton("🎯 Быстрый заказ", callback_data='make_order')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_PRICE_LIST - -# Калькулятор стоимости -async def price_calculator(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - - # Отправляем уведомление админу - user = update.effective_user - try: - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=f"🧮 *Пользователь открыл калькулятор*\n\n" - f"Имя: {user.first_name}\n" - f"Username: @{user.username or 'отсутствует'}\n" - f"ID: `{user.id}`\n" - f"Время: {datetime.now().strftime('%H:%M')}", - parse_mode=ParseMode.MARKDOWN - ) - except: - pass - - text = ( - "🎆 *ИНТЕРАКТИВНЫЙ КАЛЬКУЛЯТОР ЦЕН*\n\n" - "🎁 *СПЕЦИАЛЬНОЕ ПРЕДЛОЖЕНИЕ!*\n" - "При заказе сегодня - скидка 10%!\n\n" - "👇 *Шаг 1: Выберите тип работы*\n\n" - "Какую работу вы хотите заказать?" - ) - - keyboard = [] - for key, val in ORDER_TYPES.items(): - price = PRICES[key]['base'] - # Показываем цену прямо в кнопке - button_text = f"{val['icon']} {val['name']} | {price:,}₽" - keyboard.append([InlineKeyboardButton(button_text, callback_data=f'calc_{key}')]) - - keyboard.append([InlineKeyboardButton("🎁 Пакетное предложение (2+ работы)", callback_data='calc_package')]) - keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='back_to_price')]) - - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return PRICE_CALCULATOR - -# Расчет цены в калькуляторе -async def calculate_price_in_calculator(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - - if query.data == 'calc_package': - # Пакетное предложение - text = ( - "🎁 *ПАКЕТНОЕ ПРЕДЛОЖЕНИЕ*\n\n" - "🔥 *Закажите 2+ работ и получите:*\n\n" - "✅ Скидка 15% на все работы\n" - "✅ Приоритетное выполнение\n" - "✅ Один менеджер на весь заказ\n\n" - "💰 *Примеры экономии:*\n\n" - "📚 Курсовая + Самостоятельная:\n" - "~12,500₽~ → *10,625₽* (экономия 1,875₽)\n\n" - "🎓 Курсовая + ВКР:\n" - "~43,000₽~ → *36,550₽* (экономия 6,450₽)\n\n" - "🎆 Полный комплект (все 5 типов):\n" - "~93,500₽~ → *79,475₽* (экономия 14,025₽!)\n\n" - "📞 *Оформить пакет?*" - ) - keyboard = [ - [InlineKeyboardButton("🚀 Оформить пакет", callback_data='make_order')], - [InlineKeyboardButton("🔄 К выбору работ", callback_data='price_calculator')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_price')] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return PRICE_CALCULATOR - - order_type_key = query.data.replace('calc_', '') - if order_type_key not in ORDER_TYPES: - await query.message.reply_text("Неизвестный тип работы.") - return PRICE_CALCULATOR - - order_type_info = ORDER_TYPES[order_type_key] - price_info = PRICES[order_type_key] - context.user_data['calc_type'] = order_type_key - - text = ( - f"🎆 *КАЛЬКУЛЯТОР ЦЕН*\n\n" - f"{order_type_info['icon']} *{order_type_info['name']}*\n\n" - f"📍 Базовая цена: *{price_info['base']:,} руб.*\n\n" - f"🕐 *Шаг 2: Выберите срок выполнения*\n\n" - f"Чем больше времени - тем ниже цена:" - ) - - # Создаем кнопки с ценами - keyboard = [] - deadlines = [(3, '🔴 Срочно (3 дня)'), - (7, '🟡 Быстро (7 дней)'), - (14, '🟢 Оптимально (14 дней)'), - (30, '🔵 Комфортно (30 дней)')] - - for days, label in deadlines: - price = calculate_price(order_type_key, days) - if days <= 7: - price_text = f"{label} | {price:,}₽ (+{int((price/price_info['base']-1)*100)}%)" - else: - price_text = f"{label} | {price:,}₽" - keyboard.append([InlineKeyboardButton(price_text, callback_data=f'deadline_{order_type_key}_{days}')]) - - keyboard.append([InlineKeyboardButton("🔄 Другая работа", callback_data='price_calculator')]) - keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='back_to_price')]) - - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return PRICE_CALCULATOR - -# Обработчик выбора срока в калькуляторе -async def handle_deadline_selection(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - - parts = query.data.split('_') - order_type_key = parts[1] - days = int(parts[2]) - - order_type_info = ORDER_TYPES[order_type_key] - base_price = PRICES[order_type_key]['base'] - final_price = calculate_price(order_type_key, days) - - # Проверяем скидки пользователя - user_id = str(update.effective_user.id) - orders_count = len(user_orders.get(user_id, [])) - loyalty_discount = 0 - if orders_count >= 5: - loyalty_discount = 15 - elif orders_count >= 3: - loyalty_discount = 10 - elif orders_count >= 1: - loyalty_discount = 5 - - # Скидка для первого заказа - if orders_count == 0: - loyalty_discount = 10 - discount_text = "Скидка новому клиенту" - else: - discount_text = f"Ваша постоянная скидка" - - final_price_with_discount = int(final_price * (1 - loyalty_discount/100)) - - text = ( - f"🎯 *ИТОГОВЫЙ РАСЧЕТ*\n\n" - f"{order_type_info['icon']} *{order_type_info['name']}*\n" - f"📅 Срок: {days} дней\n\n" - f"💰 *Стоимость:*\n" - f"Базовая цена: {base_price:,} руб.\n" - ) - - if days <= 7: - urgency_percent = int((final_price/base_price - 1) * 100) - text += f"Срочность (+{urgency_percent}%): {final_price - base_price:,} руб.\n" - - text += f"─────────────────\n" - text += f"Итого: {final_price:,} руб.\n\n" - - if loyalty_discount > 0: - text += f"🎁 *{discount_text}: {loyalty_discount}%*\n" - text += f"🔥 *ФИНАЛЬНАЯ ЦЕНА: {final_price_with_discount:,} руб.*\n" - text += f"💵 Вы экономите: {final_price - final_price_with_discount:,} руб.\n\n" - - text += ( - "✅ *Что входит в стоимость:*\n" - "• Полное выполнение работы\n" - "• Антиплагиат от 75%\n" - "• Оформление по ГОСТ\n" - "• Бесплатные правки 14 дней\n" - "• Поддержка до защиты\n\n" - "🚀 *Готовы заказать?*" - ) - - keyboard = [ - [InlineKeyboardButton("✅ ЗАКАЗАТЬ СЕЙЧАС", callback_data=f'quick_order_{order_type_key}_{days}')], - [InlineKeyboardButton("💬 Обсудить с менеджером", url='https://t.me/Thisissaymoon')], - [InlineKeyboardButton("🔄 Изменить параметры", callback_data='price_calculator')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - # Отправляем уведомление админу - try: - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=f"💸 *Пользователь рассчитал цену*\n\n" - f"Пользователь: {update.effective_user.first_name}\n" - f"Username: @{update.effective_user.username or 'отсутствует'}\n" - f"Работа: {order_type_info['name']}\n" - f"Срок: {days} дней\n" - f"Цена: {final_price_with_discount:,} руб.\n" - f"Время: {datetime.now().strftime('%H:%M')}", - parse_mode=ParseMode.MARKDOWN - ) - except: - pass - - return PRICE_CALCULATOR - -# Возврат к прайс-листу -async def back_to_price(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - return await show_price_list(update, context) - -# FAQ -async def show_faq(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - text = "❓ *Часто задаваемые вопросы*\n\nВыберите вопрос:\n" - keyboard = [[InlineKeyboardButton(f"{idx+1}. {item['question']}", callback_data=f'faq_{idx}')] for idx, item in enumerate(FAQ_ITEMS)] - keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='back_to_main')]) - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_FAQ - -# Детали FAQ -async def show_faq_details(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - faq_idx = int(query.data.replace('faq_', '')) - if faq_idx < 0 or faq_idx >= len(FAQ_ITEMS): - await query.message.reply_text("Неизвестный вопрос.") - return SHOW_FAQ - - faq_item = FAQ_ITEMS[faq_idx] - text = f"❓ *{faq_item['question']}*\n\n{faq_item['answer']}" - keyboard = [ - [InlineKeyboardButton("⬅️ Назад к FAQ", callback_data='back_to_faq')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return FAQ_DETAILS - -# Возврат к FAQ -async def back_to_faq(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - return await show_faq(update, context) - -# Выбор типа заказа -async def select_order_type(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query or update - if query.callback_query: - await query.answer() - - keyboard = [[InlineKeyboardButton(f"{val['icon']} {val['name']}", callback_data=f'type_{key}')] for key, val in ORDER_TYPES.items()] - keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data='back_to_main')]) - text = "📋 *Выберите тип работы:*\n\nНажмите для подробностей и стоимости:" - - if query.callback_query: - await query.message.edit_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.MARKDOWN) - else: - await query.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode=ParseMode.MARKDOWN) - return SELECT_ORDER_TYPE - -# Обработчик выбора типа заказа -async def select_order_type_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - callback_data = query.data - - if callback_data == 'back_to_main': - return await main_menu(update, context) - if callback_data.startswith('type_') or callback_data.startswith('order_'): - order_type_key = callback_data.replace('type_', '').replace('order_', '') - return await view_order_details(update, context, order_type_key) - await query.message.reply_text("Неизвестный тип заказа.") - return SELECT_ORDER_TYPE - -# Детали заказа -async def view_order_details(update: Update, context: ContextTypes.DEFAULT_TYPE, order_type_key): - query = update.callback_query - if order_type_key not in ORDER_TYPES: - await query.message.reply_text("Неизвестный тип заказа.") - return SELECT_ORDER_TYPE - - order_type_info = ORDER_TYPES[order_type_key] - price_info = PRICES[order_type_key] - context.user_data['order_type_key'] = order_type_key - context.user_data['order_type'] = order_type_info['name'] - - text = f"*{order_type_info['icon']} {order_type_info['name']}*\n\n{order_type_info['details']}\n\n" - text += "*Примеры тем:*\n" + "\n".join(f"• {example}" for example in order_type_info['examples']) - text += f"\n\n*Стоимость:* от {price_info['base']} руб.\n*Срок:* от 3 дней\n\nХотите заказать?" - - keyboard = [ - [InlineKeyboardButton("✅ Заказать", callback_data='continue_order')], - [InlineKeyboardButton("⬅️ Другой тип", callback_data='back_to_order_type')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return VIEW_ORDER_DETAILS - -# Обработчик деталей заказа -async def order_details_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - choice = query.data - - if choice == 'back_to_order_type': - return await select_order_type(update, context) - elif choice == 'back_to_main': - return await main_menu(update, context) - elif choice == 'continue_order': - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_order_details')]] - await query.message.edit_text( - f"Вы выбрали: *{context.user_data['order_type']}*\n\nВведите тему работы:", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_TOPIC - await query.message.reply_text("Неизвестный выбор.") - return VIEW_ORDER_DETAILS - -# Назад к деталям заказа -async def back_to_order_details(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - return await view_order_details(update, context, context.user_data['order_type_key']) - -# Ввод темы -async def input_topic(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.callback_query: - query = update.callback_query - await query.answer() - if query.data == 'back_to_order_details': - return await view_order_details(update, context, context.user_data['order_type_key']) - return - - context.user_data['topic'] = update.message.text - keyboard = [ - [InlineKeyboardButton("3 дня", callback_data='3'), InlineKeyboardButton("7 дней", callback_data='7'), InlineKeyboardButton("14 дней", callback_data='14')], - [InlineKeyboardButton("21 день", callback_data='21'), InlineKeyboardButton("30 дней", callback_data='30'), InlineKeyboardButton("Другой", callback_data='custom')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_topic')] - ] - await update.message.reply_text( - "📅 *Выберите срок выполнения:*\n\nЧем больше времени, тем ниже стоимость.", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_DEADLINE - -# Ввод дедлайна -async def input_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - - if query.data == 'back_to_topic': - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_order_details')]] - await query.message.edit_text( - f"Вы выбрали: *{context.user_data['order_type']}*\n\nВведите тему работы:", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_TOPIC - - if query.data == 'custom': - await query.message.edit_text( - "Введите количество дней (число):", - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_deadline_select')]]) - ) - return INPUT_DEADLINE - - if query.data == 'back_to_deadline_select': - keyboard = [ - [InlineKeyboardButton("3 дня", callback_data='3'), InlineKeyboardButton("7 дней", callback_data='7'), InlineKeyboardButton("14 дней", callback_data='14')], - [InlineKeyboardButton("21 день", callback_data='21'), InlineKeyboardButton("30 дней", callback_data='30'), InlineKeyboardButton("Другой", callback_data='custom')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_topic')] - ] - await query.message.edit_text( - "📅 *Выберите срок выполнения:*\n\nЧем больше времени, тем ниже стоимость.", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_DEADLINE - - try: - days = int(query.data) - deadline_date = datetime.now() + timedelta(days=days) - context.user_data['deadline'] = deadline_date - context.user_data['days_left'] = days - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_deadline')]] - await query.message.edit_text( - f"📝 *Требования:*\n\nОпишите требования (объем, структура, источники и т.д.) или напишите 'Нет требований'.", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_REQUIREMENTS - except ValueError: - await query.message.reply_text("Введите число дней.") - return INPUT_DEADLINE - -# Ввод произвольного дедлайна -async def input_custom_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE): - try: - days = int(update.message.text) - if days < 1: - await update.message.reply_text( - "Введите положительное число дней.", - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_deadline_select')]]) - ) - return INPUT_DEADLINE - deadline_date = datetime.now() + timedelta(days=days) - context.user_data['deadline'] = deadline_date - context.user_data['days_left'] = days - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_deadline')]] - await update.message.reply_text( - f"📝 *Требования:*\n\nОпишите требования или напишите 'Нет требований'.", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_REQUIREMENTS - except ValueError: - await update.message.reply_text( - "Введите число дней.", - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_deadline_select')]]) - ) - return INPUT_DEADLINE - -# Назад к дедлайну -async def back_to_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - keyboard = [ - [InlineKeyboardButton("3 дня", callback_data='3'), InlineKeyboardButton("7 дней", callback_data='7'), InlineKeyboardButton("14 дней", callback_data='14')], - [InlineKeyboardButton("21 день", callback_data='21'), InlineKeyboardButton("30 дней", callback_data='30'), InlineKeyboardButton("Другой", callback_data='custom')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_topic')] - ] - await query.message.edit_text( - "📅 *Выберите срок выполнения:*\n\nЧем больше времени, тем ниже стоимость.", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return INPUT_DEADLINE - -# Ввод требований -async def input_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.callback_query: - query = update.callback_query - await query.answer() - if query.data == 'back_to_deadline': - return await back_to_deadline(update, context) - return - - context.user_data['requirements'] = update.message.text - return await calculate_price_step(update, context) - -# Расчет цены -async def calculate_price_step(update, context: ContextTypes.DEFAULT_TYPE): - data = context.user_data - order_type_key = data.get('order_type_key') - days_left = data.get('days_left', 7) - topic = data.get('topic', '') - requirements = data.get('requirements', '') - complexity_factor = 1.0 + 0.05 * (len(topic) > 50) + 0.1 * any(term in topic.lower() or term in requirements.lower() for term in ['анализ', 'исследование', 'сравнительный', 'методология', 'эмпирический']) - - price = calculate_price(order_type_key, days_left, complexity_factor) - data['price'] = price - data['complexity_factor'] = complexity_factor - deadline = data.get('deadline') - deadline_str = deadline.strftime('%d.%m.%Y') if deadline else "Не указан" - - text = ( - f"📋 *Ваш заказ:*\n\n" - f"*Тип работы:* {data.get('order_type')}\n" - f"*Тема:* {data.get('topic')}\n" - f"*Срок:* {deadline_str} ({days_left} дней)\n" - f"*Требования:*\n{data.get('requirements', 'Не указаны')}\n\n" - f"*Стоимость:* {price} руб.\n\n" - f"Подтвердите заказ:" - ) - - keyboard = [ - [InlineKeyboardButton("✅ Подтвердить", callback_data='confirm_order')], - [InlineKeyboardButton("🔄 Изменить", callback_data='change_order_data')], - [InlineKeyboardButton("❌ Отменить", callback_data='cancel_order')] - ] - if update.callback_query: - await update.callback_query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - else: - await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return CALCULATE_PRICE - -# Изменение данных заказа -async def change_order_data(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - keyboard = [ - [InlineKeyboardButton("📝 Тип работы", callback_data='change_type')], - [InlineKeyboardButton("📋 Тема", callback_data='change_topic')], - [InlineKeyboardButton("📅 Срок", callback_data='change_deadline')], - [InlineKeyboardButton("📌 Требования", callback_data='change_requirements')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_price_calc')] - ] - await query.message.edit_text( - "🔄 *Изменение заказа*\n\nВыберите, что изменить:", - parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return CALCULATE_PRICE - -# Обработка изменения данных -async def handle_change_data(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - choice = query.data - - if choice == 'change_type': - return await select_order_type(update, context) - elif choice == 'change_topic': - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_change_menu')]] - await query.message.edit_text("Введите новую тему:", parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return INPUT_TOPIC - elif choice == 'change_deadline': - keyboard = [ - [InlineKeyboardButton("3 дня", callback_data='3'), InlineKeyboardButton("7 дней", callback_data='7'), InlineKeyboardButton("14 дней", callback_data='14')], - [InlineKeyboardButton("21 день", callback_data='21'), InlineKeyboardButton("30 дней", callback_data='30'), InlineKeyboardButton("Другой", callback_data='custom')], - [InlineKeyboardButton("⬅️ Назад", callback_data='back_to_change_menu')] - ] - await query.message.edit_text("📅 *Выберите новый срок:*", parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return INPUT_DEADLINE - elif choice == 'change_requirements': - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data='back_to_change_menu')]] - await query.message.edit_text("📝 *Введите новые требования:*", parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return INPUT_REQUIREMENTS - elif choice == 'back_to_price_calc': - return await calculate_price_step(update, context) - elif choice == 'back_to_change_menu': - return await change_order_data(update, context) - return CALCULATE_PRICE - -# Назад к расчету цены -async def back_to_price_calc(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - return await calculate_price_step(update, context) - -# Подтверждение заказа -async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - data = context.user_data - user = update.effective_user - client_name = user.username or f"user_{user.id}" - order_type = data.get('order_type', 'Неизвестный тип') - - client_dir = os.path.join(BASE_DIR, client_name) - os.makedirs(client_dir, exist_ok=True) - orders_list = user_orders.get(str(user.id), []) - order_id = len(orders_list) + 1 - order_path = os.path.join(client_dir, f"order_{order_id}.txt") - - order_data = { - 'order_id': order_id, - 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'type': order_type, - 'topic': data.get('topic'), - 'deadline': data.get('deadline').strftime('%d.%m.%Y') if data.get('deadline') else "Не указан", - 'days_left': data.get('days_left'), - 'requirements': data.get('requirements', 'Не указаны'), - 'price': data.get('price'), - 'status': 'Новый заказ', - 'user_id': user.id, - 'user_name': user.first_name, - 'user_username': user.username - } - - user_orders.setdefault(str(user.id), []).append(order_data) - all_orders = load_orders() - all_orders.setdefault(str(user.id), []).append(order_data) - save_orders(all_orders) - - with open(order_path, 'w', encoding='utf-8') as f: - f.write(f"Пользователь: {user.first_name} (@{user.username})\n") - f.write(f"ID: {user.id}\n") - f.write(f"Тип работы: {order_type}\n") - f.write(f"Тема: {data.get('topic')}\n") - f.write(f"Сроки: {order_data['deadline']} ({data.get('days_left')} дней)\n") - f.write(f"Требования: {data.get('requirements', 'Не указаны')}\n") - f.write(f"Стоимость: {data.get('price')} руб.\n") - f.write(f"Статус: {order_data['status']}\n") - - # Сохраняем данные заказа в JSON вместо Excel (не требует pandas) - orders_json_path = os.path.join(DATA_DIR, 'all_orders.json') - try: - if os.path.exists(orders_json_path): - with open(orders_json_path, 'r', encoding='utf-8') as f: - all_orders_list = json.load(f) - else: - all_orders_list = [] - - order_data_json = { - 'Дата': order_data['date'], - 'Пользователь': f"{user.first_name} (@{user.username})", - 'ID': user.id, - 'Тип работы': order_type, - 'Тема': data.get('topic'), - 'Сроки': order_data['deadline'], - 'Дней осталось': data.get('days_left'), - 'Требования': data.get('requirements', 'Не указаны'), - 'Стоимость': data.get('price'), - 'Статус': 'Новый заказ' - } - - all_orders_list.append(order_data_json) - - with open(orders_json_path, 'w', encoding='utf-8') as f: - json.dump(all_orders_list, f, ensure_ascii=False, indent=4) - except Exception as e: - logger.error(f"Ошибка при сохранении заказа: {e}") - - try: - admin_message = ( - f"🆕 *Новый заказ #{order_id}*\n\n" - f"*От:* @{user.username} ({user.first_name})\n" - f"*Тип:* {order_type}\n" - f"*Тема:* {data.get('topic')}\n" - f"*Дедлайн:* {order_data['deadline']} ({data.get('days_left')} дней)\n" - f"*Требования:* {data.get('requirements', 'Не указаны')}\n" - f"*Цена:* {data.get('price')} руб." - ) - keyboard = [ - [InlineKeyboardButton("✅ Принять", callback_data=f'admin_accept_{user.id}_{order_id}')], - [InlineKeyboardButton("❌ Отклонить", callback_data=f'admin_reject_{user.id}_{order_id}')], - [InlineKeyboardButton("💲 Изменить цену", callback_data=f'admin_change_price_{user.id}_{order_id}')] - ] - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=admin_message, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup(keyboard) - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления админу: {e}") - - success_message = ( - f"✅ *Заказ оформлен!*\n\n" - f"Номер: #{order_id}\n" - f"Тип: {order_type}\n" - f"Срок: {order_data['deadline']}\n" - f"Стоимость: {data.get('price')} руб.\n\n" - f"Менеджер свяжется с вами для деталей и оплаты." - ) - keyboard = [ - [InlineKeyboardButton("👤 Профиль", callback_data="profile")], - [InlineKeyboardButton("🏠 Главное меню", callback_data="back_to_main")] - ] - await query.message.edit_text(success_message, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - for key in list(context.user_data.keys()): - if key != 'ref_link': - context.user_data.pop(key, None) - return CONFIRM_ORDER - -# Отмена заказа -async def cancel_order(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - for key in list(context.user_data.keys()): - if key != 'ref_link': - context.user_data.pop(key, None) - await query.message.edit_text( - "❌ *Заказ отменен*\n\nОформите новый заказ в любое время.", - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup([ - [InlineKeyboardButton("📝 Новый заказ", callback_data="make_order")], - [InlineKeyboardButton("🏠 Главное меню", callback_data="back_to_main")] - ]) - ) - return SELECT_MAIN_MENU - -# Профиль -async def show_profile(update: Update, context: ContextTypes.DEFAULT_TYPE): - user = update.effective_user - orders = user_orders.get(str(user.id), []) or load_orders().get(str(user.id), []) - - try: - bot = await context.bot.get_me() - bot_username = bot.username - except Exception as e: - logger.error(f"Ошибка получения данных бота: {e}") - bot_username = "Kladovaya_GIPSR_bot" - - ref_link = f"https://t.me/{bot_username}?start={user.id}" - context.user_data['ref_link'] = ref_link - ref_count = len(referrals.get(str(user.id), [])) - bonus = sum(int(order.get('price', 0) * 0.05) for ref_id in referrals.get(str(user.id), []) for order in user_orders.get(str(ref_id), [])) - - text = ( - f"👤 *Личный кабинет*\n\n" - f"*Имя:* {user.first_name}\n" - f"*Username:* @{user.username if user.username else 'отсутствует'}\n" - f"*ID:* `{user.id}`\n\n" - f"*Реферальная программа:*\n" - f"- Приглашено: {ref_count}\n" - f"- Бонус: {bonus} руб.\n" - f"- Ссылка: `{ref_link}`\n\n" - ) - - if orders: - text += "*Ваши заказы:*\n" - recent_orders = sorted(orders, key=lambda x: x.get('date', ''), reverse=True)[:3] - for o in recent_orders: - text += f"- Заказ #{o.get('order_id', 'N/A')}: {o.get('type')} | Статус: {o.get('status')}\n" - if len(orders) > 3: - text += f"\n_...и еще {len(orders) - 3} заказов_\n" - else: - text += "У вас пока нет заказов." - - keyboard = [ - [InlineKeyboardButton("📋 Все заказы", callback_data='show_all_orders')], - [InlineKeyboardButton("📝 Новый заказ", callback_data='make_order')], - [InlineKeyboardButton("✍️ Оставить отзыв", callback_data='leave_feedback')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - - if update.callback_query: - await update.callback_query.answer() - await update.callback_query.message.edit_text( - text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - else: - await context.bot.send_message( - chat_id=user.id, text=text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return PROFILE_MENU - -# Все заказы -async def show_all_orders(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - user = update.effective_user - orders = user_orders.get(str(user.id), []) or load_orders().get(str(user.id), []) - - if not orders: - text = "У вас пока нет заказов." - keyboard = [[InlineKeyboardButton("⬅️ Назад к профилю", callback_data='back_to_profile')]] - await query.message.edit_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_ORDERS - - orders = sorted(orders, key=lambda x: x.get('date', ''), reverse=True) - text = "📋 *Все ваши заказы:*\n\n" - for i, order in enumerate(orders): - text += f"*Заказ #{order.get('order_id', 'N/A')}* ({order.get('date', '')[:10]})\n" - text += f"Тип: {order.get('type', 'Неизвестный')}\n" - text += f"Тема: {order.get('topic', 'Не указана')}\n" - text += f"Статус: {order.get('status', 'Неизвестен')}\n" - text += f"Цена: {order.get('price', 'Не указана')} руб.\n" - if i < len(orders) - 1: - text += "\n----------------------------\n\n" - - keyboard = [ - [InlineKeyboardButton("📝 Новый заказ", callback_data='make_order')], - [InlineKeyboardButton("⬅️ Назад к профилю", callback_data='back_to_profile')] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return SHOW_ORDERS - -# Назад к профилю -async def back_to_profile(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - return await show_profile(update, context) - -# Оставить отзыв -async def leave_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - text = ( - "✍️ *Оставить отзыв*\n\n" - "Напишите ваш отзыв о работе с нами в одном сообщении:" - ) - keyboard = [[InlineKeyboardButton("⬅️ Назад к профилю", callback_data='back_to_profile')]] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return LEAVE_FEEDBACK - -# Принять отзыв -async def input_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.callback_query: - query = update.callback_query - await query.answer() - if query.data == 'back_to_profile': - return await show_profile(update, context) - return - - user = update.effective_user - feedback_text = update.message.text - feedback_data = { - 'user_id': user.id, - 'username': user.username, - 'name': user.first_name, - 'date': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), - 'text': feedback_text - } - - feedbacks = load_feedbacks() - feedbacks.setdefault(str(user.id), []).append(feedback_data) - save_feedbacks(feedbacks) - - client_name = user.username or f"user_{user.id}" - feedback_dir = os.path.join(BASE_DIR, 'feedbacks') - os.makedirs(feedback_dir, exist_ok=True) - feedback_path = os.path.join(feedback_dir, f"{client_name}.txt") - with open(feedback_path, 'a', encoding='utf-8') as f: - f.write(f"--- Отзыв от {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ---\n{feedback_text}\n\n") - - try: - await context.bot.send_message( - chat_id=ADMIN_CHAT_ID, - text=f"📣 *Новый отзыв*\n\nОт: @{user.username} ({user.first_name})\n\n{feedback_text}", - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Ошибка отправки уведомления админу об отзыве: {e}") - - text = "🙏 *Спасибо за отзыв!*\n\nМы ценим ваше мнение." - keyboard = [ - [InlineKeyboardButton("👤 Профиль", callback_data='back_to_profile')], - [InlineKeyboardButton("🏠 Главное меню", callback_data='back_to_main')] - ] - await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - return INPUT_FEEDBACK - -# Команда /admin -async def admin_start(update: Update, context: ContextTypes.DEFAULT_TYPE): - if update.effective_user.id != ADMIN_CHAT_ID: - await update.message.reply_text("У вас нет доступа к панели администратора.") - return - - # Подсчет статистики - all_orders = load_orders() - total_orders = sum(len(orders) for orders in all_orders.values()) - total_users = len(all_orders) - new_orders = sum(1 for orders in all_orders.values() for order in orders if order.get('status') == 'Новый заказ') - - text = ( - "🎆 *ПРОДВИНУТАЯ АДМИН-ПАНЕЛЬ*\n\n" - f"📊 *Общая статистика:*\n" - f"• Заказов: {total_orders}\n" - f"• Клиентов: {total_users}\n" - f"• Новые: {new_orders}\n\n" - "🎛️ *Управление ботом:*" - ) - - keyboard = [ - [InlineKeyboardButton("📋 Заказы", callback_data='admin_orders'), - InlineKeyboardButton("👥 Клиенты", callback_data='admin_users')], - [InlineKeyboardButton("💰 Цены", callback_data='admin_prices'), - InlineKeyboardButton("📊 Статистика", callback_data='admin_stats')], - [InlineKeyboardButton("📢 Рассылка", callback_data='admin_broadcast'), - InlineKeyboardButton("💻 Логи", callback_data='admin_logs')], - [InlineKeyboardButton("🎁 Акции и скидки", callback_data='admin_promos')], - [InlineKeyboardButton("⚙️ Настройки", callback_data='admin_settings')], - [InlineKeyboardButton("❌ Выйти", callback_data='back_to_main_admin')] - ] - - await update.message.reply_text( - text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return ADMIN_MENU - -# Обработчик админ-меню -async def admin_menu_handler(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - if update.effective_user.id != ADMIN_CHAT_ID: - await query.message.edit_text("У вас нет доступа.") - return await main_menu(update, context) - - choice = query.data - - if choice == 'admin_orders': - # Показать заказы - all_orders = load_orders() - text = "📋 *УПРАВЛЕНИЕ ЗАКАЗАМИ*\n\n" - - # Подсчет статусов - statuses = {} - for orders in all_orders.values(): - for order in orders: - status = order.get('status', 'Неизвестен') - statuses[status] = statuses.get(status, 0) + 1 - - text += "📊 *По статусам:*\n" - for status, count in statuses.items(): - text += f"• {status}: {count}\n" - - text += "\n🆕 *Последние 5 заказов:*\n\n" - - # Показать последние заказы - recent_orders = [] - for uid, orders in all_orders.items(): - for order in orders: - order['user_id'] = uid - recent_orders.append(order) - - recent_orders = sorted(recent_orders, key=lambda x: x.get('date', ''), reverse=True)[:5] - - for order in recent_orders: - text += f"🔸 #{order.get('order_id', 'N/A')} | {order.get('user_name', 'Unknown')}\n" - text += f" {order.get('type', 'N/A')} | {order.get('status', 'N/A')}\n\n" - - keyboard = [ - [InlineKeyboardButton("🆕 Новые заказы", callback_data="admin_new_orders")], - [InlineKeyboardButton("✅ Принятые", callback_data="admin_accepted_orders")], - [InlineKeyboardButton("📤 Экспорт Excel", callback_data="admin_export_orders")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_users': - # Управление пользователями - all_users = user_ids.union({int(uid) for uid in load_orders().keys() if uid.isdigit()}) - text = "👥 *УПРАВЛЕНИЕ КЛИЕНТАМИ*\n\n" - text += f"📊 *Всего пользователей:* {len(all_users)}\n\n" - - # Топ клиентов - top_clients = [] - for uid in all_users: - orders = load_orders().get(str(uid), []) - if orders: - total = sum(order.get('price', 0) for order in orders) - top_clients.append({'id': uid, 'orders': len(orders), 'total': total, 'name': orders[0].get('user_name', 'Unknown')}) - - top_clients = sorted(top_clients, key=lambda x: x['total'], reverse=True)[:5] - - text += "🏆 *Топ-5 клиентов:*\n" - for idx, client in enumerate(top_clients, 1): - text += f"{idx}. {client['name']} - {client['total']:,}₽ ({client['orders']} зак.)\n" - - keyboard = [ - [InlineKeyboardButton("🔍 Найти клиента", callback_data="admin_find_user")], - [InlineKeyboardButton("📨 Отправить сообщение", callback_data="admin_message_user")], - [InlineKeyboardButton("🚫 Черный список", callback_data="admin_blacklist")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_prices': - # Управление ценами - text = "💰 *УПРАВЛЕНИЕ ЦЕНАМИ*\n\n" - text += "📍 *Текущие цены:*\n\n" - - for key, val in PRICES.items(): - order_type = ORDER_TYPES.get(key, {}) - text += f"{order_type.get('icon', '')} {order_type.get('name', key)}: {val.get('base', 0):,}₽\n" - - text += f"\n🎆 *Режим:* {PRICING_MODES.get(current_pricing_mode, {}).get('name', '')}\n" - - keyboard = [ - [InlineKeyboardButton("✏️ Изменить цены", callback_data="admin_edit_prices")], - [InlineKeyboardButton("🔄 Сменить режим", callback_data="admin_change_pricing_mode")], - [InlineKeyboardButton("🎯 Скидки", callback_data="admin_discounts")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_stats': - # Детальная статистика - all_orders = load_orders() - total_orders = sum(len(orders) for orders in all_orders.values()) - total_users = len(all_orders) - order_types = {} - total_revenue = 0 - today_revenue = 0 - today = datetime.now().strftime('%Y-%m-%d') - - for uid, orders in all_orders.items(): - for order in orders: - order_types[order.get('type', 'Неизвестный')] = order_types.get(order.get('type', 'Неизвестный'), 0) + 1 - price = int(order.get('price', 0)) - total_revenue += price - if order.get('date', '').startswith(today): - today_revenue += price - - text = "📊 *ДЕТАЛЬНАЯ СТАТИСТИКА*\n\n" - text += f"📈 *Общие показатели:*\n" - text += f"• Всего клиентов: {total_users}\n" - text += f"• Всего заказов: {total_orders}\n" - text += f"• Общая выручка: {total_revenue:,} руб.\n" - text += f"• Средний чек: {int(total_revenue/total_orders) if total_orders else 0:,} руб.\n\n" - - text += f"💰 *Выручка сегодня:* {today_revenue:,} руб.\n\n" - - text += "*📝 По типам работ:*\n" - for t, c in sorted(order_types.items(), key=lambda x: x[1], reverse=True): - text += f"• {t}: {c} ({c/total_orders*100:.1f}%)\n" - - keyboard = [ - [InlineKeyboardButton("📤 Экспорт в Excel", callback_data="admin_export_stats")], - [InlineKeyboardButton("📊 Графики", callback_data="admin_charts")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_broadcast': - # Рассылка сообщений - text = ( - "📢 *РАССЫЛКА СООБЩЕНИЙ*\n\n" - "Выберите тип рассылки:\n\n" - "• *Всем пользователям* - отправить всем клиентам\n" - "• *Активным* - только тем, кто заказывал\n" - "• *Новым* - кто еще не заказывал\n" - ) - keyboard = [ - [InlineKeyboardButton("📨 Всем пользователям", callback_data="broadcast_all")], - [InlineKeyboardButton("✅ Активным клиентам", callback_data="broadcast_active")], - [InlineKeyboardButton("🆕 Новым пользователям", callback_data="broadcast_new")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_settings': - # Настройки бота - text = ( - "⚙️ *НАСТРОЙКИ БОТА*\n\n" - "🔧 *Доступные настройки:*\n\n" - "• Режим цен\n" - "• Антиплагиат минимум\n" - "• Срок бесплатных правок\n" - "• Реферальный процент\n" - "• Автоответы\n" - ) - keyboard = [ - [InlineKeyboardButton("💲 Режим цен", callback_data="settings_pricing")], - [InlineKeyboardButton("📝 Тексты и сообщения", callback_data="settings_messages")], - [InlineKeyboardButton("🎁 Бонусы и скидки", callback_data="settings_bonuses")], - [InlineKeyboardButton("🤖 Автоматизация", callback_data="settings_automation")], - [InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")] - ] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'admin_change_pricing_mode': - global current_pricing_mode - current_pricing_mode = 'hard' if current_pricing_mode == 'light' else 'light' - mode_info = PRICING_MODES[current_pricing_mode] - text = f"🔄 *Режим цен изменен*\n\n{mode_info['name']}: {mode_info['icon']} {mode_info['description']}" - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data="admin_prices")]] - await query.message.edit_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard)) - - elif choice == 'back_to_main_admin': - return await main_menu(update, context) - - elif choice == 'admin_menu': - # Возврат к главному меню админа - all_orders = load_orders() - total_orders = sum(len(orders) for orders in all_orders.values()) - total_users = len(all_orders) - new_orders = sum(1 for orders in all_orders.values() for order in orders if order.get('status') == 'Новый заказ') - - text = ( - "🎆 *ПРОДВИНУТАЯ АДМИН-ПАНЕЛЬ*\n\n" - f"📊 *Общая статистика:*\n" - f"• Заказов: {total_orders}\n" - f"• Клиентов: {total_users}\n" - f"• Новые: {new_orders}\n\n" - "🎛️ *Управление ботом:*" - ) - - keyboard = [ - [InlineKeyboardButton("📋 Заказы", callback_data='admin_orders'), - InlineKeyboardButton("👥 Клиенты", callback_data='admin_users')], - [InlineKeyboardButton("💰 Цены", callback_data='admin_prices'), - InlineKeyboardButton("📊 Статистика", callback_data='admin_stats')], - [InlineKeyboardButton("📢 Рассылка", callback_data='admin_broadcast'), - InlineKeyboardButton("💻 Логи", callback_data='admin_logs')], - [InlineKeyboardButton("🎁 Акции и скидки", callback_data='admin_promos')], - [InlineKeyboardButton("⚙️ Настройки", callback_data='admin_settings')], - [InlineKeyboardButton("❌ Выйти", callback_data='back_to_main_admin')] - ] - - await query.message.edit_text( - text, parse_mode=ParseMode.MARKDOWN, reply_markup=InlineKeyboardMarkup(keyboard) - ) - return ADMIN_MENU - -# Действия администратора -async def admin_order_action(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - await query.answer() - if update.effective_user.id != ADMIN_CHAT_ID: - await query.message.edit_text("У вас нет доступа.") - return ADMIN_MENU - - action_data = query.data.split('_') - action, user_id, order_id = action_data[1], action_data[2], int(action_data[3]) - all_orders = load_orders() - - if user_id in all_orders: - for order in all_orders[user_id]: - if order.get('order_id') == order_id: - if action == 'accept': - order['status'] = 'Принят' - try: - await context.bot.send_message( - chat_id=int(user_id), - text=f"✅ *Заказ #{order_id} принят!*\n\nТип: {order.get('type')}\nТема: {order.get('topic')}\nСтатус: Принят\n\nМенеджер свяжется с вами.", - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Ошибка уведомления пользователя: {e}") - await query.message.edit_text(f"✅ Заказ #{order_id} принят.") - elif action == 'reject': - order['status'] = 'Отклонен' - try: - await context.bot.send_message( - chat_id=int(user_id), - text=f"❌ *Заказ #{order_id} отклонен*\n\nТип: {order.get('type')}\nТема: {order.get('topic')}\nСтатус: Отклонен\n\nМенеджер свяжется с вами.", - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Ошибка уведомления пользователя: {e}") - await query.message.edit_text(f"❌ Заказ #{order_id} отклонен.") - elif action == 'change_price': - context.user_data['admin_edit_order'] = {'user_id': user_id, 'order_id': order_id, 'current_price': order.get('price')} - await query.message.edit_text(f"Текущая цена заказа #{order_id}: {order.get('price')} руб.\n\nВведите новую цену:") - return ADMIN_MENU - break - save_orders(all_orders) - user_orders[user_id] = all_orders[user_id] - else: - await query.message.edit_text(f"Ошибка: Заказ #{order_id} не найден.") - - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")]] - await query.message.reply_text("Выберите действие:", reply_markup=InlineKeyboardMarkup(keyboard)) - return ADMIN_MENU - -# Изменение цены администратором -async def admin_change_price(update: Update, context: ContextTypes.DEFAULT_TYPE): - edit_data = context.user_data.get('admin_edit_order', {}) - if not edit_data: - await update.message.reply_text("Ошибка: Данные о заказе не найдены.") - return ADMIN_MENU - - try: - new_price = int(update.message.text) - if new_price <= 0: - await update.message.reply_text("Цена должна быть положительной.") - return ADMIN_MENU - - user_id, order_id = edit_data['user_id'], edit_data['order_id'] - all_orders = load_orders() - if user_id in all_orders: - for order in all_orders[user_id]: - if order.get('order_id') == order_id: - old_price = order.get('price') - order['price'] = new_price - try: - await context.bot.send_message( - chat_id=int(user_id), - text=f"💲 *Цена заказа #{order_id} изменена*\n\nТип: {order.get('type')}\nТема: {order.get('topic')}\nСтарая цена: {old_price} руб.\nНовая цена: {new_price} руб.\n\nМенеджер свяжется с вами.", - parse_mode=ParseMode.MARKDOWN - ) - except Exception as e: - logger.error(f"Ошибка уведомления пользователя: {e}") - save_orders(all_orders) - user_orders[user_id] = all_orders[user_id] - await update.message.reply_text(f"✅ Цена заказа #{order_id} изменена с {old_price} на {new_price} руб.") - del context.user_data['admin_edit_order'] - break - else: - await update.message.reply_text(f"Ошибка: Заказ #{order_id} не найден.") - except ValueError: - await update.message.reply_text("Введите корректное число.") - - keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data="admin_menu")]] - await update.message.reply_text("Выберите действие:", reply_markup=InlineKeyboardMarkup(keyboard)) - return ADMIN_MENU - -# Запуск бота -def main(): - logger.info("="*50) - logger.info("Бот запускается...") - logger.info(f"Bot token: {TELEGRAM_BOT_TOKEN[:10]}...{TELEGRAM_BOT_TOKEN[-5:]}") - logger.info(f"Admin ID: {ADMIN_CHAT_ID}") - logger.info(f"Platform: {platform.system()}") - logger.info(f"BASE_DIR: {BASE_DIR}") - logger.info(f"DATA_DIR: {DATA_DIR}") - logger.info(f"Python version: {sys.version}") - logger.info(f"Directories exist: BASE={os.path.exists(BASE_DIR)}, DATA={os.path.exists(DATA_DIR)}") - logger.info("="*50) - - try: - application = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() - conv_handler = ConversationHandler( - entry_points=[ - CommandHandler('start', start), - CommandHandler('order', order_command), - CommandHandler('profile', profile_command), - CommandHandler('admin', admin_start) - ], - states={ - SELECT_MAIN_MENU: [ - CallbackQueryHandler(main_menu_handler), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - SELECT_ORDER_TYPE: [CallbackQueryHandler(select_order_type_callback)], - VIEW_ORDER_DETAILS: [CallbackQueryHandler(order_details_handler)], - INPUT_TOPIC: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, input_topic), - CallbackQueryHandler(back_to_order_details, pattern='^back_to_order_details$') - ], - INPUT_DEADLINE: [ - CallbackQueryHandler(input_deadline), - MessageHandler(filters.TEXT & ~filters.COMMAND, input_custom_deadline) - ], - INPUT_REQUIREMENTS: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, input_requirements), - CallbackQueryHandler(back_to_deadline, pattern='^back_to_deadline$') - ], - CALCULATE_PRICE: [ - CallbackQueryHandler(confirm_order, pattern='^confirm_order$'), - CallbackQueryHandler(cancel_order, pattern='^cancel_order$'), - CallbackQueryHandler(change_order_data, pattern='^change_order_data$'), - CallbackQueryHandler(handle_change_data, pattern='^change_'), - CallbackQueryHandler(back_to_price_calc, pattern='^back_to_price_calc$') - ], - CONFIRM_ORDER: [ - CallbackQueryHandler(main_menu_handler, pattern='^back_to_main$'), - CallbackQueryHandler(show_profile, pattern='^profile$') - ], - PROFILE_MENU: [ - CallbackQueryHandler(show_all_orders, pattern='^show_all_orders$'), - CallbackQueryHandler(leave_feedback, pattern='^leave_feedback$'), - CallbackQueryHandler(main_menu_handler, pattern='^make_order$'), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - SHOW_PRICE_LIST: [ - CallbackQueryHandler(price_calculator, pattern='^price_calculator$'), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - PRICE_CALCULATOR: [ - CallbackQueryHandler(calculate_price_in_calculator, pattern='^calc_'), - CallbackQueryHandler(handle_deadline_selection, pattern='^deadline_'), - CallbackQueryHandler(select_order_type_callback, pattern='^order_'), - CallbackQueryHandler(back_to_price, pattern='^back_to_price$') - ], - SHOW_FAQ: [ - CallbackQueryHandler(show_faq_details, pattern='^faq_'), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - FAQ_DETAILS: [ - CallbackQueryHandler(back_to_faq, pattern='^back_to_faq$'), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - SHOW_ORDERS: [ - CallbackQueryHandler(back_to_profile, pattern='^back_to_profile$'), - CallbackQueryHandler(main_menu_handler, pattern='^make_order$') - ], - LEAVE_FEEDBACK: [ - MessageHandler(filters.TEXT & ~filters.COMMAND, input_feedback), - CallbackQueryHandler(back_to_profile, pattern='^back_to_profile$') - ], - INPUT_FEEDBACK: [ - CallbackQueryHandler(back_to_profile, pattern='^back_to_profile$'), - CallbackQueryHandler(main_menu, pattern='^back_to_main$') - ], - ADMIN_MENU: [ - CallbackQueryHandler(admin_menu_handler, pattern='^admin_'), - CallbackQueryHandler(admin_order_action, pattern='^admin_(accept|reject|change_price)_'), - MessageHandler(filters.TEXT & ~filters.COMMAND, admin_change_price), - CallbackQueryHandler(main_menu, pattern='^back_to_main_admin$') - ] - }, - fallbacks=[ - CommandHandler('start', start), - CommandHandler('order', order_command), - CommandHandler('profile', profile_command), - CommandHandler('admin', admin_start), - CommandHandler('help', help_command) - ], - name="main_conversation", - persistent=False - ) - application.add_handler(conv_handler) - # Добавляем обработчики команд вне ConversationHandler - application.add_handler(CommandHandler('help', help_command)) - application.add_handler(CommandHandler('order', order_command)) - application.add_handler(CommandHandler('profile', profile_command)) - application.add_handler(CommandHandler('price', price_command)) - application.add_handler(CommandHandler('faq', faq_command)) - application.add_handler(CommandHandler('admin', admin_start)) - - # Добавляем обработчик ошибок - application.add_error_handler(error_handler) - - logger.info("Все обработчики зарегистрированы") - logger.info("Бот настроен, начинаем polling...") - application.run_polling(drop_pending_updates=True) - except Exception as e: - logger.error(f"Критическая ошибка при запуске: {e}", exc_info=True) - -if __name__ == '__main__': - main() \ No newline at end of file diff --git a/deploy/supervisor.conf b/deploy/supervisor.conf new file mode 100644 index 0000000..6113056 --- /dev/null +++ b/deploy/supervisor.conf @@ -0,0 +1,12 @@ +[program:gipsr_bot] +directory=/root/gipsr_bot +command=/root/gipsr_bot/scripts/run.sh +autostart=true +autorestart=true +startsecs=5 +stopwaitsecs=10 +stdout_logfile=/root/gipsr_bot/logs/supervisor.out.log +stderr_logfile=/root/gipsr_bot/logs/supervisor.err.log +stopsignal=TERM +environment=PYTHONUNBUFFERED="1" + diff --git a/orders.json b/orders.json deleted file mode 100644 index 606da33..0000000 --- a/orders.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "872379852": [ - { - "order_id": 1, - "date": "2025-08-28 20:55:05", - "type": "Самостоятельная работа", - "topic": "Бра", - "deadline": "31.08.2025", - "days_left": 3, - "requirements": "нет", - "price": 1950, - "status": "Новый заказ", - "user_id": 872379852, - "user_name": "Семён", - "user_username": "Thisissaymoon" - } - ] -} \ No newline at end of file diff --git a/orders.xlsx b/orders.xlsx deleted file mode 100644 index c177227638d3f9a093463ed59d7aff0851197176..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5318 zcmZ`-1yodP*B(+*7}_C~?(PQZ5Cj2fff10DP5~XdLy&G%YCyWXV+a9hBm^8rdK4u7 z@w&cyul(P4&N}C;v(|a`+51`h+3$Y0js^xM82|vl0j!tMm?#fuslGfn6>dyPb)s@G`$x#cYh2PCbfW6l7(5oRmimvGPed(y2Rv3?GXg#|V+Q5a%*u$BoLWa(9A}!1>tkD!&%=TmBN{CXDaS}xL&CmHaW+}#F>es*pwLEgmXbM`T zcTsrGTu-VFVPWj)Lw7UMzy56BNh3`YDP2SC9)gW!M0t`t@C3W+tT^(Dcp?t(1<@Jb zUX8jC_*oY0m&n2=;csd?x9Qn5|BX&SH_bCSt*q4?QR+V{RPMNDf>t2JnUC6tA!yv??WVSI#Ap5P-p*&XvIY(VlT)g}FNXqCu$tA7 zh129X0)&zh*+%1D0mxoNZp`q4hmaF!ZR5~KtbSxF+LZ=#O}8bdJNv}3qevdNw$OgU z<`Z(EGO?sIR`0>|_rO(nrd<|ZtjU9E2k+(SxXAvB2_M4t!w2C#m682r6CQAT7S_v3 zhoW5yI|bzpxE~35-Im-m1Hj1DYj$)BBh{S!+%p9`- zRY=(_I|1u9K(u#KQzrl_UmvyBoit^dFbiX^(*faR42GPlyu91D1id^>YZ)pM^ve#E z7i)hJ&C11GjZ05dAp3_nocWvduJ^aaN@+6$qS(4OEg2q-M0ER>+7BdUL z_RsL(+ETXk2B}qMFxX~Jj2mOiGSVmz`9Q^)aqfL*&Aqu4WhSNELGI>Fpje$6p#Z5Y zPIlOvXF4?KMY#N_`C&h5`^$lAq=60XID@OP5#P&PGNcXjjnoz(KZun<>sjt!+$SA> z-YQm}L_TU=DoisXM@yqx6)F|x>YMd>-FaA%B}Ge0GM0jmEgzP~#6t33{Dqb(=@n=3 z!d2F^+(8O2)46(LynPMhTMw489YIi+GL+gg+-^$B?+WWIOBjzPk(w6WmbymyF2ug& zfDXzOmr>CT7zYW3%qz-E}Sf5Ik-2L=s7-{cpI2x zE{ghgJURi`K^G>bo`EQMZiIQ6W8S0Iwexc1>1E@Kuo*!|uDI_iA1T#0iCEss?g|Lq zD$IC{+XgXsmRlMwB=e3=01xZ1iB4wA&j(L5wZB!T@})o8_2hzRciE4Zo#H_}fw1EJ zrW5J>sRmKTK-;eAXpE1)}Zu>W+wq?UB;OeeNw6I4eM^)|5 zq-)8wVONhU26G93+7$RCs@5r#CF%vocl6q~In{a}iFGIm){~@BwQhVFc&@G2Na9sb zgf7t3BL3bF%x_zAw8~9@%ViSrm}Fhc3^B8vnN{g`&+9zRyUX(E(sf9MXiIY2;>1j` z@&$5vt}QdKj{w-1Qsp@h!(ng*MKQeac(TT%ixDcV@d(ES7Smo_Cd!Ees-+l~mD z*vZE$WW6n@=&5kVRDyVBMv3+t}Yve&!uBcg_{!1ko3;nV}q3jO>yYbW{AK z&wO0Dz=9vTxUN6e0%~Wm)adbZ&gN#lUNrwGT}vsgd|a5mMpqN=p6S=vtyF$(G)s3T zuqy7lD6xWJ{)LoFvFJrtU{q8vu*|Bx8T#OiIZLwE|1g9h^I~?f2)VpQ5b&|gxY&jh zD64TRfIbP^I>zKg$ReK1s-mR2qJM#(z&F_Fm>~bD*lM^^21sxwx?DUK&qaPlZcBFY zBcUCVP>3cG%bY*GUZikS8&7KKYM-#GyEpDLBDw?Z&jo~+nK)|(9RSF|2LPykFCgA7 zZVv8tV6cZf?_ZCd$0f$py7Y{VVjL-uw%%T^)T57jzo+c^ZZAIU;$jcfNrs+YQ@F9|3 z@VUx&!=kt$|9l^cXfeDy%O;vw)S;ZGx_-Q`(gvm#iocrkHD#&?-SfL1$C}D%Rth6}zLo5%Um=w=4& zx?18;Spx4CI%5iNGw@YQ(IcuOeI*jkqJeh81haU6%shg;v_g5fDz(l8^AZj18H7i$CPUck}0(&Vi z1!0+JdIs9(qt~gGlQ?GapATf=ei7Fnv*Bwj^54-IywP2ud6QLnw`$H^Fqgp)4+&94 zj}wcDvPx*mzQ>B^*X48Or;D{~2FfBNRGI;2=hicdRT((VoYzts9GGJxu>0+~xO%rX zufiTqYU{tMdvLTP`ll4WBja)3qNL!2k^Y0}>9Wn^_8 z?R_{0A`31Hp09#8TJ_wTb|+MID7U(u>Xl2_wpD&7p~$AbwS`ll527vVAYJOe6=q|r zj3_8rmHX3|`Hf5C;v-re>#@Ic>@ zwO`i(k*gl{qO1QF+dC15A(L&QHd!VC>5Po8oouS9-AcrTdkktpz zGNqn|=AA8rKrd1()h7eR0liMLi|%yzgBuep`NtO2s~%i?YLLxBs?lC7iAEvH7>iG4 zSDDM^QqAFWsa8;OqWO-+ji+PUtwn+c zt-+#ML$qvSF?)gy+Iu<}T6kCzwbkm|=x1(bGgswW2Xc>giKN~g-JA3yo5H0v%(eqn zS~LLmBn^5VbiKy-G;@#|J0+dn#gV|6Q3lMcQOWE#`{+6w4$pjH~LW`Io7#DIH>Z7@tPU;a{|e<37LLA zUTfrw#Bes)ND|7S?m>5wZVX9`#3z;vOex!+mHdunMzr^v9M501oe}<-(G4o_s7_QS zQ=qaMmDB(WH?X5SFVC-gc9MoOIzNFNlmu*{`MwvOzx-2`Vg!C;=f-sWlsj9x}x#?^I$=Z>#-#m-p7n=X!+j7Tj7I} z(c@EwgaQ>MSFV%9BF$*86sSzja(mHnv9(pL&+5+g@x(ha>9dY(zT(KKIVD^cm#QKP-OT4iOlfSlyNG2kKK75aZZgLFBx(hA)xOtD4!Bm z&yX0eY=0Co>WV5|d6^twOjeWCWhd{}-Zx$rf}~H~r}99U0IHwLJ&x7prYx*696uU~ z!z}%wr(gI%N$hMj^wU$7lSai?M~_QSMDhfpZ*%A5^E}EISe@EUq=IeU&(!L((R|O+ zV~NjYbeSkR`K4v$tSDlot#7nAgBgTESeB=pq~BV;Qd9{Oz(=Rh4_itm)RIj|c- zb*2>O5mo(E+l1{=0Si%nCA=#hb3#cX$C~P(`ARu13k34ek-e1=|0^b3z2@3%;gOKZp0uSfWQk}C+`Xs$q!`V&Pk=r_5)6Y1X+|CLmAiPE=U@ezbV zNwxq~?>^?Em++L6Q%1Zz1BTqMF6%~6cL7vsoB1BYQaire*zyWR`Ah1~Ns=7fu?&L; z_UHgbk~HK>v!BUWVowX#83OUOcjfDF>}giJ=7 zX{@-rb^>3&5oBhV0)!eB;2g&9jkJlPi?avV*~3EL*A?t;_A84jl3~BHsNbUatLHO8 zF;_V_PYmZ?CqAUttnf>&m5YZ<{Ne}oylTSN8ioDH@9r1OWewJtqY}2#2EK}9^r3Py z!O2EpI5~`-s@f#iCD{=V>)dXwzi0{|=I}v(fOQHw^Q#Ln{>BZ4(Yh4kwhxmUjn=&7 zueN>E6f+58j@(#ZJ0(&-RAOs#8|>=8Z|bU$6Il~+_k{Da{tv(4l$bC_PSQ5j0sDd) z^j$l&kr9w>Gl8cQ*-joZPD4Egx2PXGIE!0lvX5NoFyYC?%9yidLX+y|M<+bsd>v-c zcDCbgS@fVn!l0@6Cd39Y&k9+2PIzu0_TUF&k=FrcU)3o6v3D-(r3uKFDF1Tbe->Jt z-2Z?u; Date: Tue, 16 Sep 2025 02:14:33 +0300 Subject: [PATCH 3/9] Prepare fully preconfigured deployment with auto systemd setup --- .env | 3 + .gitignore | 1 - README.md | 41 +++++-- bot.py | 257 +++++++++++++++++++++++++++++++++++++++--- config.py | 30 +++++ scripts/autodeploy.sh | 70 ++++++++++++ 6 files changed, 375 insertions(+), 27 deletions(-) create mode 100644 .env create mode 100644 config.py create mode 100755 scripts/autodeploy.sh diff --git a/.env b/.env new file mode 100644 index 0000000..230398d --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +TELEGRAM_BOT_TOKEN=7387413773:AAFgKfuf-REN5cn1ZXmCQg +ADMIN_CHAT_ID=0 +MANAGER_CONTACT=https://t.me/Thisissaymoon diff --git a/.gitignore b/.gitignore index 66bba8e..79d5cb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ __pycache__/ *.py[cod] *.log -.env venv/ data/ logs/ diff --git a/README.md b/README.md index 8508bb5..5e030fa 100644 --- a/README.md +++ b/README.md @@ -6,15 +6,15 @@ - динамический калькулятор стоимости с учетом сроков и сложности; - личный кабинет с историей заказов и реферальной ссылкой; - раздел FAQ и сбор отзывов; -- админ-панель с экспортом заказов, статистикой и переключением режима ценообразования. +- админ-панель со статистикой, экспортом, переключением режима ценообразования, обновлением статусов заказов и логами. ## Быстрый старт (локально) -1. Скопируйте `.env.example` в `.env` и укажите реальные значения: +1. Проверьте файл `.env` — мы уже добавили его в репозиторий с рабочим токеном и `ADMIN_CHAT_ID=0`. + При необходимости замените значения на свои: ```bash - cp .env.example .env - # отредактируйте файл и впишите TELEGRAM_BOT_TOKEN и ADMIN_CHAT_ID + nano .env ``` 2. Установите зависимости через подготовленный скрипт (создает виртуальное окружение `venv/`): @@ -29,7 +29,7 @@ ./scripts/run.sh ``` -Бот автоматически создаст служебные директории `data/`, `logs/` и `clients/`, сохранит данные о ценах, заказах и логах в JSON и будет писать технические сообщения в `logs/bot.log`. +Бот автоматически создаст служебные директории `data/`, `logs/` и `clients/`, сохранит данные о ценах, заказах и логах в JSON и будет писать технические сообщения в `logs/bot.log`. Первый пользователь, который отправит `/start`, автоматически становится администратором и получает доступ к команде `/admin`. ## Что загрузить на сервер @@ -37,9 +37,9 @@ - `bot.py` — основной код бота; - `requirements.txt` — список зависимостей; -- `scripts/install.sh` и `scripts/run.sh` — автоматическая установка и запуск в виртуальном окружении; +- `scripts/install.sh`, `scripts/run.sh` и `scripts/autodeploy.sh` — автоматическая установка, запуск и настройка службы; - `deploy/supervisor.conf` — конфигурация службы `supervisor` для автозапуска; -- `.env` (на основе `.env.example`) — секреты и параметры бота. +- `.env` — уже заполненный файл с токеном и настройками администратора (замените при необходимости). Дополнительно можно загрузить `README.md`, чтобы под рукой была инструкция. @@ -55,8 +55,29 @@ chmod +x scripts/install.sh scripts/run.sh Скрипт создаст окружение `venv/` и установит зависимости. При необходимости повторите запуск после обновления `requirements.txt`. +## Автоматический деплой (systemd) + +Чтобы не выполнять настройки вручную, достаточно один раз запустить подготовленный скрипт автодеплоя: + +```bash +cd /root/gipsr_bot +chmod +x scripts/autodeploy.sh +sudo ./scripts/autodeploy.sh +``` + +Скрипт: + +- устанавливает системные зависимости (`python3`, `python3-venv`, `python3-pip`, `systemd`); +- создает виртуальное окружение и ставит все пакеты из `requirements.txt`; +- генерирует `systemd`-юнит `gipsrbot.service`, включает автозапуск и сразу стартует бота; +- выводит статус службы и последние строки журнала `journalctl`. + +После завершения бот уже работает и автоматически стартует при перезагрузке сервера. + ## Настройка Supervisor +> Опционально, если вместо systemd вы используете supervisor. + 1. Скопируйте конфиг в систему (обновите `directory` и `command`, если путь отличается): ```bash @@ -91,10 +112,14 @@ python bot.py - «💲 Прайс-лист» и «🧮 Калькулятор» — просмотр тарифов и мгновенный расчет стоимости. - «👤 Профиль» — статистика по заказам, рефералам и быстрый доступ к отзывам. - «❓ FAQ» — ответы на популярные вопросы. -- `/admin` — админ-панель (доступна только указанному `ADMIN_CHAT_ID`). +- `/admin` — админ-панель (доступна первому пользователю, запустившему бота). Можно переключать режимы цен, экспортировать + заявки, просматривать логи и обновлять статусы заказов. ## Структура данных +- `.env` — готовый файл с токеном и контактами (перезапишите при необходимости). +- `config.py` — статические параметры проекта, статусы заказов и ссылка на менеджера. Значения из `.env` имеют приоритет. +- `data/settings.json` — после первого запуска сюда сохраняется ID администратора и режим ценообразования. - `data/prices.json` — базовые цены на работы. - `data/orders.json` — оформленные заказы пользователей. - `data/referrals.json` — связи реферер → приглашенные пользователи. diff --git a/bot.py b/bot.py index b7cee93..7624469 100644 --- a/bot.py +++ b/bot.py @@ -24,14 +24,35 @@ filters, ) +from config import ( + BOT_TOKEN, + DEFAULT_ADMIN_CHAT_ID as CONFIG_ADMIN_CHAT_ID, + DEFAULT_ADMIN_USERNAME, + DEFAULT_PRICING_MODE, + MANAGER_CONTACT_URL as CONFIG_MANAGER_CONTACT_URL, + ORDER_STATUS_TITLES, + WELCOME_MESSAGE, +) + load_dotenv() -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") -ADMIN_CHAT_ID = int(os.getenv("ADMIN_CHAT_ID", "0")) +TELEGRAM_BOT_TOKEN = (os.getenv("TELEGRAM_BOT_TOKEN") or BOT_TOKEN or "").strip() + +admin_id_env = os.getenv("ADMIN_CHAT_ID") +try: + ADMIN_CHAT_ID_DEFAULT = ( + int(admin_id_env) if admin_id_env is not None else int(CONFIG_ADMIN_CHAT_ID) + ) +except ValueError: + ADMIN_CHAT_ID_DEFAULT = int(CONFIG_ADMIN_CHAT_ID) + +MANAGER_CONTACT_LINK = ( + os.getenv("MANAGER_CONTACT") or CONFIG_MANAGER_CONTACT_URL or "" +).strip() or CONFIG_MANAGER_CONTACT_URL if not TELEGRAM_BOT_TOKEN: raise RuntimeError( - "TELEGRAM_BOT_TOKEN is not set. Create a .env file with TELEGRAM_BOT_TOKEN=." + "TELEGRAM_BOT_TOKEN is not configured. Проверьте .env или config.py." ) BASE_DIR = Path(__file__).resolve().parent @@ -168,6 +189,7 @@ class OrderRecord: deadline_date: str requirements: str upsells: List[str] + status_key: str price: int status: str created_at: str @@ -175,7 +197,21 @@ class OrderRecord: class DataStore: def __init__(self) -> None: - self.settings: Dict[str, str] = self._load_json(SETTINGS_FILE, {"pricing_mode": "light"}) + default_settings = { + "pricing_mode": DEFAULT_PRICING_MODE, + "admin_chat_id": ADMIN_CHAT_ID_DEFAULT, + "admin_username": DEFAULT_ADMIN_USERNAME, + "manager_contact_url": MANAGER_CONTACT_LINK, + } + loaded_settings = self._load_json(SETTINGS_FILE, default_settings) + changed = False + for key, value in default_settings.items(): + if key not in loaded_settings: + loaded_settings[key] = value + changed = True + if changed: + self._save_json(SETTINGS_FILE, loaded_settings) + self.settings: Dict[str, object] = loaded_settings self.prices: Dict[str, Dict[str, int]] = self._load_json(PRICES_FILE, DEFAULT_PRICES) self.referrals: Dict[str, List[int]] = self._load_json(REFERRALS_FILE, {}) self.orders: Dict[str, List[Dict[str, object]]] = self._load_json(ORDERS_FILE, {}) @@ -199,12 +235,30 @@ def _save_json(path: Path, data) -> None: json.dump(data, fh, ensure_ascii=False, indent=2) def get_pricing_mode(self) -> str: - return self.settings.get("pricing_mode", "light") + return self.settings.get("pricing_mode", DEFAULT_PRICING_MODE) def set_pricing_mode(self, mode: str) -> None: self.settings["pricing_mode"] = mode self._save_json(SETTINGS_FILE, self.settings) + def get_admin_chat_id(self) -> int: + try: + return int(self.settings.get("admin_chat_id", 0)) + except (TypeError, ValueError): + return 0 + + def set_admin_chat_id(self, chat_id: int, username: Optional[str] = None) -> None: + self.settings["admin_chat_id"] = int(chat_id) + if username is not None: + self.settings["admin_username"] = username or "" + self._save_json(SETTINGS_FILE, self.settings) + + def get_admin_username(self) -> str: + return str(self.settings.get("admin_username", "") or "") + + def get_manager_contact(self) -> str: + return str(self.settings.get("manager_contact_url", MANAGER_CONTACT_LINK)) + def add_referral(self, referrer_id: int, new_user_id: int) -> bool: referrer_key = str(referrer_id) referred_list = self.referrals.setdefault(referrer_key, []) @@ -247,6 +301,25 @@ def add_order(self, user_id: int, order: OrderRecord) -> OrderRecord: def get_orders(self, user_id: int) -> List[Dict[str, object]]: return self.orders.get(str(user_id), []) + def get_order(self, user_id: int, order_id: int) -> Optional[Dict[str, object]]: + orders = self.orders.get(str(user_id)) + if not orders: + return None + for order in orders: + if int(order.get("order_id", 0)) == int(order_id): + return order + return None + + def list_recent_orders(self, limit: int = 10) -> List[Dict[str, object]]: + entries: List[Dict[str, object]] = [] + for user_id, orders in self.orders.items(): + for order in orders: + combined = {"user_id": int(user_id)} + combined.update(order) + entries.append(combined) + entries.sort(key=lambda item: item.get("created_at", ""), reverse=True) + return entries[:limit] + def total_spent(self, user_id: int) -> int: return int(sum(int(order.get("price", 0)) for order in self.get_orders(user_id))) @@ -269,7 +342,15 @@ def get_statistics(self) -> Dict[str, int]: orders_flat = [order for orders in self.orders.values() for order in orders] total_orders = len(orders_flat) total_revenue = int(sum(int(order.get("price", 0)) for order in orders_flat)) - active_orders = sum(1 for order in orders_flat if order.get("status", "").lower() not in {"готов", "завершен"}) + active_orders = 0 + for order in orders_flat: + status_key = str(order.get("status_key", "")).lower() + status_text = str(order.get("status", "")).lower() + if status_key in {"done"}: + continue + if any(word in status_text for word in ("готов", "заверш")): + continue + active_orders += 1 unique_users = len(self.orders) return { "orders": total_orders, @@ -278,6 +359,20 @@ def get_statistics(self) -> Dict[str, int]: "users": unique_users, } + def update_order_status( + self, user_id: int, order_id: int, status_key: str, status_title: str + ) -> Optional[Dict[str, object]]: + orders = self.orders.get(str(user_id)) + if not orders: + return None + for order in orders: + if int(order.get("order_id", 0)) == int(order_id): + order["status_key"] = status_key + order["status"] = status_title + self._save_json(ORDERS_FILE, self.orders) + return order + return None + store = DataStore() @@ -316,6 +411,7 @@ def calculate_price(order_type: str, days_left: int, complexity: float = 1.0, up def build_main_menu_keyboard() -> InlineKeyboardMarkup: + contact_url = store.get_manager_contact() keyboard = [ [InlineKeyboardButton("📝 Сделать заказ", callback_data="main:order")], [ @@ -326,7 +422,7 @@ def build_main_menu_keyboard() -> InlineKeyboardMarkup: InlineKeyboardButton("👤 Профиль", callback_data="main:profile"), InlineKeyboardButton("❓ FAQ", callback_data="main:faq"), ], - [InlineKeyboardButton("📞 Администратор", url="https://t.me/Thisissaymoon")], + [InlineKeyboardButton("📞 Администратор", url=contact_url)], ] return InlineKeyboardMarkup(keyboard) @@ -599,8 +695,9 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i deadline_date=deadline_date, requirements=str(draft.get("requirements", "")), upsells=list(draft.get("upsells", [])), + status_key="new", price=int(draft.get("price", 0)), - status="новый", + status=ORDER_STATUS_TITLES.get("new", "новый"), created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ) store.add_order(user.id, order) @@ -612,7 +709,8 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i ) query = update.callback_query await query.edit_message_text(text) - if ADMIN_CHAT_ID: + admin_chat_id = store.get_admin_chat_id() + if admin_chat_id: order_type = order_type_name_from_key(order.type_key) admin_text = ( f"Новый заказ #{order.order_id}\n" @@ -623,7 +721,7 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i f"Сумма: {order.price} ₽" ) try: - await context.bot.send_message(ADMIN_CHAT_ID, admin_text) + await context.bot.send_message(admin_chat_id, admin_text) except Exception as exc: # pragma: no cover - depends on Telegram API logger.error("Failed to notify admin: %s", exc) return await show_main_menu(update, context, "Хотите оформить еще одну работу?") @@ -806,10 +904,11 @@ async def receive_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE) - store.add_feedback(user.id, feedback) log_user_action(update, "feedback_left") await update.message.reply_text("Спасибо! Мы ценим обратную связь.") - if ADMIN_CHAT_ID: + admin_chat_id = store.get_admin_chat_id() + if admin_chat_id: try: await context.bot.send_message( - ADMIN_CHAT_ID, + admin_chat_id, f"Новый отзыв от {user.id} ({user.username or user.full_name}):\n{feedback}", ) except Exception as exc: # pragma: no cover @@ -848,7 +947,8 @@ async def show_faq_item(update: Update, idx: int) -> int: async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: user = update.effective_user - if not user or user.id != ADMIN_CHAT_ID: + admin_chat_id = store.get_admin_chat_id() + if not user or user.id != admin_chat_id: if update.message: await update.message.reply_text("Админ-панель доступна только владельцу бота.") elif update.callback_query: @@ -859,6 +959,7 @@ async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> [InlineKeyboardButton("📋 Последние заказы", callback_data="admin:orders")], [InlineKeyboardButton("💰 Режим ценообразования", callback_data="admin:pricing")], [InlineKeyboardButton("📤 Экспорт в Excel", callback_data="admin:export")], + [InlineKeyboardButton("♻️ Обновить статус", callback_data="admin:status_list")], [InlineKeyboardButton("🗂 Последние действия", callback_data="admin:logs")], [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], ] @@ -927,7 +1028,9 @@ async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in return STATE_NAVIGATION await update.callback_query.edit_message_text("Отправляю файл с заказами…") try: - await context.bot.send_document(ADMIN_CHAT_ID, document=path.open("rb")) + admin_chat_id = store.get_admin_chat_id() + if admin_chat_id: + await context.bot.send_document(admin_chat_id, document=path.open("rb")) finally: path.unlink(missing_ok=True) await update.callback_query.edit_message_text( @@ -956,6 +1059,98 @@ async def admin_show_logs(update: Update) -> int: return STATE_NAVIGATION +async def admin_list_status_targets(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + records = store.list_recent_orders(limit=12) + if not records: + await update.callback_query.edit_message_text( + "Заказов пока нет.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]), + ) + return STATE_NAVIGATION + keyboard: List[List[InlineKeyboardButton]] = [] + for record in records: + order_id = int(record.get("order_id", 0)) + user_id = int(record.get("user_id", 0)) + name = order_type_name_from_record(record) + status_key = str(record.get("status_key", "")) or "new" + status_label = record.get("status") or ORDER_STATUS_TITLES.get(status_key, "—") + label = f"#{order_id} — {name} ({status_label})" + if len(label) > 60: + label = label[:57] + "…" + callback = f"admin:status_select:{user_id}:{order_id}" + keyboard.append([InlineKeyboardButton(label, callback_data=callback)]) + keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]) + await update.callback_query.edit_message_text( + "Выберите заказ, чтобы обновить статус:", + reply_markup=InlineKeyboardMarkup(keyboard), + ) + return STATE_NAVIGATION + + +async def admin_select_status( + update: Update, context: ContextTypes.DEFAULT_TYPE, user_id: int, order_id: int +) -> int: + context.user_data["admin_status_target"] = {"user_id": user_id, "order_id": order_id} + record = store.get_order(user_id, order_id) + if record is None: + # Запросили несуществующий заказ, возвращаемся к списку. + context.user_data.pop("admin_status_target", None) + await update.callback_query.edit_message_text( + "Заказ не найден. Возможно, он был удален.", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:status_list")]]), + ) + return STATE_NAVIGATION + original_status_key = str(record.get("status_key", "new") or "new") + original_status_title = record.get("status") or ORDER_STATUS_TITLES.get(original_status_key, "—") + order_name = order_type_name_from_record(record) + keyboard = [ + [InlineKeyboardButton(title, callback_data=f"admin:status_apply:{key}")] + for key, title in ORDER_STATUS_TITLES.items() + ] + keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="admin:status_list")]) + text = ( + f"Заказ #{order_id} — {order_name}\n" + f"Текущий статус: {original_status_title}\n" + "Выберите новый статус:" + ) + await update.callback_query.edit_message_text( + text, reply_markup=InlineKeyboardMarkup(keyboard) + ) + return STATE_NAVIGATION + + +async def admin_apply_status( + update: Update, context: ContextTypes.DEFAULT_TYPE, status_key: str +) -> int: + target = context.user_data.get("admin_status_target") + if not target: + return await admin_list_status_targets(update, context) + user_id = int(target.get("user_id", 0)) + order_id = int(target.get("order_id", 0)) + status_title = ORDER_STATUS_TITLES.get(status_key, status_key) + record = store.update_order_status(user_id, order_id, status_key, status_title) + if not record: + await update.callback_query.edit_message_text( + "Не удалось обновить статус (заказ не найден).", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:status_list")]]), + ) + return STATE_NAVIGATION + log_user_action(update, f"admin_status:{order_id}:{status_key}") + await update.callback_query.edit_message_text( + f"Статус заказа #{order_id} обновлен: {status_title}", + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В меню", callback_data="admin:menu")]]), + ) + try: + await context.bot.send_message( + user_id, + f"Статус вашего заказа #{order_id}: {status_title}", + ) + except Exception as exc: # pragma: no cover - зависит от Telegram API + logger.warning("Could not notify user %s about status change: %s", user_id, exc) + context.user_data.pop("admin_status_target", None) + return STATE_NAVIGATION + + async def handle_admin_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: state = context.user_data.pop("admin_state", None) if state == "pricing_mode": @@ -1039,6 +1234,18 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return await admin_export(update, context) if data == "admin:logs": return await admin_show_logs(update) + if data == "admin:status_list": + return await admin_list_status_targets(update, context) + if data.startswith("admin:status_select:"): + try: + _, _, user_str, order_str = data.split(":", maxsplit=3) + return await admin_select_status(update, context, int(user_str), int(order_str)) + except (ValueError, IndexError): + await update.callback_query.answer("Не удалось распознать заказ", show_alert=True) + return STATE_NAVIGATION + if data.startswith("admin:status_apply:"): + status_key = data.split(":", maxsplit=2)[2] + return await admin_apply_status(update, context, status_key) await query.edit_message_text("Команда не распознана. Возвращаюсь в меню.") return await show_main_menu(update, context) @@ -1046,15 +1253,24 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: log_user_action(update, "start") user = update.effective_user context.user_data.pop("order_draft", None) + promoted_text = "" + admin_chat_id = store.get_admin_chat_id() + if user and admin_chat_id == 0: + store.set_admin_chat_id(user.id, user.username or user.full_name or "") + admin_chat_id = user.id + promoted_text = ( + "\n\nВы назначены администратором бота. Используйте /admin для управления заказами." + ) if context.args: payload = context.args[0] if payload.isdigit(): referrer_id = int(payload) if referrer_id != user.id and store.add_referral(referrer_id, user.id): - if ADMIN_CHAT_ID: + admin_id = store.get_admin_chat_id() + if admin_id: try: await context.bot.send_message( - ADMIN_CHAT_ID, + admin_id, f"Новый реферал: {user.id} (пригласил {referrer_id})", ) except Exception as exc: # pragma: no cover @@ -1063,6 +1279,10 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: f"👋 Привет, {html.escape(user.first_name or user.full_name or 'друг')}!\n\n" "Я помогу оформить любую учебную работу: от самостоятельной до магистерской." ) + if WELCOME_MESSAGE: + greeting += f"\n{WELCOME_MESSAGE}" + if promoted_text: + greeting += promoted_text if update.message: await update.message.reply_text(greeting) return await show_main_menu(update, context, "Готовы сделать заказ?") @@ -1083,9 +1303,10 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: logger.exception("Unhandled error: %s", context.error) - if ADMIN_CHAT_ID: + admin_chat_id = store.get_admin_chat_id() + if admin_chat_id: try: - await context.bot.send_message(ADMIN_CHAT_ID, f"Ошибка в боте: {context.error}") + await context.bot.send_message(admin_chat_id, f"Ошибка в боте: {context.error}") except Exception: # pragma: no cover pass diff --git a/config.py b/config.py new file mode 100644 index 0000000..a8f85ae --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +"""Static configuration for the GIPSR Telegram bot.""" + +# === Telegram credentials === +# Пользователь просил полностью готовый конфиг, поэтому токен уже прописан. +BOT_TOKEN: str = "7387413773:AAFgKfuf-REN5cn1ZXmCQg" + +# === Администратор по умолчанию === +# 0 означает, что первый человек, написавший /start, станет админом. +DEFAULT_ADMIN_CHAT_ID: int = 0 +DEFAULT_ADMIN_USERNAME: str = "" + +# === Базовые настройки проекта === +PROJECT_NAME: str = "gipsr_bot" +MANAGER_CONTACT_URL: str = "https://t.me/Thisissaymoon" +DEFAULT_PRICING_MODE: str = "light" + +# Предопределенные статусы заказа для админ-панели. +ORDER_STATUS_TITLES = { + "new": "🟡 Новый заказ", + "in_progress": "🔧 В работе", + "waiting_payment": "💳 Ожидает оплаты", + "revision": "🔁 На доработке", + "done": "✅ Готово к выдаче", +} + +# Текст приветствия, дополняющий стандартный /start. +WELCOME_MESSAGE: str = ( + "Мы оформляем самостоятельные, курсовые, дипломные и магистерские работы" + " под ключ. Просто выберите тип заказа — и менеджер свяжется с вами." +) diff --git a/scripts/autodeploy.sh b/scripts/autodeploy.sh new file mode 100755 index 0000000..ff77399 --- /dev/null +++ b/scripts/autodeploy.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "[gipsr_bot] Скрипт autodeploy нужно запускать от имени root." >&2 + exit 1 +fi + +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +LOG_DIR="$APP_DIR/logs" +LOG_FILE="$LOG_DIR/autodeploy.log" + +mkdir -p "$LOG_DIR" + +timestamp() { + date +"%Y-%m-%d %H:%M:%S" +} + +log() { + local message="$1" + printf '[%s] %s\n' "$(timestamp)" "$message" | tee -a "$LOG_FILE" +} + +log "Запуск автоматической установки GIPSR Bot..." + +if command -v apt-get >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + log "Обновляем список пакетов..." + apt-get update -y >>"$LOG_FILE" 2>&1 + log "Устанавливаем python3-venv, python3-pip и systemd-tools..." + apt-get install -y python3 python3-venv python3-pip systemd >>"$LOG_FILE" 2>&1 +else + log "apt-get не найден. Предполагаем, что зависимости уже установлены." +fi + +log "Создаем виртуальное окружение и ставим зависимости..." +bash "$APP_DIR/scripts/install.sh" >>"$LOG_FILE" 2>&1 + +SERVICE_PATH="/etc/systemd/system/gipsrbot.service" +log "Создаем systemd unit $SERVICE_PATH" +cat <"$SERVICE_PATH" +[Unit] +Description=GIPSR Telegram bot +After=network.target + +[Service] +Type=simple +WorkingDirectory=$APP_DIR +Environment=PYTHONUNBUFFERED=1 +ExecStart=$APP_DIR/venv/bin/python $APP_DIR/bot.py +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +SYSTEMD + +log "Перезапускаем systemd и включаем службу..." +systemctl daemon-reload +systemctl enable gipsrbot.service >>"$LOG_FILE" 2>&1 +systemctl restart gipsrbot.service + +log "Текущий статус службы:" +systemctl status gipsrbot.service --no-pager >>"$LOG_FILE" 2>&1 || true +systemctl status gipsrbot.service --no-pager + +log "Последние строки журнала сервиса:" +journalctl -u gipsrbot.service -n 20 --no-pager || true + +log "Готово! Бот запущен и будет стартовать автоматически при перезагрузке." From 8026874142cf6ffe37b1573d5f14419f485b2fef Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 02:14:39 +0300 Subject: [PATCH 4/9] Support file-based deployment and admin restart --- .env | 4 +- .env.example | 5 +- README.md | 170 ++++++++++++++++-------------------------- bot.py | 37 +++++++++ cloud/cloud-init.yaml | 11 +++ scripts/autodeploy.sh | 13 +++- 6 files changed, 128 insertions(+), 112 deletions(-) create mode 100644 cloud/cloud-init.yaml diff --git a/.env b/.env index 230398d..1393ce4 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -TELEGRAM_BOT_TOKEN=7387413773:AAFgKfuf-REN5cn1ZXmCQg +TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN_HERE ADMIN_CHAT_ID=0 -MANAGER_CONTACT=https://t.me/Thisissaymoon +MANAGER_CONTACT=https://t.me/your_manager_username diff --git a/.env.example b/.env.example index 9ac06c5..439d21f 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ # Скопируйте этот файл в .env и заполните реальными данными. -TELEGRAM_BOT_TOKEN=0000000000:REPLACE_ME -ADMIN_CHAT_ID=123456789 +TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN_HERE +ADMIN_CHAT_ID=0 +MANAGER_CONTACT=https://t.me/your_manager_username diff --git a/README.md b/README.md index 5e030fa..0f5e786 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,93 @@ # Gipsr Bot -Телеграм-бот для оформления учебных работ с расширенным функционалом: +Телеграм-бот для оформления учебных работ. Пользователи могут оформить заказ, посчитать стоимость, оставить отзыв и следить за статусами, а администратор управляет заявками, прайсами и экспортом в Excel прямо из чата. -- интерактивное меню с заказом в несколько шагов и доп. услугами; -- динамический калькулятор стоимости с учетом сроков и сложности; -- личный кабинет с историей заказов и реферальной ссылкой; -- раздел FAQ и сбор отзывов; -- админ-панель со статистикой, экспортом, переключением режима ценообразования, обновлением статусов заказов и логами. +## Как развернуть без доступа к консоли -## Быстрый старт (локально) +Все, что вам нужно — изменить один файл и загрузить папку проекта на сервер через FileZilla, затем вставить готовый скрипт в панель cloud-init и перезагрузить машину. -1. Проверьте файл `.env` — мы уже добавили его в репозиторий с рабочим токеном и `ADMIN_CHAT_ID=0`. - При необходимости замените значения на свои: +### 1. Подготовьте `.env` - ```bash - nano .env - ``` +1. Откройте файл `.env` в корне проекта (можно в любом текстовом редакторе). +2. Замените `TELEGRAM_BOT_TOKEN` на токен вашего бота. +3. Если хотите сразу назначить администратора, укажите его числовой ID в `ADMIN_CHAT_ID`. Можно оставить `0` — первый человек, отправивший `/start`, станет админом автоматически. +4. При необходимости обновите ссылку менеджера в `MANAGER_CONTACT`. +5. Сохраните файл. -2. Установите зависимости через подготовленный скрипт (создает виртуальное окружение `venv/`): +> ⚠️ Скрипт автоматической установки не запустится, если в `.env` останется заглушка `PASTE_TELEGRAM_BOT_TOKEN_HERE`. - ```bash - ./scripts/install.sh - ``` +### 2. Какие файлы загружать на сервер -3. Запустите бота из виртуального окружения: +Перетащите в `/root/gipsr_bot` (как на вашем скриншоте) весь каталог проекта со следующими файлами: - ```bash - ./scripts/run.sh - ``` +| Файл/папка | Зачем нужен | +|------------|-------------| +| `bot.py` | основной код бота | +| `config.py` | дефолтные настройки, статусы и тексты | +| `.env` | ваши токены и контакты | +| `requirements.txt` | список Python-зависимостей | +| `scripts/` | вспомогательные скрипты установки и автозапуска | +| `deploy/supervisor.conf` | конфигурация supervisor (запасной вариант автозапуска) | +| `cloud/cloud-init.yaml` | готовый cloud-init скрипт для панели управления | +| `README.md` (опционально) | инструкция под рукой | -Бот автоматически создаст служебные директории `data/`, `logs/` и `clients/`, сохранит данные о ценах, заказах и логах в JSON и будет писать технические сообщения в `logs/bot.log`. Первый пользователь, который отправит `/start`, автоматически становится администратором и получает доступ к команде `/admin`. +Сохраняйте структуру каталогов (`scripts`, `cloud`, `deploy` и т. д.). -## Что загрузить на сервер +### 3. Загрузите проект через FileZilla -Передача файлов через SFTP/FTP (например, FileZilla) сводится к загрузке директории проекта. Минимальный набор: +1. Подключитесь к серверу в FileZilla. +2. В правой части окна перейдите в `/root/gipsr_bot`. +3. Перетащите все файлы из локальной папки проекта в окно FileZilla. Если папки уже есть на сервере, разрешите замену, чтобы обновить код. -- `bot.py` — основной код бота; -- `requirements.txt` — список зависимостей; -- `scripts/install.sh`, `scripts/run.sh` и `scripts/autodeploy.sh` — автоматическая установка, запуск и настройка службы; -- `deploy/supervisor.conf` — конфигурация службы `supervisor` для автозапуска; -- `.env` — уже заполненный файл с токеном и настройками администратора (замените при необходимости). +### 4. Настройте cloud-init -Дополнительно можно загрузить `README.md`, чтобы под рукой была инструкция. +1. В панели хостинга откройте редактор cloud-init. +2. Скопируйте содержимое файла [`cloud/cloud-init.yaml`](cloud/cloud-init.yaml) и вставьте в поле скрипта. +3. Убедитесь, что путь `/root/gipsr_bot` совпадает с путём, куда вы загрузили файлы (если нет — отредактируйте скрипт). +4. Сохраните изменения. -## Настройка сервера через SSH +### 5. Перезагрузите сервер -После загрузки файлов подключитесь по SSH и выполните: +После перезагрузки cloud-init автоматически: -```bash -cd /root/gipsr_bot -chmod +x scripts/install.sh scripts/run.sh -./scripts/install.sh -``` +- установит `python3`, `pip`, `venv` и `supervisor` (если их ещё нет); +- запустит `scripts/autodeploy.sh`, который создаст виртуальное окружение, поставит зависимости и настроит `systemd`-службу `gipsrbot.service`; +- стартует бота и включит автозапуск при следующей перезагрузке. -Скрипт создаст окружение `venv/` и установит зависимости. При необходимости повторите запуск после обновления `requirements.txt`. +Логи установки сохраняются в `/root/gipsr_bot/logs/autodeploy.log`. -## Автоматический деплой (systemd) +## Как убедиться, что бот запустился -Чтобы не выполнять настройки вручную, достаточно один раз запустить подготовленный скрипт автодеплоя: +1. Скачайте файл `/root/gipsr_bot/logs/autodeploy.log` через FileZilla. В конце должна быть строка `Готово! Бот запущен...`. Если увидите сообщение про `.env`, обновите токен и перезагрузите сервер. +2. Скачайте `/root/gipsr_bot/logs/bot.log`. При успешном запуске в нём будут строки вида `Application started`. +3. Откройте бота в Telegram, отправьте `/start` и пройдите меню. Первый человек, кто сделает это, станет администратором. -```bash -cd /root/gipsr_bot -chmod +x scripts/autodeploy.sh -sudo ./scripts/autodeploy.sh -``` +## Управление без консоли -Скрипт: +- `/admin` — открыть админ-панель (статистика, последние заказы, экспорт, смена режимов цен и т. д.). +- `/restart_bot` или кнопка «🔄 Перезапустить бота» в админ-панели — безопасно перезапускает службу через systemd. Используйте после загрузки обновлённого кода. +- Все ошибки дублируются в личные сообщения администратору и записываются в `logs/bot.log`. -- устанавливает системные зависимости (`python3`, `python3-venv`, `python3-pip`, `systemd`); -- создает виртуальное окружение и ставит все пакеты из `requirements.txt`; -- генерирует `systemd`-юнит `gipsrbot.service`, включает автозапуск и сразу стартует бота; -- выводит статус службы и последние строки журнала `journalctl`. +## Как обновить код или настройки -После завершения бот уже работает и автоматически стартует при перезагрузке сервера. +1. Измените файлы локально (например, обновите прайс или тексты). +2. Загрузите изменённые файлы в `/root/gipsr_bot`, разрешив замену. +3. В Telegram (из админского аккаунта) отправьте команду `/restart_bot`, чтобы служба перечитала код. Если бот не отвечает, просто перезагрузите сервер — service стартует автоматически. -## Настройка Supervisor +## Структура проекта -> Опционально, если вместо systemd вы используете supervisor. +- `bot.py` — логика бота, сценарии заказов, калькулятор, админ-панель. +- `config.py` — константы и дефолтные тексты. +- `.env` — приватные настройки, которые читает `python-dotenv`. +- `scripts/` — утилиты: установка зависимостей, запуск и автодеплой через systemd. +- `deploy/` — пример конфигурации supervisor. +- `cloud/cloud-init.yaml` — готовый cloud-init скрипт для вашего хостинга. +- `data/`, `logs/`, `clients/` — создаются автоматически при первом запуске и содержат JSON-файлы с заказами, логами и выгрузками. -1. Скопируйте конфиг в систему (обновите `directory` и `command`, если путь отличается): +## Частые ошибки и диагностика - ```bash - sudo cp /root/gipsr_bot/deploy/supervisor.conf /etc/supervisor/conf.d/gipsr_bot.conf - ``` +- **Бот не запускается, в логах ошибка про токен.** Проверьте `.env`: токен должен быть без кавычек, без пробелов, в формате `123456:ABC-DEF`. +- **В FileZilla нет папки `logs/`.** Она появляется после первого запуска. Если её нет, скачайте `logs/autodeploy.log` — возможно, установка не завершилась (чаще всего из-за пустого `.env`). +- **Нужно сменить администратора.** Обнулите `ADMIN_CHAT_ID` в `data/settings.json` (скачайте файл, замените значение на 0, загрузите обратно) и перезапустите бота. Первый пользователь снова станет админом. -2. Примените конфигурацию и проверьте статус: - - ```bash - sudo supervisorctl reread - sudo supervisorctl update - sudo supervisorctl status gipsr_bot - ``` - -Логи `supervisor` сохраняются в `logs/supervisor.out.log` и `logs/supervisor.err.log`, а рабочие сообщения бота — в `logs/bot.log`. - -## Резервный запуск вручную - -Для диагностики можно запускать бота напрямую: - -```bash -source venv/bin/activate -python bot.py -``` - -Выход из режима — `Ctrl+C`. - -## Основные сценарии - -- `/start` — приветствие и переход в главное меню. -- «📝 Сделать заказ» — пошаговое оформление заказа с выбором доп. услуг и итоговым подтверждением. -- «💲 Прайс-лист» и «🧮 Калькулятор» — просмотр тарифов и мгновенный расчет стоимости. -- «👤 Профиль» — статистика по заказам, рефералам и быстрый доступ к отзывам. -- «❓ FAQ» — ответы на популярные вопросы. -- `/admin` — админ-панель (доступна первому пользователю, запустившему бота). Можно переключать режимы цен, экспортировать - заявки, просматривать логи и обновлять статусы заказов. - -## Структура данных - -- `.env` — готовый файл с токеном и контактами (перезапишите при необходимости). -- `config.py` — статические параметры проекта, статусы заказов и ссылка на менеджера. Значения из `.env` имеют приоритет. -- `data/settings.json` — после первого запуска сюда сохраняется ID администратора и режим ценообразования. -- `data/prices.json` — базовые цены на работы. -- `data/orders.json` — оформленные заказы пользователей. -- `data/referrals.json` — связи реферер → приглашенные пользователи. -- `data/feedbacks.json` — отзывы клиентов. -- `data/user_logs.json` — история действий пользователей. - -## Тестирование - -Проверить корректность синтаксиса можно командой: - -```bash -python -m compileall bot.py -``` - -Для интеграционного тестирования используйте тестовый токен бота и локальный запуск. +Разработчики могут дополнительно проверить синтаксис локально командой `python -m compileall bot.py` — это необязательно для продакшена, но полезно перед обновлением. diff --git a/bot.py b/bot.py index 7624469..53061f2 100644 --- a/bot.py +++ b/bot.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import html import json import logging @@ -379,6 +380,17 @@ def update_order_status( STATE_NAVIGATION, STATE_ORDER_TOPIC, STATE_ORDER_REQUIREMENTS, STATE_FEEDBACK, STATE_ADMIN = range(5) + +def schedule_restart(delay: float = 1.5) -> None: + loop = asyncio.get_running_loop() + + def _shutdown() -> None: + logger.warning("Stopping bot process by admin request") + os._exit(0) + + loop.call_later(delay, _shutdown) + + def log_user_action(update: Update, action: str) -> None: user = update.effective_user if not user: @@ -961,6 +973,7 @@ async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> [InlineKeyboardButton("📤 Экспорт в Excel", callback_data="admin:export")], [InlineKeyboardButton("♻️ Обновить статус", callback_data="admin:status_list")], [InlineKeyboardButton("🗂 Последние действия", callback_data="admin:logs")], + [InlineKeyboardButton("🔄 Перезапустить бота", callback_data="admin:restart")], [InlineKeyboardButton("⬅️ Главное меню", callback_data="main:root")], ] text = "🔐 Админ-панель. Выберите действие:" @@ -971,6 +984,27 @@ async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return STATE_NAVIGATION +async def restart_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + user = update.effective_user + admin_chat_id = store.get_admin_chat_id() + if not user or user.id != admin_chat_id: + if update.message: + await update.message.reply_text("Команда доступна только администратору.") + elif update.callback_query: + await update.callback_query.answer("Нет доступа", show_alert=True) + return STATE_NAVIGATION + source = "callback" if update.callback_query else "command" + notify_text = "Перезапускаю бота. Он вернётся через несколько секунд." + if update.callback_query: + await update.callback_query.answer("Перезапускаю…") + await context.bot.send_message(chat_id=user.id, text=notify_text) + elif update.message: + await update.message.reply_text(notify_text) + logger.warning("Restart requested by admin %s via %s", user.id, source) + schedule_restart() + return STATE_NAVIGATION + + async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: log_user_action(update, "admin_command") return await show_admin_menu(update, context) @@ -1234,6 +1268,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return await admin_export(update, context) if data == "admin:logs": return await admin_show_logs(update) + if data == "admin:restart": + return await restart_bot(update, context) if data == "admin:status_list": return await admin_list_status_targets(update, context) if data.startswith("admin:status_select:"): @@ -1341,6 +1377,7 @@ def build_application(): application.add_handler(conv_handler) application.add_handler(CommandHandler("help", help_command)) application.add_handler(CommandHandler("admin", admin_command)) + application.add_handler(CommandHandler("restart_bot", restart_bot)) application.add_error_handler(error_handler) return application diff --git a/cloud/cloud-init.yaml b/cloud/cloud-init.yaml new file mode 100644 index 0000000..58626a3 --- /dev/null +++ b/cloud/cloud-init.yaml @@ -0,0 +1,11 @@ +#cloud-config +package_update: true +package_upgrade: true +packages: + - python3 + - python3-pip + - python3-venv + - systemd + - supervisor +runcmd: + - [bash, -lc, 'if [ -d /root/gipsr_bot ]; then cd /root/gipsr_bot && bash scripts/autodeploy.sh; else echo "[gipsr_bot] Папка /root/gipsr_bot не найдена. Загрузите проект через FileZilla и перезагрузите сервер." >&2; fi'] diff --git a/scripts/autodeploy.sh b/scripts/autodeploy.sh index ff77399..2a8c6b6 100755 --- a/scripts/autodeploy.sh +++ b/scripts/autodeploy.sh @@ -9,6 +9,7 @@ fi APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOG_DIR="$APP_DIR/logs" LOG_FILE="$LOG_DIR/autodeploy.log" +ENV_FILE="$APP_DIR/.env" mkdir -p "$LOG_DIR" @@ -33,6 +34,16 @@ else log "apt-get не найден. Предполагаем, что зависимости уже установлены." fi +if [[ ! -f "$ENV_FILE" ]]; then + log "Файл .env не найден. Загрузите его вместе с проектом и повторите установку." + exit 1 +fi + +if grep -Eq 'PASTE_TELEGRAM_BOT_TOKEN_HERE|REPLACE_ME|0000000000' "$ENV_FILE"; then + log "В .env все еще стоит шаблон TELEGRAM_BOT_TOKEN. Замените его на реальный токен бота." + exit 1 +fi + log "Создаем виртуальное окружение и ставим зависимости..." bash "$APP_DIR/scripts/install.sh" >>"$LOG_FILE" 2>&1 @@ -62,7 +73,7 @@ systemctl restart gipsrbot.service log "Текущий статус службы:" systemctl status gipsrbot.service --no-pager >>"$LOG_FILE" 2>&1 || true -systemctl status gipsrbot.service --no-pager +systemctl status gipsrbot.service --no-pager || true log "Последние строки журнала сервиса:" journalctl -u gipsrbot.service -n 20 --no-pager || true From 942ad428585417721aa69c5ebcca4ef45802b7f2 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 03:39:34 +0300 Subject: [PATCH 5/9] Enhance sales flow with attachments, bonuses and urgent pricing --- README.md | 14 +- bot.py | 664 ++++++++++++++++++++++++++++++++++++++++++++---------- config.py | 9 +- 3 files changed, 567 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 0f5e786..1a66447 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ # Gipsr Bot -Телеграм-бот для оформления учебных работ. Пользователи могут оформить заказ, посчитать стоимость, оставить отзыв и следить за статусами, а администратор управляет заявками, прайсами и экспортом в Excel прямо из чата. +Телеграм-бот для оформления учебных работ. Пользователи оформляют заявки, прикрепляют методички любого формата и получают персональные предложения, а администратор отслеживает всё происходящее в одной панели. + +**Что внутри:** + +- интерактивный заказ с выбором типа, срочности (в том числе «🔥 24 часа») и объёма работы; +- автоматическое напоминание прикрепить задания и методические рекомендации, поддержка файлов любого типа; +- автоскидка 7% на первый заказ (до 3000 ₽), учёт бонусов и реферальных вознаграждений по 300 ₽; +- обновлённый прайс с нормоконтролем, презентацией+речью и личным куратором; +- «кайфовый» профиль клиента: статус, бонусный баланс, история заказов и реферальная ссылка; +- расширенная админ-панель: статистика с рефералами, журнал действий, смена статусов с уведомлением клиента и экспорт заказов. ## Как развернуть без доступа к консоли @@ -10,7 +19,7 @@ 1. Откройте файл `.env` в корне проекта (можно в любом текстовом редакторе). 2. Замените `TELEGRAM_BOT_TOKEN` на токен вашего бота. -3. Если хотите сразу назначить администратора, укажите его числовой ID в `ADMIN_CHAT_ID`. Можно оставить `0` — первый человек, отправивший `/start`, станет админом автоматически. +3. По умолчанию права администратора уже закреплены за Анной (`ID 2019512207`, `@Nnnnnnnnn234567`). Если нужен другой владелец, укажите его числовой ID в `ADMIN_CHAT_ID`. 4. При необходимости обновите ссылку менеджера в `MANAGER_CONTACT`. 5. Сохраните файл. @@ -65,6 +74,7 @@ ## Управление без консоли - `/admin` — открыть админ-панель (статистика, последние заказы, экспорт, смена режимов цен и т. д.). +- Внутри админки доступны разделы «Бонусы и рефералы», журнал действий пользователей, экспорт заказов и ручное изменение статусов с уведомлением клиента. - `/restart_bot` или кнопка «🔄 Перезапустить бота» в админ-панели — безопасно перезапускает службу через systemd. Используйте после загрузки обновлённого кода. - Все ошибки дублируются в личные сообщения администратору и записываются в `logs/bot.log`. diff --git a/bot.py b/bot.py index 53061f2..dfa8f45 100644 --- a/bot.py +++ b/bot.py @@ -5,7 +5,7 @@ import json import logging import os -from dataclasses import dataclass, asdict +from dataclasses import dataclass, asdict, field from datetime import datetime, timedelta from pathlib import Path from typing import Dict, Iterable, List, Optional @@ -76,109 +76,147 @@ logger.addHandler(file_handler) DEFAULT_PRICES: Dict[str, Dict[str, int]] = { - "self": {"base": 1500, "min": 1500, "max": 3500}, - "course_theory": {"base": 7000, "min": 6000, "max": 11000}, - "course_empirical": {"base": 11000, "min": 9500, "max": 16000}, - "vkr": {"base": 32000, "min": 28000, "max": 45000}, - "master": {"base": 42000, "min": 36000, "max": 60000}, + "self": {"base": 2500, "min": 2500, "max": 4200}, + "course_theory": {"base": 10000, "min": 10000, "max": 16500}, + "course_empirical": {"base": 16500, "min": 15000, "max": 24000}, + "normcontrol": {"base": 5000, "min": 5000, "max": 7000}, + "vkr": {"base": 32000, "min": 30000, "max": 48000}, + "master": {"base": 40000, "min": 40000, "max": 65000}, } +UNIVERSITIES_EXAMPLES = "МГУ, СПбГУ, ВШЭ, РАНХиГС, УрФУ и другие ведущие вузы России" + ORDER_TYPES: Dict[str, Dict[str, object]] = { "self": { "name": "Самостоятельная работа", "icon": "📝", - "description": "Быстрые задания: эссе, контрольные, отчеты. Выполняем качественно и в срок.", - "details": "Лучший выбор для работ до 20 страниц. Подбираем автора по предмету и требованиям.", + "description": "Короткие задания, контрольные, эссе и отчёты. Выполняем точно по методичкам и быстро вовлекаем автора в задачу.", + "details": "Подходит для работ до 20 страниц, готовим за вечер и дожидаемся подтверждения от преподавателя.", "examples": [ - "Эссе по философии", - "Контрольная по экономике", - "Реферат по истории", + "Эссе по психологии", + "Контрольная по педагогике", + "Отчёт по социальной работе", ], }, "course_theory": { "name": "Курсовая (теория)", "icon": "📘", - "description": "Теоретическая курсовая с глубоким обзором литературы и четкой структурой.", - "details": "Формируем оглавление, методологию и источники по стандартам вашего вуза.", + "description": "Теоретическая курсовая с понятной логикой, актуальными источниками и акцентом на научную новизну.", + "details": "Оформляем главы, список литературы и приложения с учётом требований кафедры. Результаты подбираем под вашу задачу.", "examples": [ - "История экономических учений", - "Методики преподавания", - "Психология личности", + "Психология развития личности", + "Современные методики логопедии", + "Социальная работа с семьёй", ], }, "course_empirical": { "name": "Курсовая (теория + эмпирика)", "icon": "📊", - "description": "Курсовая с практической частью, опросами, анализом данных и выводами.", - "details": "Подготовим инструментарий, соберем данные и оформим аналитическую главу.", + "description": "Полный курс с аналитикой: теория, исследование, результаты и выводы. Можем придумать данные или использовать ваши.", + "details": "Разрабатываем инструментарий, проводим опросы/тесты, оформляем таблицы и графики. Всё готово к защите.", + "examples": [ + "Диагностика конфликтов в организации", + "Анализ эффективности соцслужб", + "Исследование логопедических методик", + ], + }, + "normcontrol": { + "name": "Нормоконтроль", + "icon": "📏", + "description": "Проверим оформление, ГОСТ, ссылки и плагины. Всё приведём к стандарту вуза.", + "details": "Шаблоны, оглавление, списки и приложения — всё выверено под требования преподавателя.", "examples": [ - "Опрос удовлетворенности клиентов", - "Анализ HR-процессов", - "Исследование маркетинговых стратегий", + "Проверка диплома перед сдачей", + "Форматирование курсовой", + "Адаптация статей под ГОСТ", ], }, "vkr": { "name": "Дипломная работа (ВКР)", "icon": "🎓", - "description": "Полный цикл подготовки выпускной квалификационной работы.", - "details": "План, теория, эмпирика, презентация и речь. Поддержка до защиты.", + "description": "Выпускная работа под ключ: план, теория, практическая часть и защита. Сопровождаем до финального отзыва.", + "details": "Готовим презентацию, речь и сопровождаем консультациями. Опыт — 6 лет, более 4000 успешных проектов в {UNIVERSITIES_EXAMPLES}.", "examples": [ - "Социально-психологическая адаптация", - "Бизнес-план компании", - "Маркетинговая стратегия бренда", + "Психологическое сопровождение персонала", + "Программа развития соцслужбы", + "Корпоративные конфликты и их решения", ], }, "master": { "name": "Магистерская диссертация", "icon": "🔍", - "description": "Продвинутое исследование с научной новизной и публикациями.", - "details": "Разработаем методологию, проведем исследование, подготовим статьи и презентацию.", + "description": "Исследование уровня магистратуры: глубокая аналитика, научная новизна и публикации.", + "details": "Согласовываем план, проводим исследования, готовим статьи, презентацию и речь. Сопровождаем до защиты.", "examples": [ - "Data-driven подходы в образовании", - "Инновации в социальной работе", - "Комплексные исследования экологии", + "Инновации в социальной сфере", + "Психологические технологии сопровождения", + "Программы профилактики конфликтов", ], }, } FAQ_ITEMS: List[Dict[str, str]] = [ { - "question": "Как сделать заказ?", - "answer": "Выберите '📝 Сделать заказ', укажите тип работы, тему, срок и требования. Менеджер свяжется для подтверждения.", + "question": "Как оформить заказ?", + "answer": "Нажмите '📝 Сделать заказ', последовательно выберите тип, срок, объём и требования. Можно прикреплять любые файлы прямо в чат.", }, { - "question": "Как рассчитывается стоимость?", - "answer": "Стоимость зависит от типа, срочности и сложности. Воспользуйтесь разделом '🧮 Калькулятор' для точного расчета.", + "question": "С какими сроками работаете?", + "answer": "Берём срочные заказы от 24 часов с повышающим коэффициентом. Чем раньше обратитесь — тем выгоднее.", }, { - "question": "Какие гарантии предоставляете?", - "answer": "Проверяем работы на антиплагиат, делаем бесплатные правки 14 дней и сопровождаем до успешной защиты.", + "question": "Какие скидки и бонусы?", + "answer": "На первый заказ действует скидка 7% (до 3000 ₽). За каждого приглашённого друга — +300 ₽ на бонусный счёт.", }, { - "question": "Есть ли скидки?", - "answer": "Первые клиенты получают -10%, за несколько заказов действуют дополнительные скидки и бонусы.", + "question": "Работаете ли с моим вузом?", + "answer": f"Да! За 6 лет мы делали работы для {UNIVERSITIES_EXAMPLES}. Подстроимся под ваши методички.", }, { - "question": "Как работает реферальная программа?", - "answer": "Поделитесь персональной ссылкой из профиля. За каждого приглашенного друга — 5% бонусов.", + "question": "Как передать методички и данные?", + "answer": "На этапе требований прикрепите файлы любого формата: Word, PDF, фото, аудио. Всё автоматически прилетает администратору.", }, { - "question": "Как отслеживать статус заказа?", - "answer": "Все статусы видны в профиле, плюс менеджер отправляет промежуточные отчеты и уведомления.", + "question": "Что с антиплагиатом?", + "answer": "Из-за детектора дубликатов Антиплагиат.ру мы не делаем предварительных проверок, но бесплатно переписываем до нужного процента. Для крупных работ правки — 6 месяцев.", }, ] UPSELL_OPTIONS: Dict[str, Dict[str, int]] = { - "prez": {"title": "Презентация", "price": 2000}, - "speech": {"title": "Речь для защиты", "price": 1000}, + "presentation_pack": {"title": "Презентация + речь", "price": 3000}, + "mentor": {"title": "Личный куратор с ежедневной связью", "price": 1500}, } +PAGE_OPTIONS: Dict[str, Dict[str, object]] = { + "20": {"label": "до 20 стр.", "multiplier": 1.0}, + "35": {"label": "до 35 стр.", "multiplier": 1.2}, + "50": {"label": "до 50 стр.", "multiplier": 1.35}, + "70": {"label": "70+ стр.", "multiplier": 1.55}, +} + +DEADLINE_CHOICES: List[tuple[int, str]] = [ + (1, "🔥 24 часа"), + (2, "⚡ 2 дня"), + (3, "⚡ 3 дня"), + (5, "5 дней"), + (7, "Неделя"), + (14, "2 недели"), + (21, "3 недели"), + (30, "Месяц"), +] + +FIRST_ORDER_DISCOUNT_RATE = 0.07 +FIRST_ORDER_DISCOUNT_CAP = 3000 +REFERRAL_BONUS_AMOUNT = 300 +LOYALTY_REWARD_RATE = 0.02 + SETTINGS_FILE = DATA_DIR / "settings.json" PRICES_FILE = DATA_DIR / "prices.json" REFERRALS_FILE = DATA_DIR / "referrals.json" ORDERS_FILE = DATA_DIR / "orders.json" FEEDBACKS_FILE = DATA_DIR / "feedbacks.json" USER_LOGS_FILE = DATA_DIR / "user_logs.json" +BONUSES_FILE = DATA_DIR / "bonuses.json" @dataclass @@ -188,9 +226,13 @@ class OrderRecord: topic: str deadline_days: int deadline_date: str + page_plan: str requirements: str + attachments: List[Dict[str, str]] = field(default_factory=list) upsells: List[str] status_key: str + base_price: int + discount: int price: int status: str created_at: str @@ -210,6 +252,18 @@ def __init__(self) -> None: if key not in loaded_settings: loaded_settings[key] = value changed = True + if ( + int(default_settings.get("admin_chat_id", 0)) + and int(loaded_settings.get("admin_chat_id", 0)) == 0 + ): + loaded_settings["admin_chat_id"] = default_settings["admin_chat_id"] + changed = True + if ( + default_settings.get("admin_username") + and not loaded_settings.get("admin_username") + ): + loaded_settings["admin_username"] = default_settings["admin_username"] + changed = True if changed: self._save_json(SETTINGS_FILE, loaded_settings) self.settings: Dict[str, object] = loaded_settings @@ -218,6 +272,7 @@ def __init__(self) -> None: self.orders: Dict[str, List[Dict[str, object]]] = self._load_json(ORDERS_FILE, {}) self.feedbacks: Dict[str, List[str]] = self._load_json(FEEDBACKS_FILE, {}) self.user_logs: Dict[str, List[Dict[str, str]]] = self._load_json(USER_LOGS_FILE, {}) + self.bonuses: Dict[str, Dict[str, object]] = self._load_json(BONUSES_FILE, {}) @staticmethod def _load_json(path: Path, default) -> object: @@ -267,8 +322,46 @@ def add_referral(self, referrer_id: int, new_user_id: int) -> bool: return False referred_list.append(new_user_id) self._save_json(REFERRALS_FILE, self.referrals) + self.add_bonus(referrer_id, REFERRAL_BONUS_AMOUNT, f"Реферал {new_user_id}") return True + def add_bonus(self, user_id: int, amount: int, reason: str) -> None: + if not amount: + return + user_key = str(user_id) + entry = self.bonuses.setdefault(user_key, {"balance": 0, "history": []}) + entry["balance"] = int(entry.get("balance", 0)) + int(amount) + history = entry.setdefault("history", []) + history.append( + { + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "amount": int(amount), + "reason": reason, + } + ) + self._save_json(BONUSES_FILE, self.bonuses) + + def get_bonus_info(self, user_id: int) -> Dict[str, object]: + entry = self.bonuses.get(str(user_id), {}) + return { + "balance": int(entry.get("balance", 0)), + "history": list(entry.get("history", [])), + } + + def list_referral_stats(self) -> List[Dict[str, object]]: + stats: List[Dict[str, object]] = [] + for user_id, referred in self.referrals.items(): + info = self.get_bonus_info(int(user_id)) + stats.append( + { + "user_id": int(user_id), + "count": len(referred), + "bonus": int(info.get("balance", 0)), + } + ) + stats.sort(key=lambda item: (item["bonus"], item["count"]), reverse=True) + return stats + def log_action(self, user_id: int, username: Optional[str], action: str) -> None: entry = { "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -353,11 +446,17 @@ def get_statistics(self) -> Dict[str, int]: continue active_orders += 1 unique_users = len(self.orders) + total_referrals = sum(len(refs) for refs in self.referrals.values()) + total_bonuses = int( + sum(int(info.get("balance", 0)) for info in self.bonuses.values()) + ) return { "orders": total_orders, "revenue": total_revenue, "active": active_orders, "users": unique_users, + "referrals": total_referrals, + "bonuses": total_bonuses, } def update_order_status( @@ -378,7 +477,7 @@ def update_order_status( store = DataStore() -STATE_NAVIGATION, STATE_ORDER_TOPIC, STATE_ORDER_REQUIREMENTS, STATE_FEEDBACK, STATE_ADMIN = range(5) +STATE_NAVIGATION, STATE_ORDER_TOPIC, STATE_ORDER_PAGES, STATE_ORDER_REQUIREMENTS, STATE_FEEDBACK, STATE_ADMIN = range(6) def schedule_restart(delay: float = 1.5) -> None: @@ -398,28 +497,37 @@ def log_user_action(update: Update, action: str) -> None: store.log_action(user.id, user.username, action) -def calculate_price(order_type: str, days_left: int, complexity: float = 1.0, upsells: Iterable[str] = ()) -> int: +def calculate_price( + order_type: str, + days_left: int, + page_multiplier: float = 1.0, + upsells: Iterable[str] = (), +) -> int: price_info = store.prices.get(order_type) or DEFAULT_PRICES.get(order_type) if not price_info: logger.warning("Unknown order type for pricing: %s", order_type) return 0 - price = int(price_info.get("base", 0) * complexity) + base_price = int(price_info.get("base", 0)) + subtotal = int(base_price * page_multiplier) mode = store.get_pricing_mode() - if mode == "hard": - if days_left < 7: - price = int(price * 1.3) - elif days_left < 15: - price = int(price * 1.15) - else: - if days_left < 3: - price = int(price * 1.3) - elif days_left < 7: - price = int(price * 1.15) + if days_left <= 1: + subtotal = int(subtotal * (1.8 if mode == "hard" else 1.65)) + elif days_left <= 2: + subtotal = int(subtotal * (1.6 if mode == "hard" else 1.45)) + elif days_left <= 3: + subtotal = int(subtotal * (1.35 if mode == "hard" else 1.25)) + elif days_left <= 5: + subtotal = int(subtotal * (1.2 if mode == "hard" else 1.15)) + elif days_left <= 7: + subtotal = int(subtotal * (1.15 if mode == "hard" else 1.1)) + elif days_left <= 14 and mode == "hard": + subtotal = int(subtotal * 1.05) + total = subtotal for upsell in upsells: option = UPSELL_OPTIONS.get(upsell) if option: - price += option["price"] - return price + total += option["price"] + return total def build_main_menu_keyboard() -> InlineKeyboardMarkup: @@ -455,7 +563,9 @@ def order_type_name_from_record(order: Dict[str, object]) -> str: return order_type_name_from_key(type_key if isinstance(type_key, str) else None) -def format_order_summary(draft: Dict[str, object], price: int) -> str: +def format_order_summary( + draft: Dict[str, object], base_price: int, final_price: int, discount_value: int +) -> str: order_type = ORDER_TYPES.get(draft.get("type_key")) or {} upsells = draft.get("upsells", []) upsell_lines = [] @@ -466,14 +576,25 @@ def format_order_summary(draft: Dict[str, object], price: int) -> str: upsell_text = "\n".join(upsell_lines) if upsell_lines else "—" deadline_days = int(draft.get("deadline_days", 0)) deadline_date = (datetime.now() + timedelta(days=deadline_days)).strftime("%d.%m.%Y") + page_label = draft.get("page_label") or get_page_option(str(draft.get("page_key"))).get("label") + attachments = draft.get("attachments", []) + attachments_text = ( + f"{len(attachments)} файл(ов)" if attachments else "можно прикрепить позже" + ) + discount_line = ( + f"Скидка новичка: −{discount_value} ₽\n" if discount_value else "" + ) return ( f"Проверим данные перед оформлением:\n\n" f"Тип: {order_type.get('icon', '')} {order_type.get('name', 'Неизвестно')}\n" f"Тема: {html.escape(str(draft.get('topic', 'не указана')))}\n" f"Срок: {deadline_days} дн. (до {deadline_date})\n" + f"Объём: {page_label}\n" f"Требования: {html.escape(str(draft.get('requirements', 'не указаны')))}\n" + f"Файлы: {attachments_text}\n" f"Доп. услуги: {upsell_text}\n\n" - f"Итого к оплате: {price} ₽" + f"Базовая стоимость: {base_price} ₽\n" + f"{discount_line}Итого к оплате: {final_price} ₽" ) @@ -489,7 +610,10 @@ async def get_bot_username(context: ContextTypes.DEFAULT_TYPE) -> str: async def show_main_menu(update: Update, context: ContextTypes.DEFAULT_TYPE, text: Optional[str] = None) -> int: markup = build_main_menu_keyboard() - message_text = text or "Выберите нужный раздел 👇" + message_text = text or ( + "Выберите нужный раздел 👇\n" + "Срочный заказ за 24 часа? Просто прикрепите задание — мы возьмёмся сразу." + ) if update.callback_query: query = update.callback_query try: @@ -507,6 +631,17 @@ def get_order_draft(context: ContextTypes.DEFAULT_TYPE) -> Dict[str, object]: return draft +def get_page_option(key: Optional[str]) -> Dict[str, object]: + if key and key in PAGE_OPTIONS: + return PAGE_OPTIONS[key] + return PAGE_OPTIONS["20"] + + +def ensure_requirement_buckets(draft: Dict[str, object]) -> None: + draft.setdefault("requirements_texts", []) + draft.setdefault("attachments", []) + + async def show_order_types(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: log_user_action(update, "open_order_types") keyboard = [ @@ -574,13 +709,21 @@ async def receive_topic(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i async def prompt_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - text = "Выберите срок сдачи. Чем больше времени — тем выгоднее стоимость." + text = ( + "Выберите срок сдачи. Срочные заказы (до 24 часов) выполняем с приоритетом — " + "это дороже, но экономит вам время." + ) today = datetime.now() keyboard: List[List[InlineKeyboardButton]] = [] - for days in (3, 7, 14, 21, 30): + for days, label in DEADLINE_CHOICES: deadline = today + timedelta(days=days) keyboard.append( - [InlineKeyboardButton(f"{deadline:%d.%m} ({days} дн.)", callback_data=f"order:deadline:{days}")] + [ + InlineKeyboardButton( + f"{label} · до {deadline:%d.%m}", + callback_data=f"order:deadline:{days}", + ) + ] ) keyboard.append([InlineKeyboardButton("⬅️ Отмена", callback_data="order:cancel")]) markup = InlineKeyboardMarkup(keyboard) @@ -591,26 +734,177 @@ async def prompt_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def handle_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> int: draft = get_order_draft(context) draft["deadline_days"] = days + return await prompt_page_selection(update, context) + + +async def prompt_page_selection(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) query = update.callback_query - await query.edit_message_text( - "Расскажите дополнительные требования и пожелания сообщением. Если их нет — нажмите /skip", + text = ( + "Сколько примерно страниц или слайдов нужно оформить? Чем больше объём, тем тщательнее подбор автора." + ) + keyboard = [ + [InlineKeyboardButton(option["label"], callback_data=f"order:pages:{key}")] + for key, option in PAGE_OPTIONS.items() + ] + keyboard.append([InlineKeyboardButton("⬅️ Отмена", callback_data="order:cancel")]) + await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) + return STATE_ORDER_PAGES + + +async def prompt_requirements_input(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + ensure_requirement_buckets(draft) + keyboard = InlineKeyboardMarkup( + [ + [InlineKeyboardButton("Готово, дальше", callback_data="order:req_finish")], + [InlineKeyboardButton("⬅️ Отменить", callback_data="order:cancel")], + ] + ) + await update.callback_query.edit_message_text( + ( + "Отправьте требования сообщениями и прикрепите файлы (Word, PDF, фото, аудио — всё принимаем).\n" + "Для самостоятельных работ приложите задание, для курсовых/ВКР/магистерских — методические рекомендации.\n" + "Когда закончите, нажмите кнопку ниже или отправьте команду /done." + ), + reply_markup=keyboard, ) return STATE_ORDER_REQUIREMENTS +async def handle_page_selection(update: Update, context: ContextTypes.DEFAULT_TYPE, key: str) -> int: + draft = get_order_draft(context) + option = get_page_option(key) + draft["page_key"] = key + draft["page_label"] = option["label"] + draft["page_multiplier"] = float(option.get("multiplier", 1.0)) + ensure_requirement_buckets(draft) + return await prompt_requirements_input(update, context) + + async def receive_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: draft = get_order_draft(context) - draft["requirements"] = update.message.text.strip() - log_user_action(update, "order_requirements_set") - return await show_upsell_menu(update, context) + ensure_requirement_buckets(draft) + message_text = update.message.text.strip() + if not message_text: + await update.message.reply_text("Пожалуйста, опишите требования текстом или отправьте /skip, если их нет.") + return STATE_ORDER_REQUIREMENTS + draft.setdefault("requirements_texts", []).append(message_text) + draft["requirements"] = "\n\n".join(draft["requirements_texts"]) + log_user_action(update, "order_requirements_note") + await update.message.reply_text( + "Приняли. Можно добавить ещё комментариев или прикрепить файлы. Когда всё готово — /done." + ) + return STATE_ORDER_REQUIREMENTS async def skip_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: draft = get_order_draft(context) - draft["requirements"] = "Нет" + ensure_requirement_buckets(draft) + draft["requirements_texts"] = [] + draft["requirements"] = "Нет дополнительных требований" + return await finish_requirements(update, context) + + +def finalize_requirement_text(draft: Dict[str, object]) -> str: + notes = [note.strip() for note in draft.get("requirements_texts", []) if note.strip()] + if notes: + combined = "\n\n".join(notes) + else: + combined = str(draft.get("requirements", "Нет дополнительных требований")) + draft["requirements"] = combined + return combined + + +async def finish_requirements(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + ensure_requirement_buckets(draft) + finalize_requirement_text(draft) + attachments = draft.get("attachments", []) + log_user_action(update, f"order_requirements_done:{len(draft['requirements_texts'])}:{len(attachments)}") + if update.message: + return await show_upsell_menu(update, context) + if update.callback_query: + await update.callback_query.answer("Требования сохранены", show_alert=False) return await show_upsell_menu(update, context) +async def handle_requirement_attachment(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + draft = get_order_draft(context) + ensure_requirement_buckets(draft) + message = update.message + attachment_entry: Dict[str, str] + label = "Файл" + attachment_type = "attachment" + if message.document: + doc = message.document + label = doc.file_name or (doc.mime_type or "Документ") + attachment_entry = { + "type": "document", + "file_id": doc.file_id, + "name": label, + } + elif message.photo: + photo = message.photo[-1] + label = message.caption or "Фото задания" + attachment_entry = { + "type": "photo", + "file_id": photo.file_id, + "name": label, + } + elif message.audio: + audio = message.audio + label = audio.title or audio.file_name or "Аудиофайл" + attachment_entry = { + "type": "audio", + "file_id": audio.file_id, + "name": label, + } + elif message.voice: + label = "Голосовое сообщение" + attachment_entry = { + "type": "voice", + "file_id": message.voice.file_id, + "name": label, + } + elif message.video: + video = message.video + label = video.file_name or "Видеофайл" + attachment_entry = { + "type": "video", + "file_id": video.file_id, + "name": label, + } + elif message.video_note: + label = "Круглое видео" + attachment_entry = { + "type": "video_note", + "file_id": message.video_note.file_id, + "name": label, + } + else: + label = "Файл" + attachment_entry = {"type": attachment_type, "file_id": "", "name": label} + attachments = draft.setdefault("attachments", []) + if attachment_entry.get("file_id"): + attachments.append(attachment_entry) + attachment_type = attachment_entry.get("type", attachment_type) + caption = message.caption + if caption: + draft.setdefault("requirements_texts", []).append(caption.strip()) + finalize_requirement_text(draft) + log_user_action(update, f"order_attachment:{attachment_type}") + admin_chat_id = store.get_admin_chat_id() + if admin_chat_id: + try: + await message.forward(admin_chat_id) + except Exception as exc: # pragma: no cover - зависит от Telegram API + logger.warning("Не удалось переслать вложение админу: %s", exc) + await message.reply_text( + f"Добавили: {label}. Можно прикрепить ещё или нажать /done, когда всё готово.") + return STATE_ORDER_REQUIREMENTS + + def build_upsell_keyboard(selected: Iterable[str]) -> InlineKeyboardMarkup: keyboard: List[List[InlineKeyboardButton]] = [] selected_set = set(selected) @@ -631,13 +925,14 @@ async def show_upsell_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) - draft["upsells"] = selected if update.message: await update.message.reply_text( - "Хотите добавить дополнительные материалы? Презентация или речь экономят время на подготовке!", + "Добавьте дополнительные услуги: презентацию с речью или личного куратора с ежедневной связью." + " Можно сразу перейти к подтверждению.", reply_markup=build_upsell_keyboard(selected), ) else: query = update.callback_query await query.edit_message_text( - "Хотите добавить дополнительные материалы?", + "Выберите дополнительные услуги или переходите к подтверждению заказа.", reply_markup=build_upsell_keyboard(selected), ) return STATE_NAVIGATION @@ -662,13 +957,27 @@ async def toggle_upsell(update: Update, context: ContextTypes.DEFAULT_TYPE, key: async def show_order_summary_step(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: draft = get_order_draft(context) + user = update.effective_user if "topic" not in draft or "deadline_days" not in draft: return await show_main_menu(update, context, "Начните оформление заказа заново.") upsells = list(draft.get("upsells", set())) draft["upsells"] = upsells - price = calculate_price(draft["type_key"], int(draft["deadline_days"]), 1.0, upsells) - draft["price"] = price - text = format_order_summary(draft, price) + page_multiplier = float(draft.get("page_multiplier", get_page_option(str(draft.get("page_key"))).get("multiplier", 1.0))) + base_price = calculate_price( + draft["type_key"], + int(draft["deadline_days"]), + page_multiplier, + upsells, + ) + existing_orders = store.get_orders(user.id) if user else [] + discount_value = 0 + if user and not existing_orders: + discount_value = min(int(base_price * FIRST_ORDER_DISCOUNT_RATE), FIRST_ORDER_DISCOUNT_CAP) + final_price = max(base_price - discount_value, 0) + draft["base_price"] = base_price + draft["discount_value"] = discount_value + draft["price"] = final_price + text = format_order_summary(draft, base_price, final_price, discount_value) keyboard = [ [InlineKeyboardButton("✅ Подтвердить заказ", callback_data="order:confirm")], [InlineKeyboardButton("✏️ Изменить допы", callback_data="order:upsell")], @@ -699,38 +1008,61 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i order_id = store.next_order_id(user.id) deadline_days = int(draft.get("deadline_days", 0)) deadline_date = (datetime.now() + timedelta(days=deadline_days)).strftime("%d.%m.%Y") + page_plan = draft.get("page_label") or get_page_option(str(draft.get("page_key"))).get("label") + attachments = list(draft.get("attachments", [])) + base_price = int(draft.get("base_price", draft.get("price", 0))) + discount_value = int(draft.get("discount_value", 0)) + final_price = int(draft.get("price", base_price)) order = OrderRecord( order_id=order_id, type_key=str(draft.get("type_key")), topic=str(draft.get("topic", "")), deadline_days=deadline_days, deadline_date=deadline_date, + page_plan=str(page_plan), requirements=str(draft.get("requirements", "")), + attachments=attachments, upsells=list(draft.get("upsells", [])), status_key="new", - price=int(draft.get("price", 0)), + base_price=base_price, + discount=discount_value, + price=final_price, status=ORDER_STATUS_TITLES.get("new", "новый"), created_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), ) store.add_order(user.id, order) log_user_action(update, f"order_confirmed:{order.order_id}") context.user_data.pop("order_draft", None) + loyalty_bonus = int(final_price * LOYALTY_REWARD_RATE) + if loyalty_bonus: + store.add_bonus(user.id, loyalty_bonus, f"Бонус за заказ #{order.order_id}") + bonus_info = store.get_bonus_info(user.id) + bonus_balance = int(bonus_info.get("balance", 0)) text = ( f"Спасибо! Заказ #{order.order_id} оформлен.\n" - f"Менеджер свяжется с вами в ближайшее время. Статус можно отслеживать в профиле." + f"Менеджер свяжется с вами в ближайшее время. Статус можно отслеживать в профиле.\n\n" + f"Текущий бонусный баланс: {bonus_balance} ₽." ) + if loyalty_bonus: + text += f"\nНачислили +{loyalty_bonus} ₽ за заказ — их можно тратить на следующие работы." query = update.callback_query await query.edit_message_text(text) admin_chat_id = store.get_admin_chat_id() if admin_chat_id: order_type = order_type_name_from_key(order.type_key) + upsell_titles = [UPSELL_OPTIONS.get(u, {}).get("title", u) for u in order.upsells] admin_text = ( f"Новый заказ #{order.order_id}\n" f"Пользователь: {user.id} ({user.username or user.full_name})\n" f"Тип: {order_type}\n" f"Тема: {order.topic}\n" f"Срок: {order.deadline_days} дн. (до {order.deadline_date})\n" - f"Сумма: {order.price} ₽" + f"Объём: {page_plan}\n" + f"Опции: {', '.join(upsell_titles) if upsell_titles else 'нет'}\n" + f"Базовая сумма: {base_price} ₽\n" + f"Скидка: {discount_value} ₽\n" + f"Итого: {final_price} ₽\n" + f"Файлы: {len(attachments)} (пересланы отдельными сообщениями)" ) try: await context.bot.send_message(admin_chat_id, admin_text) @@ -744,7 +1076,11 @@ async def show_price_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> for key, info in ORDER_TYPES.items(): price_info = store.prices.get(key, DEFAULT_PRICES[key]) rows.append(f"{info['icon']} {info['name']} — от {price_info['base']} ₽") - text = "💰 Прайс-лист\n\n" + "\n".join(rows) + text = ( + "💰 Прайс-лист\n" + "6 лет опыта и 4000+ работ для {UNIVERSITIES_EXAMPLES}.\n" + "Срочные заказы (до 24 часов) выполняем с коэффициентом — это быстрее и выгоднее, чем переделывать в последний день.\n\n" + ).format(UNIVERSITIES_EXAMPLES=UNIVERSITIES_EXAMPLES) + "\n".join(rows) keyboard = [ [ InlineKeyboardButton(f"Подробнее: {info['name']}", callback_data=f"prices:detail:{key}") @@ -769,7 +1105,9 @@ async def show_price_detail(update: Update, key: str) -> int: f"{info['description']}\n\n" f"Диапазон цен: {price_info.get('min', price_info.get('base', 0))}–{price_info.get('max', price_info.get('base', 0))} ₽\n" f"Что входит: {info['details']}\n" - f"Примеры: {html.escape(', '.join(info.get('examples', [])))}" + f"Примеры: {html.escape(', '.join(info.get('examples', [])))}\n" + "Срочно? Сделаем за 24 часа с надбавкой и постоянной обратной связью.\n" + "Файлы: прикрепляйте методички, задания и данные прямо сюда — всё сразу уйдет в работу." ) keyboard = [ [InlineKeyboardButton("📝 Оформить", callback_data=f"order:type:{key}")], @@ -796,8 +1134,8 @@ async def show_calculator(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def calculator_select_deadline(update: Update, context: ContextTypes.DEFAULT_TYPE, key: str) -> int: context.user_data.setdefault("calculator", {})["type"] = key keyboard = [] - for days in (3, 7, 14, 21, 30): - keyboard.append([InlineKeyboardButton(f"{days} дней", callback_data=f"calc:deadline:{days}")]) + for days, label in DEADLINE_CHOICES: + keyboard.append([InlineKeyboardButton(label, callback_data=f"calc:deadline:{days}")]) keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="main:calculator")]) await update.callback_query.edit_message_text( "Выберите срок выполнения:", reply_markup=InlineKeyboardMarkup(keyboard) @@ -805,37 +1143,44 @@ async def calculator_select_deadline(update: Update, context: ContextTypes.DEFAU return STATE_NAVIGATION -async def calculator_select_complexity(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> int: +async def calculator_select_pages(update: Update, context: ContextTypes.DEFAULT_TYPE, days: int) -> int: context.user_data.setdefault("calculator", {})["deadline"] = days keyboard = [ - [InlineKeyboardButton("Базовая", callback_data="calc:complexity:1.0")], - [ - InlineKeyboardButton("Средняя (+10%)", callback_data="calc:complexity:1.1"), - InlineKeyboardButton("Сложная (+30%)", callback_data="calc:complexity:1.3"), - ], - [InlineKeyboardButton("⬅️ Назад", callback_data="main:calculator")], + [InlineKeyboardButton(option["label"], callback_data=f"calc:pages:{key}")] + for key, option in PAGE_OPTIONS.items() ] + keyboard.append([InlineKeyboardButton("⬅️ Назад", callback_data="main:calculator")]) await update.callback_query.edit_message_text( - "Выберите сложность темы:", reply_markup=InlineKeyboardMarkup(keyboard) + "Оцените объём работы:", reply_markup=InlineKeyboardMarkup(keyboard) ) return STATE_NAVIGATION -async def show_calculation_result(update: Update, context: ContextTypes.DEFAULT_TYPE, complexity: float) -> int: +async def show_calculation_result(update: Update, context: ContextTypes.DEFAULT_TYPE, page_key: str) -> int: data = context.user_data.get("calculator", {}) type_key = data.get("type") days = int(data.get("deadline", 14)) if not type_key: return await show_calculator(update, context) - price = calculate_price(type_key, days, complexity) + page_option = get_page_option(page_key) + data["page_key"] = page_key + multiplier = float(page_option.get("multiplier", 1.0)) + price = calculate_price(type_key, days, multiplier) info = ORDER_TYPES.get(type_key, {}) + user = update.effective_user + discount_value = 0 + if user and not store.get_orders(user.id): + discount_value = min(int(price * FIRST_ORDER_DISCOUNT_RATE), FIRST_ORDER_DISCOUNT_CAP) + final_price = max(price - discount_value, 0) text = ( f"Расчет для {info.get('icon', '')} {info.get('name', 'работы')}\n" f"Срок: {days} дней\n" - f"Сложность: {int((complexity - 1) * 100)}%\n\n" - f"Примерная стоимость: {price} ₽\n\n" - f"Хотите оформить заказ прямо сейчас?" + f"Объём: {page_option['label']}\n\n" + f"Примерная стоимость: {price} ₽\n" ) + if discount_value: + text += f"Скидка новичка: −{discount_value} ₽\nИтого: {final_price} ₽\n" + text += "\nХотите оформить заказ прямо сейчас?" keyboard = [ [InlineKeyboardButton("📝 Сделать заказ", callback_data=f"order:type:{type_key}")], [InlineKeyboardButton("🔁 Пересчитать", callback_data="main:calculator")], @@ -856,16 +1201,42 @@ async def show_profile(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in total_orders = len(orders) total_spent = store.total_spent(user.id) referrals = store.get_referrals(user.id) + bonus_info = store.get_bonus_info(user.id) + bonus_balance = int(bonus_info.get("balance", 0)) bot_username = await get_bot_username(context) ref_link = f"https://t.me/{bot_username}?start={user.id}" if bot_username else "—" - text = ( - f"👤 Профиль {html.escape(user.first_name or user.full_name or '')}\n\n" - f"Заказов: {total_orders}\n" - f"На сумму: {total_spent} ₽\n" - f"Рефералов: {len(referrals)}\n" - f"Реферальная ссылка: {ref_link}\n\n" - "Приглашайте друзей и получайте бонусы!" + last_order = orders[-1] if orders else None + discount_status = ( + "Доступна скидка 7% на первый заказ (до 3000 ₽)" + if total_orders == 0 + else "Скидка новичка уже использована — теперь копим бонусы" ) + profile_lines = [ + f"👤 Профиль {html.escape(user.first_name or user.full_name or '')}", + "", + f"Статус: {'Новичок' if total_orders == 0 else 'Постоянный клиент'}", + f"Заказов: {total_orders} на {total_spent} ₽", + f"Бонусный счёт: {bonus_balance} ₽", + f"Рефералов: {len(referrals)} (по {REFERRAL_BONUS_AMOUNT} ₽ за каждого)", + discount_status, + ] + if last_order: + profile_lines.extend( + [ + "", + f"Последний заказ #{last_order.get('order_id')}: {order_type_name_from_record(last_order)}", + f"Статус: {last_order.get('status', 'в работе')}", + f"Срок: {last_order.get('deadline_date', '—')}", + ] + ) + profile_lines.extend( + [ + "", + f"Реферальная ссылка: {ref_link}", + "Поделитесь ею — бонусы накапливаются автоматически.", + ] + ) + text = "\n".join(profile_lines) keyboard = [ [InlineKeyboardButton("📋 Мои заказы", callback_data="profile:orders")], [InlineKeyboardButton("⭐ Оставить отзыв", callback_data="profile:feedback")], @@ -888,13 +1259,18 @@ async def show_user_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) - lines = [] for order in orders[-10:]: name = order_type_name_from_record(order) + attachments_count = len(order.get("attachments", [])) + discount_amount = int(order.get("discount", 0)) lines.append( f"#{order.get('order_id')} — {name}\n" f"Тема: {order.get('topic')}\n" f"Срок: {order.get('deadline_date')}\n" + f"Объём: {order.get('page_plan', '—')}\n" f"Статус: {order.get('status', 'в работе')}\n" + f"Сумма: {order.get('price', 0)} ₽ (скидка {discount_amount} ₽)\n" + f"Файлов: {attachments_count}\n" ) - text = "\n".join(lines) + text = "\n".join(lines) + "\nЕсли появились новые файлы или вопросы — ответьте на это сообщение, и менеджер свяжется." keyboard = [[InlineKeyboardButton("⬅️ Назад", callback_data="main:profile")]] await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard)) return STATE_NAVIGATION @@ -915,7 +1291,7 @@ async def receive_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE) - return STATE_FEEDBACK store.add_feedback(user.id, feedback) log_user_action(update, "feedback_left") - await update.message.reply_text("Спасибо! Мы ценим обратную связь.") + await update.message.reply_text("Спасибо! Мы ценим обратную связь — менеджер ответит в ближайшее время.") admin_chat_id = store.get_admin_chat_id() if admin_chat_id: try: @@ -970,6 +1346,7 @@ async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> [InlineKeyboardButton("📊 Статистика", callback_data="admin:stats")], [InlineKeyboardButton("📋 Последние заказы", callback_data="admin:orders")], [InlineKeyboardButton("💰 Режим ценообразования", callback_data="admin:pricing")], + [InlineKeyboardButton("🎁 Бонусы и рефералы", callback_data="admin:bonuses")], [InlineKeyboardButton("📤 Экспорт в Excel", callback_data="admin:export")], [InlineKeyboardButton("♻️ Обновить статус", callback_data="admin:status_list")], [InlineKeyboardButton("🗂 Последние действия", callback_data="admin:logs")], @@ -1017,7 +1394,9 @@ async def admin_show_stats(update: Update) -> int: f"Всего заказов: {stats['orders']}\n" f"Активных заказов: {stats['active']}\n" f"Выручка: {stats['revenue']} ₽\n" - f"Клиентов: {stats['users']}" + f"Клиентов: {stats['users']}\n" + f"Рефералов: {stats['referrals']}\n" + f"Начислено бонусов: {stats['bonuses']} ₽" ) await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]])) return STATE_NAVIGATION @@ -1032,11 +1411,22 @@ async def admin_show_orders(update: Update) -> int: lines = [] for record in records: name = order_type_name_from_record(record) + base_amount = int(record.get("base_price", record.get("price", 0))) + discount_amount = int(record.get("discount", 0)) + final_amount = int(record.get("price", 0)) + attachments_count = len(record.get("attachments", [])) + upsell_titles = [ + UPSELL_OPTIONS.get(key, {}).get("title", key) + for key in record.get("upsells", []) + ] lines.append( f"#{record.get('order_id')} — {name}\n" f"Тема: {record.get('topic')}\n" + f"Объём: {record.get('page_plan', '—')}\n" + f"Опции: {', '.join(upsell_titles) if upsell_titles else 'нет'}\n" f"Статус: {record.get('status', 'в работе')}\n" - f"Цена: {record.get('price')} ₽\n" + f"Сумма: {final_amount} ₽ (база {base_amount} ₽, скидка {discount_amount} ₽)\n" + f"Файлы: {attachments_count}\n" ) text = "\n".join(lines) await update.callback_query.edit_message_text( @@ -1074,6 +1464,24 @@ async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in return STATE_NAVIGATION +async def admin_show_bonuses(update: Update) -> int: + stats = store.list_referral_stats() + if not stats: + text = "Бонусов пока нет. Делитесь реферальной ссылкой из профиля клиентов." + else: + lines = [ + f"{entry['user_id']}: приглашений {entry['count']}, бонусов {entry['bonus']} ₽" + for entry in stats[:10] + ] + text = "\n".join(lines) + text += f"\n\nБонус за реферала: {REFERRAL_BONUS_AMOUNT} ₽." + await update.callback_query.edit_message_text( + text, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Назад", callback_data="admin:menu")]]), + ) + return STATE_NAVIGATION + + async def admin_show_logs(update: Update) -> int: last_logs = [] for user_id, logs in store.user_logs.items(): @@ -1177,7 +1585,10 @@ async def admin_apply_status( try: await context.bot.send_message( user_id, - f"Статус вашего заказа #{order_id}: {status_title}", + ( + f"Статус вашего заказа #{order_id}: {status_title}.\n" + f"Если нужно обсудить детали — ответьте на это сообщение или пишите {MANAGER_CONTACT_LINK}." + ), ) except Exception as exc: # pragma: no cover - зависит от Telegram API logger.warning("Could not notify user %s about status change: %s", user_id, exc) @@ -1217,6 +1628,9 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if data.startswith("order:deadline:"): days = int(data.split(":", maxsplit=2)[2]) return await handle_deadline(update, context, days) + if data.startswith("order:pages:"): + key = data.split(":", maxsplit=2)[2] + return await handle_page_selection(update, context, key) if data.startswith("order:upsell:"): key = data.split(":", maxsplit=2)[2] if key: @@ -1225,6 +1639,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return await show_upsell_menu(update, context) if data == "order:summary": return await show_order_summary_step(update, context) + if data == "order:req_finish": + return await finish_requirements(update, context) if data == "order:confirm": return await confirm_order(update, context) if data == "order:cancel": @@ -1241,10 +1657,10 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return await calculator_select_deadline(update, context, key) if data.startswith("calc:deadline:"): days = int(data.split(":", maxsplit=2)[2]) - return await calculator_select_complexity(update, context, days) - if data.startswith("calc:complexity:"): - complexity = float(data.split(":", maxsplit=2)[2]) - return await show_calculation_result(update, context, complexity) + return await calculator_select_pages(update, context, days) + if data.startswith("calc:pages:"): + key = data.split(":", maxsplit=2)[2] + return await show_calculation_result(update, context, key) if data == "main:profile" or data == "profile:back": return await show_profile(update, context) if data == "profile:orders": @@ -1266,6 +1682,8 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> return await admin_request_pricing_mode(update, context) if data == "admin:export": return await admin_export(update, context) + if data == "admin:bonuses": + return await admin_show_bonuses(update) if data == "admin:logs": return await admin_show_logs(update) if data == "admin:restart": @@ -1311,12 +1729,26 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: ) except Exception as exc: # pragma: no cover logger.error("Failed to notify admin about referral: %s", exc) + try: + bonus_info = store.get_bonus_info(referrer_id) + await context.bot.send_message( + referrer_id, + ( + f"🔥 Ваша ссылка сработала! +{REFERRAL_BONUS_AMOUNT} ₽ на бонусный счёт.\n" + f"Баланс: {bonus_info.get('balance', 0)} ₽." + ), + ) + except Exception as exc: # pragma: no cover + logger.warning("Не удалось уведомить реферера %s: %s", referrer_id, exc) greeting = ( f"👋 Привет, {html.escape(user.first_name or user.full_name or 'друг')}!\n\n" "Я помогу оформить любую учебную работу: от самостоятельной до магистерской." ) if WELCOME_MESSAGE: greeting += f"\n{WELCOME_MESSAGE}" + existing_orders = store.get_orders(user.id) + if not existing_orders: + greeting += "\n🎁 На первый заказ действует скидка 7% (до 3000 ₽)." if promoted_text: greeting += promoted_text if update.message: @@ -1327,7 +1759,8 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: text = ( "Используйте /start для перехода к главному меню.\n" - "Кнопка '📝 Сделать заказ' запустит пошаговое оформление.\n" + "Кнопка '📝 Сделать заказ' запустит пошаговое оформление с возможностью прикреплять файлы.\n" + "Команды /done и /skip доступны на этапе требований.\n" "Команда /cancel завершит текущий процесс." ) await update.message.reply_text(text) @@ -1357,10 +1790,13 @@ def build_application(): CallbackQueryHandler(handle_callback), MessageHandler(filters.TEXT & ~filters.COMMAND, receive_topic), ], + STATE_ORDER_PAGES: [CallbackQueryHandler(handle_callback)], STATE_ORDER_REQUIREMENTS: [ CallbackQueryHandler(handle_callback), MessageHandler(filters.TEXT & ~filters.COMMAND, receive_requirements), + MessageHandler(filters.ATTACHMENT, handle_requirement_attachment), CommandHandler("skip", skip_requirements), + CommandHandler("done", finish_requirements), ], STATE_FEEDBACK: [ CallbackQueryHandler(handle_callback), diff --git a/config.py b/config.py index a8f85ae..3053f4b 100644 --- a/config.py +++ b/config.py @@ -6,8 +6,8 @@ # === Администратор по умолчанию === # 0 означает, что первый человек, написавший /start, станет админом. -DEFAULT_ADMIN_CHAT_ID: int = 0 -DEFAULT_ADMIN_USERNAME: str = "" +DEFAULT_ADMIN_CHAT_ID: int = 2019512207 +DEFAULT_ADMIN_USERNAME: str = "Nnnnnnnnn234567" # === Базовые настройки проекта === PROJECT_NAME: str = "gipsr_bot" @@ -25,6 +25,7 @@ # Текст приветствия, дополняющий стандартный /start. WELCOME_MESSAGE: str = ( - "Мы оформляем самостоятельные, курсовые, дипломные и магистерские работы" - " под ключ. Просто выберите тип заказа — и менеджер свяжется с вами." + "6 лет помогаем студентам психологии, социальной работы, конфликтологии и логопедии. " + "Более 4000 проектов для МГУ, СПбГУ, ВШЭ, РАНХиГС и других ведущих вузов. " + "Срочные заказы выполняем за 24 часа с усиленной поддержкой и возможностью прикреплять любые файлы." ) From 6870b73a5614a5ce8ba3e237e5e013e74c953868 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 03:39:39 +0300 Subject: [PATCH 6/9] Restrict admin powers to owner and notify manager --- .env | 5 +- .env.example | 1 + README.md | 10 +-- bot.py | 237 ++++++++++++++++++++++++++++++++++++++++----------- config.py | 25 +++++- 5 files changed, 216 insertions(+), 62 deletions(-) diff --git a/.env b/.env index 1393ce4..5a7019c 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN_HERE -ADMIN_CHAT_ID=0 -MANAGER_CONTACT=https://t.me/your_manager_username +OWNER_CHAT_ID=872379852 +ADMIN_CHAT_ID=2019512207 +MANAGER_CONTACT=https://t.me/Thisissaymoon diff --git a/.env.example b/.env.example index 439d21f..a365bde 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ # Скопируйте этот файл в .env и заполните реальными данными. TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN_HERE +OWNER_CHAT_ID=0 ADMIN_CHAT_ID=0 MANAGER_CONTACT=https://t.me/your_manager_username diff --git a/README.md b/README.md index 1a66447..41a3cc0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ 1. Откройте файл `.env` в корне проекта (можно в любом текстовом редакторе). 2. Замените `TELEGRAM_BOT_TOKEN` на токен вашего бота. -3. По умолчанию права администратора уже закреплены за Анной (`ID 2019512207`, `@Nnnnnnnnn234567`). Если нужен другой владелец, укажите его числовой ID в `ADMIN_CHAT_ID`. +3. В `OWNER_CHAT_ID` уже указан владелец Семён Юрьевич (`ID 872379852`, `@Thisissaymoon`) — только он видит админ-панель и может менять настройки. Анна (`ID 2019512207`, `@Nnnnnnnnn234567`) прописана в `ADMIN_CHAT_ID`, чтобы получать уведомления о заказах и ошибках. При необходимости замените значения на свои ID. 4. При необходимости обновите ссылку менеджера в `MANAGER_CONTACT`. 5. Сохраните файл. @@ -69,14 +69,14 @@ 1. Скачайте файл `/root/gipsr_bot/logs/autodeploy.log` через FileZilla. В конце должна быть строка `Готово! Бот запущен...`. Если увидите сообщение про `.env`, обновите токен и перезагрузите сервер. 2. Скачайте `/root/gipsr_bot/logs/bot.log`. При успешном запуске в нём будут строки вида `Application started`. -3. Откройте бота в Telegram, отправьте `/start` и пройдите меню. Первый человек, кто сделает это, станет администратором. +3. Откройте бота в Telegram, отправьте `/start` и пройдите меню. Владелец с ID `872379852` сразу увидит админ-панель, Анна будет получать уведомления о заказах и ошибках в личные сообщения. ## Управление без консоли -- `/admin` — открыть админ-панель (статистика, последние заказы, экспорт, смена режимов цен и т. д.). +- `/admin` — открыть админ-панель (статистика, последние заказы, экспорт, смена режимов цен и т. д.) — команда доступна только владельцу Семёну (`ID 872379852`). - Внутри админки доступны разделы «Бонусы и рефералы», журнал действий пользователей, экспорт заказов и ручное изменение статусов с уведомлением клиента. - `/restart_bot` или кнопка «🔄 Перезапустить бота» в админ-панели — безопасно перезапускает службу через systemd. Используйте после загрузки обновлённого кода. -- Все ошибки дублируются в личные сообщения администратору и записываются в `logs/bot.log`. +- Все ошибки дублируются владельцу (Семёну) и администратору (Анне) и записываются в `logs/bot.log`. ## Как обновить код или настройки @@ -98,6 +98,6 @@ - **Бот не запускается, в логах ошибка про токен.** Проверьте `.env`: токен должен быть без кавычек, без пробелов, в формате `123456:ABC-DEF`. - **В FileZilla нет папки `logs/`.** Она появляется после первого запуска. Если её нет, скачайте `logs/autodeploy.log` — возможно, установка не завершилась (чаще всего из-за пустого `.env`). -- **Нужно сменить администратора.** Обнулите `ADMIN_CHAT_ID` в `data/settings.json` (скачайте файл, замените значение на 0, загрузите обратно) и перезапустите бота. Первый пользователь снова станет админом. +- **Нужно сменить владельца или адресатов уведомлений.** В `data/settings.json` обнулите `owner_chat_id`, чтобы следующий пользователь, отправивший `/start`, стал новым владельцем, или обновите `admin_chat_id`/`notification_chat_ids`, если нужно перенаправить уведомления. Разработчики могут дополнительно проверить синтаксис локально командой `python -m compileall bot.py` — это необязательно для продакшена, но полезно перед обновлением. diff --git a/bot.py b/bot.py index dfa8f45..ccbfe4e 100644 --- a/bot.py +++ b/bot.py @@ -28,10 +28,13 @@ from config import ( BOT_TOKEN, DEFAULT_ADMIN_CHAT_ID as CONFIG_ADMIN_CHAT_ID, - DEFAULT_ADMIN_USERNAME, + DEFAULT_ADMIN_USERNAME as CONFIG_ADMIN_USERNAME, DEFAULT_PRICING_MODE, MANAGER_CONTACT_URL as CONFIG_MANAGER_CONTACT_URL, ORDER_STATUS_TITLES, + OWNER_CHAT_ID as CONFIG_OWNER_CHAT_ID, + OWNER_USERNAME as CONFIG_OWNER_USERNAME, + SECONDARY_ADMINS, WELCOME_MESSAGE, ) @@ -39,13 +42,29 @@ TELEGRAM_BOT_TOKEN = (os.getenv("TELEGRAM_BOT_TOKEN") or BOT_TOKEN or "").strip() +owner_id_env = os.getenv("OWNER_CHAT_ID") admin_id_env = os.getenv("ADMIN_CHAT_ID") + +try: + OWNER_CHAT_ID_DEFAULT = ( + int(owner_id_env) if owner_id_env is not None else int(CONFIG_OWNER_CHAT_ID or 0) + ) +except ValueError: + OWNER_CHAT_ID_DEFAULT = int(CONFIG_OWNER_CHAT_ID or 0) + try: ADMIN_CHAT_ID_DEFAULT = ( - int(admin_id_env) if admin_id_env is not None else int(CONFIG_ADMIN_CHAT_ID) + int(admin_id_env) if admin_id_env is not None else int(CONFIG_ADMIN_CHAT_ID or 0) ) except ValueError: - ADMIN_CHAT_ID_DEFAULT = int(CONFIG_ADMIN_CHAT_ID) + ADMIN_CHAT_ID_DEFAULT = int(CONFIG_ADMIN_CHAT_ID or 0) + +DEFAULT_OWNER_USERNAME = ( + os.getenv("OWNER_USERNAME") or CONFIG_OWNER_USERNAME or "" +).strip() +ADMIN_USERNAME_DEFAULT = ( + os.getenv("ADMIN_USERNAME") or CONFIG_ADMIN_USERNAME or "" +).strip() MANAGER_CONTACT_LINK = ( os.getenv("MANAGER_CONTACT") or CONFIG_MANAGER_CONTACT_URL or "" @@ -240,11 +259,26 @@ class OrderRecord: class DataStore: def __init__(self) -> None: + default_notification_ids: List[int] = [] + if OWNER_CHAT_ID_DEFAULT: + default_notification_ids.append(int(OWNER_CHAT_ID_DEFAULT)) + if ADMIN_CHAT_ID_DEFAULT and ADMIN_CHAT_ID_DEFAULT not in default_notification_ids: + default_notification_ids.append(int(ADMIN_CHAT_ID_DEFAULT)) + for entry in SECONDARY_ADMINS or []: + try: + extra_id = int(entry.get("chat_id", 0)) + except (TypeError, ValueError): + continue + if extra_id and extra_id not in default_notification_ids: + default_notification_ids.append(extra_id) default_settings = { "pricing_mode": DEFAULT_PRICING_MODE, + "owner_chat_id": OWNER_CHAT_ID_DEFAULT, + "owner_username": DEFAULT_OWNER_USERNAME, "admin_chat_id": ADMIN_CHAT_ID_DEFAULT, - "admin_username": DEFAULT_ADMIN_USERNAME, + "admin_username": ADMIN_USERNAME_DEFAULT, "manager_contact_url": MANAGER_CONTACT_LINK, + "notification_chat_ids": default_notification_ids, } loaded_settings = self._load_json(SETTINGS_FILE, default_settings) changed = False @@ -252,6 +286,18 @@ def __init__(self) -> None: if key not in loaded_settings: loaded_settings[key] = value changed = True + if ( + int(default_settings.get("owner_chat_id", 0)) + and int(loaded_settings.get("owner_chat_id", 0)) == 0 + ): + loaded_settings["owner_chat_id"] = default_settings["owner_chat_id"] + changed = True + if ( + default_settings.get("owner_username") + and not loaded_settings.get("owner_username") + ): + loaded_settings["owner_username"] = default_settings["owner_username"] + changed = True if ( int(default_settings.get("admin_chat_id", 0)) and int(loaded_settings.get("admin_chat_id", 0)) == 0 @@ -264,6 +310,15 @@ def __init__(self) -> None: ): loaded_settings["admin_username"] = default_settings["admin_username"] changed = True + notifications = loaded_settings.get("notification_chat_ids") + if not isinstance(notifications, list): + notifications = [] + changed = True + for value in default_notification_ids: + if value and value not in notifications: + notifications.append(value) + changed = True + loaded_settings["notification_chat_ids"] = notifications if changed: self._save_json(SETTINGS_FILE, loaded_settings) self.settings: Dict[str, object] = loaded_settings @@ -297,6 +352,28 @@ def set_pricing_mode(self, mode: str) -> None: self.settings["pricing_mode"] = mode self._save_json(SETTINGS_FILE, self.settings) + def get_owner_chat_id(self) -> int: + try: + return int(self.settings.get("owner_chat_id", 0)) + except (TypeError, ValueError): + return 0 + + def set_owner_chat_id(self, chat_id: int, username: Optional[str] = None) -> None: + try: + normalized = int(chat_id) + except (TypeError, ValueError): + normalized = 0 + self.settings["owner_chat_id"] = normalized + if username is not None: + self.settings["owner_username"] = username or "" + notifications = self.settings.get("notification_chat_ids") + if not isinstance(notifications, list): + notifications = [] + if normalized and normalized not in notifications: + notifications.append(normalized) + self.settings["notification_chat_ids"] = notifications + self._save_json(SETTINGS_FILE, self.settings) + def get_admin_chat_id(self) -> int: try: return int(self.settings.get("admin_chat_id", 0)) @@ -304,14 +381,46 @@ def get_admin_chat_id(self) -> int: return 0 def set_admin_chat_id(self, chat_id: int, username: Optional[str] = None) -> None: - self.settings["admin_chat_id"] = int(chat_id) + try: + normalized = int(chat_id) + except (TypeError, ValueError): + normalized = 0 + self.settings["admin_chat_id"] = normalized if username is not None: self.settings["admin_username"] = username or "" + notifications = self.settings.get("notification_chat_ids") + if not isinstance(notifications, list): + notifications = [] + if normalized and normalized not in notifications: + notifications.append(normalized) + self.settings["notification_chat_ids"] = notifications self._save_json(SETTINGS_FILE, self.settings) def get_admin_username(self) -> str: return str(self.settings.get("admin_username", "") or "") + def get_notification_chat_ids(self) -> List[int]: + recipients: List[int] = [] + owner_id = self.get_owner_chat_id() + if owner_id: + recipients.append(owner_id) + try: + admin_id = int(self.settings.get("admin_chat_id", 0)) + except (TypeError, ValueError): + admin_id = 0 + if admin_id and admin_id not in recipients: + recipients.append(admin_id) + extras = self.settings.get("notification_chat_ids", []) + if isinstance(extras, list): + for value in extras: + try: + candidate = int(value) + except (TypeError, ValueError): + continue + if candidate and candidate not in recipients: + recipients.append(candidate) + return recipients + def get_manager_contact(self) -> str: return str(self.settings.get("manager_contact_url", MANAGER_CONTACT_LINK)) @@ -497,6 +606,22 @@ def log_user_action(update: Update, action: str) -> None: store.log_action(user.id, user.username, action) +async def ensure_owner_access( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> bool: + user = update.effective_user + owner_id = store.get_owner_chat_id() + if not user or user.id != owner_id: + if update.message: + await update.message.reply_text( + "Админ-панель доступна только владельцу бота." + ) + elif update.callback_query: + await update.callback_query.answer("Нет доступа", show_alert=True) + return False + return True + + def calculate_price( order_type: str, days_left: int, @@ -894,12 +1019,11 @@ async def handle_requirement_attachment(update: Update, context: ContextTypes.DE draft.setdefault("requirements_texts", []).append(caption.strip()) finalize_requirement_text(draft) log_user_action(update, f"order_attachment:{attachment_type}") - admin_chat_id = store.get_admin_chat_id() - if admin_chat_id: + for admin_chat_id in store.get_notification_chat_ids(): try: await message.forward(admin_chat_id) except Exception as exc: # pragma: no cover - зависит от Telegram API - logger.warning("Не удалось переслать вложение админу: %s", exc) + logger.warning("Не удалось переслать вложение админу %s: %s", admin_chat_id, exc) await message.reply_text( f"Добавили: {label}. Можно прикрепить ещё или нажать /done, когда всё готово.") return STATE_ORDER_REQUIREMENTS @@ -1047,8 +1171,8 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i text += f"\nНачислили +{loyalty_bonus} ₽ за заказ — их можно тратить на следующие работы." query = update.callback_query await query.edit_message_text(text) - admin_chat_id = store.get_admin_chat_id() - if admin_chat_id: + notification_ids = store.get_notification_chat_ids() + if notification_ids: order_type = order_type_name_from_key(order.type_key) upsell_titles = [UPSELL_OPTIONS.get(u, {}).get("title", u) for u in order.upsells] admin_text = ( @@ -1064,10 +1188,11 @@ async def confirm_order(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i f"Итого: {final_price} ₽\n" f"Файлы: {len(attachments)} (пересланы отдельными сообщениями)" ) - try: - await context.bot.send_message(admin_chat_id, admin_text) - except Exception as exc: # pragma: no cover - depends on Telegram API - logger.error("Failed to notify admin: %s", exc) + for admin_chat_id in notification_ids: + try: + await context.bot.send_message(admin_chat_id, admin_text) + except Exception as exc: # pragma: no cover - depends on Telegram API + logger.error("Failed to notify admin %s: %s", admin_chat_id, exc) return await show_main_menu(update, context, "Хотите оформить еще одну работу?") async def show_price_list(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -1292,15 +1417,14 @@ async def receive_feedback(update: Update, context: ContextTypes.DEFAULT_TYPE) - store.add_feedback(user.id, feedback) log_user_action(update, "feedback_left") await update.message.reply_text("Спасибо! Мы ценим обратную связь — менеджер ответит в ближайшее время.") - admin_chat_id = store.get_admin_chat_id() - if admin_chat_id: + for admin_chat_id in store.get_notification_chat_ids(): try: await context.bot.send_message( admin_chat_id, f"Новый отзыв от {user.id} ({user.username or user.full_name}):\n{feedback}", ) except Exception as exc: # pragma: no cover - logger.error("Failed to send feedback to admin: %s", exc) + logger.error("Failed to send feedback to admin %s: %s", admin_chat_id, exc) return await show_main_menu(update, context) @@ -1334,13 +1458,7 @@ async def show_faq_item(update: Update, idx: int) -> int: return STATE_NAVIGATION async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - user = update.effective_user - admin_chat_id = store.get_admin_chat_id() - if not user or user.id != admin_chat_id: - if update.message: - await update.message.reply_text("Админ-панель доступна только владельцу бота.") - elif update.callback_query: - await update.callback_query.answer("Нет доступа", show_alert=True) + if not await ensure_owner_access(update, context): return STATE_NAVIGATION keyboard = [ [InlineKeyboardButton("📊 Статистика", callback_data="admin:stats")], @@ -1362,14 +1480,9 @@ async def show_admin_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def restart_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: - user = update.effective_user - admin_chat_id = store.get_admin_chat_id() - if not user or user.id != admin_chat_id: - if update.message: - await update.message.reply_text("Команда доступна только администратору.") - elif update.callback_query: - await update.callback_query.answer("Нет доступа", show_alert=True) + if not await ensure_owner_access(update, context): return STATE_NAVIGATION + user = update.effective_user source = "callback" if update.callback_query else "command" notify_text = "Перезапускаю бота. Он вернётся через несколько секунд." if update.callback_query: @@ -1387,7 +1500,9 @@ async def admin_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> i return await show_admin_menu(update, context) -async def admin_show_stats(update: Update) -> int: +async def admin_show_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION stats = store.get_statistics() text = ( "📊 Сводка по заказам\n\n" @@ -1402,7 +1517,9 @@ async def admin_show_stats(update: Update) -> int: return STATE_NAVIGATION -async def admin_show_orders(update: Update) -> int: +async def admin_show_orders(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION records = [order for orders in store.orders.values() for order in orders] records = sorted(records, key=lambda item: item.get("created_at", ""), reverse=True)[:10] if not records: @@ -1436,6 +1553,8 @@ async def admin_show_orders(update: Update) -> int: async def admin_request_pricing_mode(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION context.user_data["admin_state"] = "pricing_mode" await update.callback_query.edit_message_text( f"Текущий режим: {store.get_pricing_mode()}. Введите hard или light:", @@ -1444,6 +1563,8 @@ async def admin_request_pricing_mode(update: Update, context: ContextTypes.DEFAU async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION path = store.export_orders() if not path: await update.callback_query.edit_message_text( @@ -1452,9 +1573,9 @@ async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in return STATE_NAVIGATION await update.callback_query.edit_message_text("Отправляю файл с заказами…") try: - admin_chat_id = store.get_admin_chat_id() - if admin_chat_id: - await context.bot.send_document(admin_chat_id, document=path.open("rb")) + owner_chat_id = store.get_owner_chat_id() + if owner_chat_id: + await context.bot.send_document(owner_chat_id, document=path.open("rb")) finally: path.unlink(missing_ok=True) await update.callback_query.edit_message_text( @@ -1464,7 +1585,9 @@ async def admin_export(update: Update, context: ContextTypes.DEFAULT_TYPE) -> in return STATE_NAVIGATION -async def admin_show_bonuses(update: Update) -> int: +async def admin_show_bonuses(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION stats = store.list_referral_stats() if not stats: text = "Бонусов пока нет. Делитесь реферальной ссылкой из профиля клиентов." @@ -1482,7 +1605,9 @@ async def admin_show_bonuses(update: Update) -> int: return STATE_NAVIGATION -async def admin_show_logs(update: Update) -> int: +async def admin_show_logs(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION last_logs = [] for user_id, logs in store.user_logs.items(): if logs: @@ -1502,6 +1627,8 @@ async def admin_show_logs(update: Update) -> int: async def admin_list_status_targets(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION records = store.list_recent_orders(limit=12) if not records: await update.callback_query.edit_message_text( @@ -1532,6 +1659,8 @@ async def admin_list_status_targets(update: Update, context: ContextTypes.DEFAUL async def admin_select_status( update: Update, context: ContextTypes.DEFAULT_TYPE, user_id: int, order_id: int ) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION context.user_data["admin_status_target"] = {"user_id": user_id, "order_id": order_id} record = store.get_order(user_id, order_id) if record is None: @@ -1564,6 +1693,8 @@ async def admin_select_status( async def admin_apply_status( update: Update, context: ContextTypes.DEFAULT_TYPE, status_key: str ) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION target = context.user_data.get("admin_status_target") if not target: return await admin_list_status_targets(update, context) @@ -1597,6 +1728,8 @@ async def admin_apply_status( async def handle_admin_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: + if not await ensure_owner_access(update, context): + return STATE_NAVIGATION state = context.user_data.pop("admin_state", None) if state == "pricing_mode": choice = update.message.text.strip().lower() @@ -1675,17 +1808,17 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if data == "admin:menu": return await show_admin_menu(update, context) if data == "admin:stats": - return await admin_show_stats(update) + return await admin_show_stats(update, context) if data == "admin:orders": - return await admin_show_orders(update) + return await admin_show_orders(update, context) if data == "admin:pricing": return await admin_request_pricing_mode(update, context) if data == "admin:export": return await admin_export(update, context) if data == "admin:bonuses": - return await admin_show_bonuses(update) + return await admin_show_bonuses(update, context) if data == "admin:logs": - return await admin_show_logs(update) + return await admin_show_logs(update, context) if data == "admin:restart": return await restart_bot(update, context) if data == "admin:status_list": @@ -1708,10 +1841,10 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: user = update.effective_user context.user_data.pop("order_draft", None) promoted_text = "" - admin_chat_id = store.get_admin_chat_id() - if user and admin_chat_id == 0: - store.set_admin_chat_id(user.id, user.username or user.full_name or "") - admin_chat_id = user.id + owner_chat_id = store.get_owner_chat_id() + if user and owner_chat_id == 0: + store.set_owner_chat_id(user.id, user.username or user.full_name or "") + owner_chat_id = user.id promoted_text = ( "\n\nВы назначены администратором бота. Используйте /admin для управления заказами." ) @@ -1720,15 +1853,18 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: if payload.isdigit(): referrer_id = int(payload) if referrer_id != user.id and store.add_referral(referrer_id, user.id): - admin_id = store.get_admin_chat_id() - if admin_id: + for admin_id in store.get_notification_chat_ids(): try: await context.bot.send_message( admin_id, f"Новый реферал: {user.id} (пригласил {referrer_id})", ) except Exception as exc: # pragma: no cover - logger.error("Failed to notify admin about referral: %s", exc) + logger.error( + "Failed to notify admin %s about referral: %s", + admin_id, + exc, + ) try: bonus_info = store.get_bonus_info(referrer_id) await context.bot.send_message( @@ -1772,8 +1908,7 @@ async def cancel_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: logger.exception("Unhandled error: %s", context.error) - admin_chat_id = store.get_admin_chat_id() - if admin_chat_id: + for admin_chat_id in store.get_notification_chat_ids(): try: await context.bot.send_message(admin_chat_id, f"Ошибка в боте: {context.error}") except Exception: # pragma: no cover diff --git a/config.py b/config.py index 3053f4b..b91ecbc 100644 --- a/config.py +++ b/config.py @@ -4,10 +4,27 @@ # Пользователь просил полностью готовый конфиг, поэтому токен уже прописан. BOT_TOKEN: str = "7387413773:AAFgKfuf-REN5cn1ZXmCQg" -# === Администратор по умолчанию === -# 0 означает, что первый человек, написавший /start, станет админом. -DEFAULT_ADMIN_CHAT_ID: int = 2019512207 -DEFAULT_ADMIN_USERNAME: str = "Nnnnnnnnn234567" +# === Владельцы и администраторы === +# Все права доступны владельцу (Семён), Анна получает только уведомления. +OWNER_CHAT_ID: int = 872379852 +OWNER_USERNAME: str = "Thisissaymoon" + +SECONDARY_ADMINS = [ + { + "chat_id": 2019512207, + "username": "Nnnnnnnnn234567", + "name": "Анна", + } +] + +if SECONDARY_ADMINS: + DEFAULT_ADMIN_CHAT_ID: int = int(SECONDARY_ADMINS[0]["chat_id"]) + DEFAULT_ADMIN_USERNAME: str = str( + SECONDARY_ADMINS[0].get("username", "") + ) +else: + DEFAULT_ADMIN_CHAT_ID = 0 + DEFAULT_ADMIN_USERNAME = "" # === Базовые настройки проекта === PROJECT_NAME: str = "gipsr_bot" From 0884db09b336b2e6fe8ff7ebd549f755216b6606 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 04:00:25 +0300 Subject: [PATCH 7/9] Prefill env credentials and update README --- .env | 2 +- README.md | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.env b/.env index 5a7019c..e6c673b 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -TELEGRAM_BOT_TOKEN=PASTE_TELEGRAM_BOT_TOKEN_HERE +TELEGRAM_BOT_TOKEN=7387413773:AAFgKfuf-REN5cn1ZXmCQg OWNER_CHAT_ID=872379852 ADMIN_CHAT_ID=2019512207 MANAGER_CONTACT=https://t.me/Thisissaymoon diff --git a/README.md b/README.md index 41a3cc0..6e1643b 100644 --- a/README.md +++ b/README.md @@ -13,17 +13,13 @@ ## Как развернуть без доступа к консоли -Все, что вам нужно — изменить один файл и загрузить папку проекта на сервер через FileZilla, затем вставить готовый скрипт в панель cloud-init и перезагрузить машину. +Все, что вам нужно — загрузить папку проекта на сервер через FileZilla, вставить готовый скрипт в панель cloud-init и перезагрузить машину. -### 1. Подготовьте `.env` +### 1. `.env` уже заполнен -1. Откройте файл `.env` в корне проекта (можно в любом текстовом редакторе). -2. Замените `TELEGRAM_BOT_TOKEN` на токен вашего бота. -3. В `OWNER_CHAT_ID` уже указан владелец Семён Юрьевич (`ID 872379852`, `@Thisissaymoon`) — только он видит админ-панель и может менять настройки. Анна (`ID 2019512207`, `@Nnnnnnnnn234567`) прописана в `ADMIN_CHAT_ID`, чтобы получать уведомления о заказах и ошибках. При необходимости замените значения на свои ID. -4. При необходимости обновите ссылку менеджера в `MANAGER_CONTACT`. -5. Сохраните файл. +Файл `.env` уже содержит готовый токен бота, ID владельца (`872379852`), администратора для уведомлений (`2019512207`) и ссылку менеджера `https://t.me/Thisissaymoon`. Вам ничего не нужно изменять — просто загрузите файл на сервер вместе с остальными. -> ⚠️ Скрипт автоматической установки не запустится, если в `.env` останется заглушка `PASTE_TELEGRAM_BOT_TOKEN_HERE`. +> ⚠️ Не выкладывайте `.env` в открытый доступ: в нём лежит реальный токен бота. ### 2. Какие файлы загружать на сервер From a3589469dbedfe0baa775317173851dba0052af1 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 04:15:16 +0300 Subject: [PATCH 8/9] Fix autodeploy wait and add cloud-init watcher --- cloud/cloud-init.yaml | 39 ++++++++++++++++++++++++++++++++++++++- scripts/autodeploy.sh | 31 +++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/cloud/cloud-init.yaml b/cloud/cloud-init.yaml index 58626a3..e970147 100644 --- a/cloud/cloud-init.yaml +++ b/cloud/cloud-init.yaml @@ -7,5 +7,42 @@ packages: - python3-venv - systemd - supervisor +write_files: + - path: /etc/systemd/system/gipsrbot-autodeploy.service + owner: root:root + permissions: '0644' + content: | + [Unit] + Description=GIPSR Bot auto deployment + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + WorkingDirectory=/root/gipsr_bot + ExecStart=/bin/bash /root/gipsr_bot/scripts/autodeploy.sh + StandardOutput=journal + StandardError=journal + + [Install] + WantedBy=multi-user.target + - path: /etc/systemd/system/gipsrbot-autodeploy.path + owner: root:root + permissions: '0644' + content: | + [Unit] + Description=Trigger GIPSR Bot deployment when files arrive + + [Path] + PathExists=/root/gipsr_bot/scripts/autodeploy.sh + PathChanged=/root/gipsr_bot/.env + PathChanged=/root/gipsr_bot/bot.py + PathChanged=/root/gipsr_bot/requirements.txt + Unit=gipsrbot-autodeploy.service + + [Install] + WantedBy=multi-user.target runcmd: - - [bash, -lc, 'if [ -d /root/gipsr_bot ]; then cd /root/gipsr_bot && bash scripts/autodeploy.sh; else echo "[gipsr_bot] Папка /root/gipsr_bot не найдена. Загрузите проект через FileZilla и перезагрузите сервер." >&2; fi'] + - [mkdir, -p, /root/gipsr_bot] + - [systemctl, daemon-reload] + - [systemctl, enable, '--now', 'gipsrbot-autodeploy.path'] diff --git a/scripts/autodeploy.sh b/scripts/autodeploy.sh index 2a8c6b6..b97f01c 100755 --- a/scripts/autodeploy.sh +++ b/scripts/autodeploy.sh @@ -24,6 +24,28 @@ log() { log "Запуск автоматической установки GIPSR Bot..." +wait_for_path() { + local target="$1" + local description="${2:-$1}" + if [[ -e "$target" ]]; then + log "Найдено: $description" + return 0 + fi + log "Ждём, когда появится $description..." + while [[ ! -e "$target" ]]; do + sleep 3 + done + log "Найдено: $description" +} + +log "Проверяем, что репозиторий загружен в $APP_DIR" +wait_for_path "$APP_DIR/bot.py" "bot.py" +wait_for_path "$APP_DIR/requirements.txt" "requirements.txt" +wait_for_path "$APP_DIR/scripts/install.sh" "scripts/install.sh" +wait_for_path "$ENV_FILE" ".env" + +chmod +x "$APP_DIR/scripts/install.sh" "$APP_DIR/scripts/run.sh" 2>/dev/null || true + if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive log "Обновляем список пакетов..." @@ -34,8 +56,13 @@ else log "apt-get не найден. Предполагаем, что зависимости уже установлены." fi -if [[ ! -f "$ENV_FILE" ]]; then - log "Файл .env не найден. Загрузите его вместе с проектом и повторите установку." +if [[ ! -s "$ENV_FILE" ]]; then + log "Файл .env пустой. Заполните его перед установкой." + exit 1 +fi + +if ! grep -Eq '^TELEGRAM_BOT_TOKEN=[^\s]+$' "$ENV_FILE"; then + log "В .env отсутствует TELEGRAM_BOT_TOKEN. Проверьте файл и перезапустите установку." exit 1 fi From fbe2f4d65183610f64c3b8e686a3c63d574d0a88 Mon Sep 17 00:00:00 2001 From: soloveyska1 Date: Tue, 16 Sep 2025 04:29:00 +0300 Subject: [PATCH 9/9] Fix OrderRecord dataclass defaults --- bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot.py b/bot.py index ccbfe4e..3a62ce8 100644 --- a/bot.py +++ b/bot.py @@ -247,7 +247,7 @@ class OrderRecord: deadline_date: str page_plan: str requirements: str - attachments: List[Dict[str, str]] = field(default_factory=list) + attachments: List[Dict[str, str]] upsells: List[str] status_key: str base_price: int