Files
bot-telegram/telegram_bot.py
2025-09-22 11:54:42 +05:00

390 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Современный Telegram бот с использованием SQLAlchemy и сервисов
"""
import asyncio
import io
from datetime import datetime
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, BufferedInputFile
from aiogram.filters.state import StateFilter
import logging
from bot_config import config
from database.database import get_db_session
from database.repositories import UserRepository, SettingsRepository
from database.services import TestService, QuestionService, ImageService
# Настройка логирования
logging.basicConfig(
level=getattr(logging, config.LOG_LEVEL),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Инициализация бота
bot = Bot(token=config.TELEGRAM_BOT_TOKEN)
dp = Dispatcher(storage=MemoryStorage())
# ===== FSM States =====
class Form(StatesGroup):
consent = State()
fio = State()
dob = State()
phone = State()
city = State()
test_in_progress = State()
# ===== /start command =====
@dp.message(Command("start"))
async def start(message: types.Message, state: FSMContext):
"""Обработка команды /start"""
try:
# Создаем или получаем пользователя
with get_db_session() as session:
user, created = UserRepository.get_or_create_user(
session,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name
)
session.commit()
if created:
logger.info(f"Создан новый пользователь: {message.from_user.id}")
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)
except Exception as e:
logger.error(f"Ошибка в команде start: {e}")
await message.answer("Произошла ошибка. Попробуйте позже.")
# ===== Согласие на обработку данных =====
@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 collect_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 collect_dob(message: types.Message, state: FSMContext):
"""Сбор даты рождения"""
try:
# Проверяем формат даты
datetime.strptime(message.text, "%d.%m.%Y")
await state.update_data(dob=message.text)
await state.set_state(Form.phone)
await message.answer("Введите номер телефона:")
except ValueError:
await message.answer("Неверный формат даты. Введите дату в формате ДД.MM.ГГГГ")
# ===== Сбор телефона =====
@dp.message(Form.phone)
async def collect_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 collect_city_and_save(message: types.Message, state: FSMContext):
"""Сбор города и сохранение анкеты пользователя"""
try:
await state.update_data(city=message.text)
data = await state.get_data()
# Преобразуем дату
dob_parsed = datetime.strptime(data["dob"], "%d.%m.%Y").date()
# Сохраняем данные пользователя
with get_db_session() as session:
user = UserRepository.get_user_by_telegram_id(session, message.from_user.id)
if user:
# Обновляем дополнительные данные пользователя
# Здесь нужно будет расширить модель User для хранения ФИО, даты рождения и т.д.
# Или создать отдельную таблицу для профилей пользователей
logger.info(f"Пользователь {message.from_user.id} заполнил анкету: "
f"ФИО: {data['fio']}, ДР: {dob_parsed}, Телефон: {data['phone']}, Город: {data['city']}")
session.commit()
await message.answer("Анкета сохранена ✅")
# Показываем доступные тесты
await show_available_tests(message, state)
except ValueError:
await message.answer("Неверный формат даты. Введите дату в формате ДД.MM.ГГГГ")
await state.set_state(Form.dob)
except Exception as e:
logger.error(f"Ошибка при сохранении анкеты пользователя {message.from_user.id}: {e}")
await message.answer("Произошла ошибка при сохранении анкеты. Попробуйте позже.")
async def show_available_tests(message: types.Message, state: FSMContext):
"""Показать доступные тесты"""
try:
with get_db_session() as session:
tests = TestService.get_available_tests(session)
if not tests:
await message.answer("К сожалению, тесты пока недоступны.")
return
# Создаем клавиатуру с тестами
kb = []
for test in tests:
kb.append([KeyboardButton(text=test.title)])
keyboard = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True)
await state.clear()
await message.answer("Выберите тест:", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка при получении списка тестов: {e}")
await message.answer("Произошла ошибка при загрузке тестов.")
# ===== Выбор теста =====
@dp.message(StateFilter(None))
async def select_test(message: types.Message, state: FSMContext):
"""Обработка выбора теста"""
try:
with get_db_session() as session:
# Получаем пользователя
user = UserRepository.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("Пользователь не найден. Используйте /start")
return
# Ищем тест по названию
test = TestService.get_test_by_title(session, message.text)
if not test:
await message.answer("Тест не найден или недоступен.")
return
# Начинаем тест
test_result = TestService.start_test(session, user.id, test.id)
session.commit()
# Получаем вопросы теста
questions = TestService.get_test_questions(session, test.id)
if not questions:
await message.answer("В тесте нет вопросов. Обратитесь к администратору.")
return
# Сохраняем данные теста в состоянии
await state.update_data(
test_id=test.id,
test_result_id=test_result.id,
questions=[(q.id, q.order_index) for q in questions],
current_question_index=0
)
await state.set_state(Form.test_in_progress)
# Отправляем первый вопрос
await send_question(message.from_user.id, state, session)
logger.info(f"Пользователь {message.from_user.id} начал тест: {test.title}")
except Exception as e:
logger.error(f"Ошибка при выборе теста: {e}")
await message.answer("Произошла ошибка при запуске теста. Попробуйте позже.")
async def send_question(user_id: int, state: FSMContext, session):
"""Отправка вопроса пользователю"""
try:
data = await state.get_data()
questions = data.get("questions", [])
current_index = data.get("current_question_index", 0)
if current_index >= len(questions):
# Тест завершен
await complete_test(user_id, state, session)
return
question_id, _ = questions[current_index]
# Получаем вопрос с вариантами ответов
question = QuestionService.get_question_with_options(session, question_id)
if not question:
await bot.send_message(user_id, "Ошибка при загрузке вопроса.")
return
# Форматируем вопрос для бота
question_data = QuestionService.format_question_for_bot(session, question)
# Создаем клавиатуру с вариантами ответов
kb = []
for option in question_data['options']:
kb.append([KeyboardButton(text=option['text'])])
keyboard = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True)
# Отправляем изображение, если есть
if question_data.get('image'):
image_data = question_data['image']
try:
image_file = BufferedInputFile(
image_data['data'],
filename=image_data['filename']
)
await bot.send_photo(
chat_id=user_id,
photo=image_file,
caption=f"Вопрос {current_index + 1} из {len(questions)}:\\n{question_data['text'] or 'Выберите вариант'}",
reply_markup=keyboard
)
return
except Exception as e:
logger.error(f"Ошибка отправки изображения: {e}")
# Отправляем текстовый вопрос
question_text = question_data['text'] or "Выберите вариант"
await bot.send_message(
chat_id=user_id,
text=f"Вопрос {current_index + 1} из {len(questions)}:\\n{question_text}",
reply_markup=keyboard
)
except Exception as e:
logger.error(f"Ошибка при отправке вопроса: {e}")
await bot.send_message(user_id, "Произошла ошибка. Попробуйте позже.")
# ===== Обработка ответов на вопросы =====
@dp.message(Form.test_in_progress)
async def handle_answer(message: types.Message, state: FSMContext):
"""Обработка ответа на вопрос теста"""
try:
data = await state.get_data()
questions = data.get("questions", [])
current_index = data.get("current_question_index", 0)
test_result_id = data.get("test_result_id")
if current_index >= len(questions):
return
question_id, _ = questions[current_index]
with get_db_session() as session:
# Получаем вопрос с вариантами ответов
question = QuestionService.get_question_with_options(session, question_id)
if not question:
await message.answer("Ошибка при обработке ответа.")
return
# Ищем выбранный вариант ответа
selected_option = None
for option in question.answer_options:
if option.option_text == message.text:
selected_option = option
break
if not selected_option:
await message.answer("Пожалуйста, выберите один из предложенных вариантов.")
return
# Сохраняем ответ
TestService.save_answer(session, test_result_id, question_id, selected_option.id)
session.commit()
# Переходим к следующему вопросу
next_index = current_index + 1
await state.update_data(current_question_index=next_index)
# Отправляем следующий вопрос или завершаем тест
await send_question(message.from_user.id, state, session)
except Exception as e:
logger.error(f"Ошибка при обработке ответа: {e}")
await message.answer("Произошла ошибка при обработке ответа.")
async def complete_test(user_id: int, state: FSMContext, session):
"""Завершение теста и показ результатов"""
try:
data = await state.get_data()
test_result_id = data.get("test_result_id")
# Завершаем тест и подсчитываем результаты
test_result = TestService.complete_test(session, test_result_id)
session.commit()
# Получаем форматированные результаты
results = TestService.get_test_results(session, test_result_id)
# Формируем сообщение с результатами
result_text = "🎉 Тест завершен!\\n\\nВаши результаты:\\n"
for category, data in results.items():
result_text += f"{category}: {data['percentage']}%\\n"
# Определяем ведущий тип
if results:
top_category = max(results.keys(), key=lambda x: results[x]['percentage'])
result_text += f"\\n🏆 Ваш ведущий тип: {top_category}"
await bot.send_message(user_id, result_text, reply_markup=ReplyKeyboardRemove())
# Отправляем описания профессий
profession_descriptions = {
"Человек-природа": "К типу 'человек-природа' можно отнести профессии, связанные с изучением живой и неживой природы (микробиолог, агрохимик, геолог), с уходом за растениями и животными (лесовод, овощевод, зоотехник).",
"Человек-техника": "Тип 'человек-техника' включает в себя профессии, связанные с созданием, монтажом, сборкой и наладкой технических устройств (каменщик, монтажник, сварщик, инженер-конструктор), эксплуатацией технических средств (водитель, крановщик, токарь, швея), ремонтом техники.",
"Человек-человек": "К профессиям типа 'человек-человек' относятся профессии, связанные с медицинским обслуживанием (врач, медсестра, санитарка), обучением и воспитанием (воспитатель, няня, учитель, преподаватель, тренер), бытовым обслуживанием (продавец, проводник, официант).",
"Человек-знаковая система": "Тип 'человек - знаковая система' объединяет профессии, связанные с текстами (корректор, оператор ПК, переводчик, библиотекарь), с цифрами, формулами и таблицами (программист, экономист, бухгалтер, кассир), с чертежами, картами, схемами (штурман, чертежник).",
"Человек-художественный образ": "К типу 'человек - художественный образ' можно отнести профессии, связанные с созданием, проектированием, моделированием художественных произведений (художник, журналист, модельер, композитор), с воспроизведением, изготовлением различных изделий по эскизу, образцу."
}
for category, description in profession_descriptions.items():
await bot.send_message(user_id, f"🟢 {category}\\n\\n{description}")
# Финальное сообщение
await bot.send_message(user_id,
"Задать вопрос или записаться на индивидуальную консультацию по построению образовательного маршрута можно здесь: @chrtakova11\\n\\n"
"Узнать подробнее о профессиях: https://postupi.online/professii/so/")
await state.clear()
logger.info(f"Пользователь {user_id} завершил тест")
except Exception as e:
logger.error(f"Ошибка при завершении теста: {e}")
await bot.send_message(user_id, "Произошла ошибка при подсчете результатов.")
# ===== Запуск бота =====
async def main():
"""Основная функция запуска бота"""
logger.info("Запуск Telegram бота...")
try:
await dp.start_polling(bot)
except Exception as e:
logger.error(f"Ошибка при запуске бота: {e}")
finally:
logger.info("Бот остановлен")
if __name__ == "__main__":
asyncio.run(main())