338 lines
16 KiB
Python
338 lines
16 KiB
Python
|
||
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())
|