import os from io import BytesIO from aiogram.types import KeyboardButton, ReplyKeyboardMarkup import asyncio from datetime import datetime import psycopg2 import psycopg2.extras from aiogram import Bot, Dispatcher, types from aiogram.filters import Command from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove from aiogram.filters.state import StateFilter from dotenv import load_dotenv import logging # Загрузка переменных окружения load_dotenv() # Настройка логирования logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Конфигурация из переменных окружения TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") if not TOKEN: raise ValueError("TELEGRAM_BOT_TOKEN не найден в переменных окружения") # ===== PostgreSQL ===== try: conn = psycopg2.connect( dbname=os.getenv("DB_NAME", "botdb"), user=os.getenv("DB_USER", "botuser"), password=os.getenv("DB_PASSWORD"), host=os.getenv("DB_HOST", "localhost"), port=os.getenv("DB_PORT", "5432") ) cur = conn.cursor() logger.info("Подключение к базе данных установлено") except Exception as e: logger.error(f"Ошибка подключения к базе данных: {e}") raise bot = Bot(token=TOKEN) dp = Dispatcher(storage=MemoryStorage()) # ===== FSM ===== class Form(StatesGroup): consent = State() fio = State() dob = State() phone = State() city = State() klimov = State() # ===== /start ===== @dp.message(Command("start")) async def start(message: types.Message, state: FSMContext): kb = [ [KeyboardButton(text="✅ Да, согласен"), KeyboardButton(text="❌ Нет")] ] keyboard = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True) await message.answer( "Привет! Я помогу пройти тест Климова.\n\n" "Сначала — согласие на обработку персональных данных.\n" "Ты согласен?", reply_markup=keyboard ) await state.set_state(Form.consent) # ===== Согласие ===== @dp.message(Form.consent) async def consent(message: types.Message, state: FSMContext): if message.text.startswith("✅"): await state.set_state(Form.fio) await message.answer("Введите своё ФИО:", reply_markup=ReplyKeyboardRemove()) else: await message.answer("Без согласия мы не можем продолжить 🙏", reply_markup=ReplyKeyboardRemove()) await state.clear() # ===== ФИО ===== @dp.message(Form.fio) async def fio(message: types.Message, state: FSMContext): await state.update_data(fio=message.text) await state.set_state(Form.dob) await message.answer("Введите дату рождения (ДД.MM.ГГГГ):") # ===== Дата рождения ===== @dp.message(Form.dob) async def dob(message: types.Message, state: FSMContext): await state.update_data(dob=message.text) await state.set_state(Form.phone) await message.answer("Введите номер телефона:") # ===== Телефон ===== @dp.message(Form.phone) async def phone(message: types.Message, state: FSMContext): await state.update_data(phone=message.text) await state.set_state(Form.city) await message.answer("Из какого ты населённого пункта?") # ===== Город + меню тестов ===== @dp.message(Form.city) async def city(message: types.Message, state: FSMContext): await state.update_data(city=message.text) data = await state.get_data() # Преобразуем дату в формат PostgreSQL try: dob_parsed = datetime.strptime(data["dob"], "%d.%m.%Y").date() except ValueError: await message.answer("Неверный формат даты. Введите дату в формате ДД.MM.ГГГГ") await state.set_state(Form.dob) return try: cur.execute( """ INSERT INTO users (telegram_id, fio, dob, phone, city) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (telegram_id) DO UPDATE SET fio=EXCLUDED.fio, dob=EXCLUDED.dob, phone=EXCLUDED.phone, city=EXCLUDED.city """, (message.from_user.id, data["fio"], dob_parsed, data["phone"], data["city"]) ) conn.commit() await message.answer("Анкета сохранена ✅") logger.info(f"Пользователь {message.from_user.id} сохранил анкету") except Exception as e: conn.rollback() logger.error(f"Ошибка при сохранении анкеты пользователя {message.from_user.id}: {e}") await message.answer("Произошла ошибка при сохранении анкеты. Попробуйте позже.") return kb = [ [KeyboardButton(text="📘 Тест Климова")], [KeyboardButton(text="📗 Тест №2"), KeyboardButton(text="📙 Тест №3")] ] keyboard = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True) await state.clear() await message.answer("Теперь выбери тест:", reply_markup=keyboard) # ===== Выбор теста после анкеты ===== # Выбор теста — только когда FSM пустой (после анкеты) @dp.message(StateFilter(None)) async def select_test(message: types.Message, state: FSMContext): if message.text == "📘 Тест Климова": try: cur.execute("SELECT id, text1, category1, text2, category2, image FROM klimov_questions ORDER BY id") questions = cur.fetchall() if not questions: await message.answer("Вопросы теста не найдены. Обратитесь к администратору.") logger.warning("Вопросы теста Климова не найдены в базе данных") return await state.update_data(klimov_questions=questions, klimov_index=0, klimov_result={}) await state.set_state(Form.klimov) await send_klimov_question(message.from_user.id, state) logger.info(f"Пользователь {message.from_user.id} начал тест Климова") except Exception as e: logger.error(f"Ошибка при загрузке теста Климова: {e}") await message.answer("Произошла ошибка при загрузке теста. Попробуйте позже.") else: await message.answer("Пока этот тест не реализован.") import os from aiogram.types import KeyboardButton, ReplyKeyboardMarkup, FSInputFile from aiogram.fsm.context import FSMContext async def send_klimov_question(user_id, state: FSMContext): data = await state.get_data() index = data["klimov_index"] questions = data["klimov_questions"] if index >= len(questions): await send_klimov_result(user_id, state) await state.clear() return q = questions[index] # (id, text1, category1, text2, category2, image) # Клавиатура с вариантами kb = [ [KeyboardButton(text=q[1])], [KeyboardButton(text=q[3])] ] keyboard = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True) # Отправка картинки, если есть image_filename = os.path.basename(q[5].strip()) if q[5] else None if image_filename: image_path = os.path.join(os.getcwd(), "images", image_filename) logger.debug(f"Путь к изображению: {image_path}, существует: {os.path.isfile(image_path)}") if os.path.isfile(image_path): try: photo = FSInputFile(image_path) await bot.send_photo( chat_id=user_id, photo=photo, caption=f"Вопрос {index+1} из {len(questions)}:\nВыберите вариант", reply_markup=keyboard ) return except Exception as e: logger.error(f"Ошибка отправки изображения {image_path}: {e}") # Продолжаем с текстовым сообщением # Если картинки нет, просто отправляем текст await bot.send_message( chat_id=user_id, text=f"Вопрос {index+1} из {len(questions)}:\nВыберите вариант", reply_markup=keyboard ) # ===== Ответ на вопросы ===== @dp.message(StateFilter(Form.klimov)) async def klimov_answer(message: types.Message, state: FSMContext): data = await state.get_data() index = data["klimov_index"] questions = data["klimov_questions"] result = data["klimov_result"] q = questions[index] # Проверяем какой вариант выбрал пользователь if message.text == q[1]: result[q[2]] = result.get(q[2], 0) + 1 elif message.text == q[3]: result[q[4]] = result.get(q[4], 0) + 1 index += 1 await state.update_data(klimov_index=index, klimov_result=result) # Переходим к следующему вопросу await send_klimov_question(message.from_user.id, state) # ===== Функция показа вопроса ===== # Функция показа вопроса # ===== Функция для сохранения результата в БД ===== async def save_klimov_result(user_id, state: FSMContext): data = await state.get_data() result = data.get("klimov_result", {}) try: conn = psycopg2.connect( dbname=os.getenv("DB_NAME", "botdb"), user=os.getenv("DB_USER", "botuser"), password=os.getenv("DB_PASSWORD"), host=os.getenv("DB_HOST", "localhost"), port=os.getenv("DB_PORT", "5432") ) cur = conn.cursor() # Получаем user_id из таблицы users cur.execute("SELECT id FROM users WHERE telegram_id = %s", (user_id,)) user_row = cur.fetchone() if user_row: db_user_id = user_row[0] cur.execute( "INSERT INTO results (user_id, test_name, result) VALUES (%s, %s, %s)", (db_user_id, "Тест Климова", psycopg2.extras.Json(result)) ) conn.commit() logger.info(f"Результат теста сохранен для пользователя {user_id}") cur.close() conn.close() except Exception as e: logger.error(f"Ошибка сохранения результата теста: {e}") # Не прерываем работу бота из-за ошибки сохранения # Словарь с описаниями профессий по категориям professions_desc = { "Человек-природа": "К типу 'человек-природа' можно отнести профессии, связанные с изучением живой и неживой природы (микробиолог, агрохимик, геолог), с уходом за растениями и животными (лесовод, овощевод, зоотехник).", "Человек-техника": "Тип 'человек-техника' включает в себя профессии, связанные с созданием, монтажом, сборкой и наладкой технических устройств (каменщик, монтажник, сварщик, инженер-конструктор), эксплуатацией технических средств (водитель, крановщик, токарь, швея), ремонтом техники.", "Человек-человек": "К профессиям типа 'человек-человек' относятся профессии, связанные с медицинским обслуживанием (врач, медсестра, санитарка), обучением и воспитанием (воспитатель, няня, учитель, преподаватель, тренер), бытовым обслуживанием (продавец, проводник, официант).", "Человек-знаковая система": "Тип 'человек - знаковая система' объединяет профессии, связанные с текстами (корректор, оператор ПК, переводчик, библиотекарь), с цифрами, формулами и таблицами (программист, экономист, бухгалтер, кассир), с чертежами, картами, схемами (штурман, чертежник).", "Человек-художественный образ": "К типу 'человек - художественный образ' можно отнести профессии, связанные с созданием, проектированием, моделированием художественных произведений (художник, журналист, модельер, композитор), с воспроизведением, изготовлением различных изделий по эскизу, образцу." } # ===== Функция для форматированного вывода результатов ===== # ===== Форматированный вывод результатов с процентами по максимуму ===== async def send_klimov_result(user_id, state: FSMContext): data = await state.get_data() result = data.get("klimov_result", {}) questions = data.get("klimov_questions", []) if not questions: await bot.send_message(user_id, "Вы не ответили ни на один вопрос.") return # Подсчёт максимального количества очков для каждой категории max_points = {} for q in questions: # q[2] и q[4] — категории для вариантов max_points[q[2]] = max_points.get(q[2], 0) + 1 max_points[q[4]] = max_points.get(q[4], 0) + 1 output = "Ваши результаты:\n" for category, max_pt in max_points.items(): pts = result.get(category, 0) percent = (pts / max_pt) * 100 output += f"- {category} - {percent:.1f}%\n" # Определяем категорию с наибольшим результатом if result: top_category = max(result, key=result.get) output += f"\nТип профессии: {top_category}" await bot.send_message(user_id, output, reply_markup=types.ReplyKeyboardRemove()) # Затем по очереди отправляем описания for category in ["Человек-природа", "Человек-техника", "Человек-человек", "Человек-знаковая система", "Человек-художественный образ"]: await bot.send_message(user_id, f"🟢 {category}\n\n{professions_desc[category]}") # Последнее сообщение с контактами / ссылками await bot.send_message( user_id, " Задать вопрос или записаться на индивидуальную консультацию по построению образовательного маршрута можно здесь: @chrtakova11\n\n" "Узнать подробнее о профессиях: https://postupi.online/professii/so/" ) # ===== Запуск бота ===== async def main(): await dp.start_polling(bot) if __name__ == "__main__": asyncio.run(main())