Добавлены основные функции MVP: тематические подборки, импорт слов, диалоговая практика, напоминания и тест уровня

Новые команды:
- /words [тема] - AI-генерация тематических подборок слов (10 слов по теме с учётом уровня)
- /import - извлечение до 15 ключевых слов из текста (книги, статьи, песни)
- /practice - диалоговая практика с AI в 6 сценариях (ресторан, магазин, путешествие, работа, врач, общение)
- /reminder - настройка ежедневных напоминаний по расписанию
- /level_test - тест из 7 вопросов для определения уровня английского (A1-C2)

Основные изменения:
- AI сервис: добавлены методы generate_thematic_words, extract_words_from_text, start_conversation, continue_conversation, generate_level_test
- Диалоговая практика: исправление ошибок в реальном времени, подсказки, перевод реплик
- Напоминания: APScheduler для ежедневной отправки напоминаний в выбранное время
- Тест уровня: автоматическое определение уровня при регистрации, можно пропустить
- База данных: добавлены поля reminders_enabled, last_reminder_sent
- Vocabulary service: метод get_word_by_original для проверки дубликатов
- Зависимости: apscheduler==3.10.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 15:46:02 +03:00
parent 2c51fa19b6
commit 72a63eeda5
13 changed files with 1781 additions and 23 deletions

248
bot/handlers/import_text.py Normal file
View File

@@ -0,0 +1,248 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from database.models import WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
router = Router()
class ImportStates(StatesGroup):
"""Состояния для импорта слов из текста"""
waiting_for_text = State()
viewing_words = State()
@router.message(Command("import"))
async def cmd_import(message: Message, state: FSMContext):
"""Обработчик команды /import"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("Сначала запусти бота командой /start")
return
await state.set_state(ImportStates.waiting_for_text)
await message.answer(
"📖 <b>Импорт слов из текста</b>\n\n"
"Отправь мне текст на английском языке, и я извлеку из него "
"полезные слова для изучения.\n\n"
"Можно отправить:\n"
"• Отрывок из книги или статьи\n"
"• Текст песни\n"
"• Описание чего-либо\n"
"• Любой интересный текст\n\n"
"Отправь /cancel для отмены."
)
@router.message(Command("cancel"), ImportStates.waiting_for_text)
async def cancel_import(message: Message, state: FSMContext):
"""Отмена импорта"""
await state.clear()
await message.answer("❌ Импорт отменён.")
@router.message(ImportStates.waiting_for_text)
async def process_text(message: Message, state: FSMContext):
"""Обработка текста от пользователя"""
text = message.text.strip()
if len(text) < 50:
await message.answer(
"⚠️ Текст слишком короткий. Отправь текст минимум из 50 символов.\n"
"Или используй /cancel для отмены."
)
return
if len(text) > 3000:
await message.answer(
"⚠️ Текст слишком длинный (максимум 3000 символов).\n"
"Отправь текст покороче или используй /cancel для отмены."
)
return
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
# Показываем индикатор обработки
processing_msg = await message.answer("🔄 Анализирую текст и извлекаю слова...")
# Извлекаем слова через AI
words = await ai_service.extract_words_from_text(
text=text,
level=user.level.value,
max_words=15
)
await processing_msg.delete()
if not words:
await message.answer(
"Не удалось извлечь слова из текста. Попробуй другой текст или повтори позже."
)
await state.clear()
return
# Сохраняем данные в состоянии
await state.update_data(
words=words,
user_id=user.id,
original_text=text
)
await state.set_state(ImportStates.viewing_words)
# Показываем извлечённые слова
await show_extracted_words(message, words)
async def show_extracted_words(message: Message, words: list):
"""Показать извлечённые слова с кнопками для добавления"""
text = f"📚 <b>Найдено слов: {len(words)}</b>\n\n"
for idx, word_data in enumerate(words, 1):
text += (
f"{idx}. <b>{word_data['word']}</b> "
f"[{word_data.get('transcription', '')}]\n"
f" {word_data['translation']}\n"
)
if word_data.get('context'):
# Укорачиваем контекст, если он слишком длинный
context = word_data['context']
if len(context) > 80:
context = context[:77] + "..."
text += f" <i>«{context}»</i>\n"
text += "\n"
text += "Выбери слова, которые хочешь добавить в словарь:"
# Создаем кнопки для каждого слова (по 2 в ряд)
keyboard = []
for idx, word_data in enumerate(words):
button = InlineKeyboardButton(
text=f" {word_data['word']}",
callback_data=f"import_word_{idx}"
)
# Добавляем по 2 кнопки в ряд
if len(keyboard) == 0 or len(keyboard[-1]) == 2:
keyboard.append([button])
else:
keyboard[-1].append(button)
# Кнопка "Добавить все"
keyboard.append([
InlineKeyboardButton(text="✅ Добавить все", callback_data="import_all_words")
])
# Кнопка "Закрыть"
keyboard.append([
InlineKeyboardButton(text="❌ Закрыть", callback_data="close_import")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("import_word_"), ImportStates.viewing_words)
async def import_single_word(callback: CallbackQuery, state: FSMContext):
"""Добавить одно слово из импорта"""
word_index = int(callback.data.split("_")[2])
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
if word_index >= len(words):
await callback.answer("❌ Ошибка: слово не найдено")
return
word_data = words[word_index]
async with async_session_maker() as session:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True)
return
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data['translation'],
transcription=word_data.get('transcription'),
examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [],
source=WordSource.CONTEXT,
category='imported',
difficulty_level=None
)
await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь")
@router.callback_query(F.data == "import_all_words", ImportStates.viewing_words)
async def import_all_words(callback: CallbackQuery, state: FSMContext):
"""Добавить все слова из импорта"""
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
added_count = 0
skipped_count = 0
async with async_session_maker() as session:
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
skipped_count += 1
continue
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data['translation'],
transcription=word_data.get('transcription'),
examples=[{"en": word_data.get('context', ''), "ru": ""}] if word_data.get('context') else [],
source=WordSource.CONTEXT,
category='imported',
difficulty_level=None
)
added_count += 1
result_text = f"✅ Добавлено слов: <b>{added_count}</b>"
if skipped_count > 0:
result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}"
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(result_text)
await state.clear()
await callback.answer()
@router.callback_query(F.data == "close_import", ImportStates.viewing_words)
async def close_import(callback: CallbackQuery, state: FSMContext):
"""Закрыть импорт"""
await callback.message.delete()
await state.clear()
await callback.answer()

264
bot/handlers/level_test.py Normal file
View File

@@ -0,0 +1,264 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from database.models import LanguageLevel
from services.user_service import UserService
from services.ai_service import ai_service
router = Router()
class LevelTestStates(StatesGroup):
"""Состояния для прохождения теста уровня"""
taking_test = State()
@router.message(Command("level_test"))
async def cmd_level_test(message: Message, state: FSMContext):
"""Обработчик команды /level_test"""
await start_level_test(message, state)
async def start_level_test(message: Message, state: FSMContext):
"""Начать тест определения уровня"""
# Показываем описание теста
await message.answer(
"📊 <b>Тест определения уровня</b>\n\n"
"Этот короткий тест поможет определить твой уровень английского.\n\n"
"📋 Тест включает 7 вопросов:\n"
"• Грамматика\n"
"• Лексика\n"
"• Понимание\n\n"
"⏱ Займёт около 2-3 минут\n\n"
"Готов начать?"
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✅ Начать тест", callback_data="start_test")],
[InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_test")]
])
await message.answer("Нажми кнопку когда будешь готов:", reply_markup=keyboard)
@router.callback_query(F.data == "cancel_test")
async def cancel_test(callback: CallbackQuery, state: FSMContext):
"""Отменить тест"""
await state.clear()
await callback.message.delete()
await callback.message.answer("❌ Тест отменён")
await callback.answer()
@router.callback_query(F.data == "start_test")
async def begin_test(callback: CallbackQuery, state: FSMContext):
"""Начать прохождение теста"""
await callback.message.delete()
# Показываем индикатор загрузки
loading_msg = await callback.message.answer("🔄 Генерирую вопросы...")
# Генерируем тест через AI
questions = await ai_service.generate_level_test()
await loading_msg.delete()
if not questions:
await callback.message.answer(
"Не удалось сгенерировать тест. Попробуй позже или используй /settings для ручной установки уровня."
)
await state.clear()
await callback.answer()
return
# Сохраняем данные в состоянии
await state.update_data(
questions=questions,
current_question=0,
correct_answers=0,
answers=[] # Для отслеживания ответов по уровням
)
await state.set_state(LevelTestStates.taking_test)
# Показываем первый вопрос
await show_question(callback.message, state)
await callback.answer()
async def show_question(message: Message, state: FSMContext):
"""Показать текущий вопрос"""
data = await state.get_data()
questions = data.get('questions', [])
current_idx = data.get('current_question', 0)
if current_idx >= len(questions):
# Тест завершён
await finish_test(message, state)
return
question = questions[current_idx]
# Формируем текст вопроса
text = (
f"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\n\n"
f"<b>{question['question']}</b>\n"
f"<i>{question.get('question_ru', '')}</i>\n\n"
)
# Создаем кнопки с вариантами ответа
keyboard = []
letters = ['A', 'B', 'C', 'D']
for idx, option in enumerate(question['options']):
keyboard.append([
InlineKeyboardButton(
text=f"{letters[idx]}) {option}",
callback_data=f"answer_{idx}"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("answer_"), LevelTestStates.taking_test)
async def process_answer(callback: CallbackQuery, state: FSMContext):
"""Обработать ответ на вопрос"""
answer_idx = int(callback.data.split("_")[1])
data = await state.get_data()
questions = data.get('questions', [])
current_idx = data.get('current_question', 0)
correct_answers = data.get('correct_answers', 0)
answers = data.get('answers', [])
question = questions[current_idx]
is_correct = (answer_idx == question['correct'])
# Сохраняем результат
if is_correct:
correct_answers += 1
# Сохраняем ответ с уровнем вопроса
answers.append({
'level': question['level'],
'correct': is_correct
})
# Показываем результат
if is_correct:
result_text = "✅ Правильно!"
else:
correct_option = question['options'][question['correct']]
result_text = f"❌ Неправильно\nПравильный ответ: <b>{correct_option}</b>"
await callback.message.edit_text(
f"❓ <b>Вопрос {current_idx + 1} из {len(questions)}</b>\n\n"
f"{result_text}"
)
# Переходим к следующему вопросу
await state.update_data(
current_question=current_idx + 1,
correct_answers=correct_answers,
answers=answers
)
# Небольшая пауза перед следующим вопросом
import asyncio
await asyncio.sleep(1.5)
await show_question(callback.message, state)
await callback.answer()
async def finish_test(message: Message, state: FSMContext):
"""Завершить тест и определить уровень"""
data = await state.get_data()
questions = data.get('questions', [])
correct_answers = data.get('correct_answers', 0)
answers = data.get('answers', [])
total = len(questions)
accuracy = int((correct_answers / total) * 100) if total > 0 else 0
# Определяем уровень на основе правильных ответов по уровням
level = determine_level(answers)
# Сохраняем уровень в базе данных
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.chat.id)
if user:
user.level = level
await session.commit()
# Описания уровней
level_descriptions = {
"A1": "Начальный - понимаешь основные фразы и можешь представиться",
"A2": "Элементарный - можешь общаться на простые темы",
"B1": "Средний - можешь поддержать беседу на знакомые темы",
"B2": "Выше среднего - свободно общаешься в большинстве ситуаций",
"C1": "Продвинутый - используешь язык гибко и эффективно",
"C2": "Профессиональный - владеешь языком на уровне носителя"
}
await state.clear()
result_text = (
f"🎉 <b>Тест завершён!</b>\n\n"
f"📊 Результаты:\n"
f"Правильных ответов: <b>{correct_answers}</b> из {total}\n"
f"Точность: <b>{accuracy}%</b>\n\n"
f"🎯 Твой уровень: <b>{level.value}</b>\n"
f"<i>{level_descriptions.get(level.value, '')}</i>\n\n"
f"Теперь задания и материалы будут подбираться под твой уровень!\n"
f"Ты можешь изменить уровень в любое время через /settings"
)
await message.answer(result_text)
def determine_level(answers: list) -> LanguageLevel:
"""
Определить уровень на основе ответов
Args:
answers: Список ответов с уровнями
Returns:
Определённый уровень
"""
# Подсчитываем правильные ответы по уровням
level_stats = {
'A1': {'correct': 0, 'total': 0},
'A2': {'correct': 0, 'total': 0},
'B1': {'correct': 0, 'total': 0},
'B2': {'correct': 0, 'total': 0},
'C1': {'correct': 0, 'total': 0},
'C2': {'correct': 0, 'total': 0}
}
for answer in answers:
level = answer['level']
if level in level_stats:
level_stats[level]['total'] += 1
if answer['correct']:
level_stats[level]['correct'] += 1
# Определяем уровень: ищем последний уровень, где правильно >= 50%
levels_order = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
determined_level = 'A1'
for level in levels_order:
if level_stats[level]['total'] > 0:
accuracy = level_stats[level]['correct'] / level_stats[level]['total']
if accuracy >= 0.5: # 50% и выше
determined_level = level
else:
# Если не прошёл этот уровень, останавливаемся
break
return LanguageLevel[determined_level]

228
bot/handlers/practice.py Normal file
View File

@@ -0,0 +1,228 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from services.user_service import UserService
from services.ai_service import ai_service
router = Router()
class PracticeStates(StatesGroup):
"""Состояния для диалоговой практики"""
choosing_scenario = State()
in_conversation = State()
# Доступные сценарии
SCENARIOS = {
"restaurant": "🍽️ Ресторан",
"shopping": "🛍️ Магазин",
"travel": "✈️ Путешествие",
"work": "💼 Работа",
"doctor": "🏥 Врач",
"casual": "💬 Общение"
}
@router.message(Command("practice"))
async def cmd_practice(message: Message, state: FSMContext):
"""Обработчик команды /practice"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("Сначала запусти бота командой /start")
return
# Показываем выбор сценария
keyboard = []
for scenario_id, scenario_name in SCENARIOS.items():
keyboard.append([
InlineKeyboardButton(
text=scenario_name,
callback_data=f"scenario_{scenario_id}"
)
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await state.update_data(user_id=user.id, level=user.level.value)
await state.set_state(PracticeStates.choosing_scenario)
await message.answer(
"💬 <b>Диалоговая практика с AI</b>\n\n"
"Выбери сценарий для разговора:\n\n"
"• AI будет играть роль собеседника\n"
"• Ты можешь общаться на английском\n"
"• AI будет исправлять твои ошибки\n"
"• Используй /stop для завершения диалога\n\n"
"Выбери сценарий:",
reply_markup=reply_markup
)
@router.callback_query(F.data.startswith("scenario_"), PracticeStates.choosing_scenario)
async def start_scenario(callback: CallbackQuery, state: FSMContext):
"""Начать диалог с выбранным сценарием"""
scenario = callback.data.split("_")[1]
data = await state.get_data()
level = data.get('level', 'B1')
# Удаляем клавиатуру
await callback.message.edit_reply_markup(reply_markup=None)
# Показываем индикатор
thinking_msg = await callback.message.answer("🤔 AI готовится к диалогу...")
# Начинаем диалог
conversation_start = await ai_service.start_conversation(scenario, level)
await thinking_msg.delete()
# Сохраняем данные в состоянии
await state.update_data(
scenario=scenario,
scenario_name=SCENARIOS[scenario],
conversation_history=[],
message_count=0
)
await state.set_state(PracticeStates.in_conversation)
# Формируем сообщение
text = (
f"<b>{SCENARIOS[scenario]}</b>\n\n"
f"📝 <i>{conversation_start.get('context', '')}</i>\n\n"
f"<b>AI:</b> {conversation_start.get('message', '')}\n"
f"<i>({conversation_start.get('translation', '')})</i>\n\n"
"💡 <b>Подсказки:</b>\n"
)
for suggestion in conversation_start.get('suggestions', []):
text += f"{suggestion}\n"
text += "\n📝 Напиши свой ответ на английском или используй /stop для завершения"
# Кнопки управления
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="💡 Показать подсказки", callback_data="show_hints")],
[InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")]
])
await callback.message.answer(text, reply_markup=keyboard)
await callback.answer()
@router.message(Command("stop"), PracticeStates.in_conversation)
async def stop_practice(message: Message, state: FSMContext):
"""Завершить диалоговую практику"""
data = await state.get_data()
message_count = data.get('message_count', 0)
await state.clear()
await message.answer(
f"✅ <b>Диалог завершён!</b>\n\n"
f"Сообщений обменено: <b>{message_count}</b>\n\n"
"Отличная работа! Продолжай практиковаться.\n"
"Используй /practice для нового диалога."
)
@router.callback_query(F.data == "stop_practice", PracticeStates.in_conversation)
async def stop_practice_callback(callback: CallbackQuery, state: FSMContext):
"""Завершить диалог через кнопку"""
data = await state.get_data()
message_count = data.get('message_count', 0)
await callback.message.delete()
await state.clear()
await callback.message.answer(
f"✅ <b>Диалог завершён!</b>\n\n"
f"Сообщений обменено: <b>{message_count}</b>\n\n"
"Отличная работа! Продолжай практиковаться.\n"
"Используй /practice для нового диалога."
)
await callback.answer()
@router.message(PracticeStates.in_conversation)
async def handle_conversation(message: Message, state: FSMContext):
"""Обработка сообщений в диалоге"""
user_message = message.text.strip()
if not user_message:
await message.answer("Напиши что-нибудь на английском или используй /stop для завершения")
return
data = await state.get_data()
conversation_history = data.get('conversation_history', [])
scenario = data.get('scenario', 'casual')
level = data.get('level', 'B1')
message_count = data.get('message_count', 0)
# Показываем индикатор
thinking_msg = await message.answer("🤔 AI думает...")
# Добавляем сообщение пользователя в историю
conversation_history.append({
"role": "user",
"content": user_message
})
# Получаем ответ от AI
ai_response = await ai_service.continue_conversation(
conversation_history=conversation_history,
user_message=user_message,
scenario=scenario,
level=level
)
await thinking_msg.delete()
# Добавляем ответ AI в историю
conversation_history.append({
"role": "assistant",
"content": ai_response.get('response', '')
})
# Обновляем состояние
message_count += 1
await state.update_data(
conversation_history=conversation_history,
message_count=message_count
)
# Формируем ответ
text = ""
# Показываем feedback, если есть ошибки
feedback = ai_response.get('feedback', {})
if feedback.get('has_errors') and feedback.get('corrections'):
text += f"⚠️ <b>Исправления:</b>\n{feedback['corrections']}\n\n"
if feedback.get('comment'):
text += f"💬 {feedback['comment']}\n\n"
# Ответ AI
text += (
f"<b>AI:</b> {ai_response.get('response', '')}\n"
f"<i>({ai_response.get('translation', '')})</i>\n\n"
)
# Подсказки
suggestions = ai_response.get('suggestions', [])
if suggestions:
text += "💡 <b>Подсказки:</b>\n"
for suggestion in suggestions[:3]:
text += f"{suggestion}\n"
# Кнопки
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔚 Завершить диалог", callback_data="stop_practice")]
])
await message.answer(text, reply_markup=keyboard)

172
bot/handlers/reminder.py Normal file
View File

@@ -0,0 +1,172 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from services.user_service import UserService
router = Router()
class ReminderStates(StatesGroup):
"""Состояния для настройки напоминаний"""
waiting_for_time = State()
@router.message(Command("reminder"))
async def cmd_reminder(message: Message):
"""Обработчик команды /reminder"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("Сначала запусти бота командой /start")
return
# Формируем текст
status = "✅ Включены" if user.reminders_enabled else "❌ Выключены"
time_text = user.daily_task_time if user.daily_task_time else "Не установлено"
text = (
f"⏰ <b>Напоминания</b>\n\n"
f"Статус: {status}\n"
f"Время: {time_text} UTC\n\n"
f"Напоминания помогут не забывать о ежедневной практике.\n"
f"Бот будет присылать сообщение в выбранное время каждый день."
)
# Создаем кнопки
keyboard = []
if user.reminders_enabled:
keyboard.append([
InlineKeyboardButton(text="❌ Выключить", callback_data="reminder_disable")
])
else:
keyboard.append([
InlineKeyboardButton(text="✅ Включить", callback_data="reminder_enable")
])
keyboard.append([
InlineKeyboardButton(text="⏰ Изменить время", callback_data="reminder_set_time")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data == "reminder_enable")
async def enable_reminders(callback: CallbackQuery):
"""Включить напоминания"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
if not user.daily_task_time:
await callback.answer(
"Сначала установи время напоминаний!",
show_alert=True
)
return
user.reminders_enabled = True
await session.commit()
await callback.answer("✅ Напоминания включены!")
await callback.message.edit_text(
f"✅ <b>Напоминания включены!</b>\n\n"
f"Время: {user.daily_task_time} UTC\n\n"
f"Ты будешь получать ежедневные напоминания о практике."
)
@router.callback_query(F.data == "reminder_disable")
async def disable_reminders(callback: CallbackQuery):
"""Выключить напоминания"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
user.reminders_enabled = False
await session.commit()
await callback.answer("❌ Напоминания выключены")
await callback.message.edit_text(
"❌ <b>Напоминания выключены</b>\n\n"
"Используй /reminder чтобы включить их снова."
)
@router.callback_query(F.data == "reminder_set_time")
async def set_reminder_time_prompt(callback: CallbackQuery, state: FSMContext):
"""Запросить время для напоминаний"""
await state.set_state(ReminderStates.waiting_for_time)
await callback.message.edit_text(
"⏰ <b>Установка времени напоминаний</b>\n\n"
"Отправь время в формате <b>HH:MM</b> (UTC)\n\n"
"Примеры:\n"
"• <code>09:00</code> - 9 утра по UTC\n"
"• <code>18:30</code> - 18:30 по UTC\n"
"• <code>20:00</code> - 8 вечера по UTC\n\n"
"💡 UTC = МСК - 3 часа\n"
"(если хочешь 12:00 по МСК, введи 09:00)\n\n"
"Отправь /cancel для отмены"
)
await callback.answer()
@router.message(Command("cancel"), ReminderStates.waiting_for_time)
async def cancel_set_time(message: Message, state: FSMContext):
"""Отменить установку времени"""
await state.clear()
await message.answer("❌ Установка времени отменена")
@router.message(ReminderStates.waiting_for_time)
async def process_reminder_time(message: Message, state: FSMContext):
"""Обработать введённое время"""
time_str = message.text.strip()
# Валидация формата HH:MM
try:
parts = time_str.split(':')
if len(parts) != 2:
raise ValueError()
hour, minute = int(parts[0]), int(parts[1])
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError()
# Формат OK
formatted_time = f"{hour:02d}:{minute:02d}"
except:
await message.answer(
"❌ Неверный формат времени!\n\n"
"Используй формат <b>HH:MM</b> (например, 09:00 или 18:30)\n"
"Или отправь /cancel для отмены"
)
return
# Сохраняем время
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
user.daily_task_time = formatted_time
# Автоматически включаем напоминания
user.reminders_enabled = True
await session.commit()
await state.clear()
await message.answer(
f"✅ <b>Время установлено!</b>\n\n"
f"Напоминания: <b>{formatted_time} UTC</b>\n"
f"Статус: <b>Включены</b>\n\n"
f"Ты будешь получать ежедневные напоминания о практике.\n"
f"Используй /reminder для изменения настроек."
)

View File

@@ -1,6 +1,6 @@
from aiogram import Router, F
from aiogram.filters import CommandStart, Command
from aiogram.types import Message
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from database.db import async_session_maker
@@ -24,16 +24,33 @@ async def cmd_start(message: Message, state: FSMContext):
await message.answer(
f"👋 Привет, {message.from_user.first_name}!\n\n"
f"Я бот для изучения английского языка. Помогу тебе:\n"
f"📚 Пополнять словарный запас\n"
f"✍️ Выполнять ежедневные задания\n"
f"💬 Практиковать язык в диалоге\n\n"
f"📚 Пополнять словарный запас (ручное/тематическое/из текста)\n"
f"✍️ Выполнять интерактивные задания\n"
f"💬 Практиковать язык в диалоге с AI\n"
f"📊 Отслеживать свой прогресс\n\n"
f"<b>Основные команды:</b>\n"
f"/add [слово] - добавить слово в словарь\n"
f"/words [тема] - тематическая подборка\n"
f"/vocabulary - мой словарь\n"
f"/task - получить задание\n"
f"/practice - диалог с AI\n"
f"/stats - статистика\n"
f"/settings - настройки\n"
f"/help - справка\n\n"
f"Давай начнём! Отправь мне слово, которое хочешь выучить, или используй команду /add"
)
# Предлагаем пройти тест уровня
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Пройти тест уровня", callback_data="offer_level_test")],
[InlineKeyboardButton(text="➡️ Пропустить", callback_data="skip_level_test")]
])
await message.answer(
"🎯 <b>Определим твой уровень?</b>\n\n"
"Короткий тест (7 вопросов) поможет подобрать задания под твой уровень.\n"
"Это займёт 2-3 минуты.\n\n"
"Или можешь пропустить и установить уровень вручную позже в /settings",
reply_markup=keyboard
)
else:
# Существующий пользователь
@@ -42,6 +59,7 @@ async def cmd_start(message: Message, state: FSMContext):
f"Готов продолжить обучение?\n"
f"/vocabulary - посмотреть словарь\n"
f"/task - получить задание\n"
f"/practice - практика диалога\n"
f"/stats - статистика"
)
@@ -54,13 +72,39 @@ async def cmd_help(message: Message):
"<b>Управление словарём:</b>\n"
"/add [слово] - добавить слово в словарь\n"
"/vocabulary - просмотр словаря\n"
"/words [тема] - тематическая подборка слов\n"
"/import - импортировать слова из текста\n\n"
"<b>Обучение:</b>\n"
"/task - получить задание\n"
"/practice - практика с ИИ\n\n"
"/task - получить задание (перевод, заполнение пропусков)\n"
"/practice - диалоговая практика с ИИ (6 сценариев)\n\n"
"<b>Статистика:</b>\n"
"/stats - твой прогресс\n\n"
"<b>Настройки:</b>\n"
"/settings - настройки бота\n\n"
"/settings - настройки бота\n"
"/reminder - ежедневные напоминания\n\n"
"Ты также можешь просто отправить мне слово, и я предложу добавить его в словарь!"
)
@router.callback_query(F.data == "offer_level_test")
async def offer_level_test_callback(callback: CallbackQuery, state: FSMContext):
"""Начать тест уровня из приветствия"""
from bot.handlers.level_test import start_level_test
await callback.message.delete()
await start_level_test(callback.message, state)
await callback.answer()
@router.callback_query(F.data == "skip_level_test")
async def skip_level_test_callback(callback: CallbackQuery):
"""Пропустить тест уровня"""
await callback.message.edit_text(
"✅ Хорошо!\n\n"
"Ты можешь пройти тест позже командой /level_test\n"
"или установить уровень вручную в /settings\n\n"
"Давай начнём! Попробуй:\n"
"• /words travel - тематическая подборка\n"
"• /practice - диалог с AI\n"
"• /add hello - добавить слово"
)
await callback.answer()

215
bot/handlers/words.py Normal file
View File

@@ -0,0 +1,215 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from database.db import async_session_maker
from database.models import WordSource
from services.user_service import UserService
from services.vocabulary_service import VocabularyService
from services.ai_service import ai_service
router = Router()
class WordsStates(StatesGroup):
"""Состояния для работы с тематическими подборками"""
viewing_words = State()
@router.message(Command("words"))
async def cmd_words(message: Message, state: FSMContext):
"""Обработчик команды /words [тема]"""
async with async_session_maker() as session:
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
if not user:
await message.answer("Сначала запусти бота командой /start")
return
# Извлекаем тему из команды
command_parts = message.text.split(maxsplit=1)
if len(command_parts) < 2:
await message.answer(
"📚 <b>Тематические подборки слов</b>\n\n"
"Используй: <code>/words [тема]</code>\n\n"
"Примеры:\n"
"• <code>/words travel</code> - путешествия\n"
"• <code>/words food</code> - еда\n"
"• <code>/words work</code> - работа\n"
"• <code>/words nature</code> - природа\n"
"• <code>/words technology</code> - технологии\n\n"
"Я сгенерирую 10 слов по теме, подходящих для твоего уровня!"
)
return
theme = command_parts[1].strip()
# Показываем индикатор генерации
generating_msg = await message.answer(f"🔄 Генерирую подборку слов по теме '{theme}'...")
# Генерируем слова через AI
words = await ai_service.generate_thematic_words(
theme=theme,
level=user.level.value,
count=10
)
await generating_msg.delete()
if not words:
await message.answer(
"Не удалось сгенерировать подборку. Попробуй другую тему или повтори позже."
)
return
# Сохраняем данные в состоянии
await state.update_data(
theme=theme,
words=words,
user_id=user.id
)
await state.set_state(WordsStates.viewing_words)
# Показываем подборку
await show_words_list(message, words, theme)
async def show_words_list(message: Message, words: list, theme: str):
"""Показать список слов с кнопками для добавления"""
text = f"📚 <b>Подборка слов: {theme}</b>\n\n"
for idx, word_data in enumerate(words, 1):
text += (
f"{idx}. <b>{word_data['word']}</b> "
f"[{word_data.get('transcription', '')}]\n"
f" {word_data['translation']}\n"
f" <i>{word_data.get('example', '')}</i>\n\n"
)
text += "Выбери слова, которые хочешь добавить в словарь:"
# Создаем кнопки для каждого слова (по 2 в ряд)
keyboard = []
for idx, word_data in enumerate(words):
button = InlineKeyboardButton(
text=f" {word_data['word']}",
callback_data=f"add_word_{idx}"
)
# Добавляем по 2 кнопки в ряд
if len(keyboard) == 0 or len(keyboard[-1]) == 2:
keyboard.append([button])
else:
keyboard[-1].append(button)
# Кнопка "Добавить все"
keyboard.append([
InlineKeyboardButton(text="✅ Добавить все", callback_data="add_all_words")
])
# Кнопка "Закрыть"
keyboard.append([
InlineKeyboardButton(text="❌ Закрыть", callback_data="close_words")
])
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
await message.answer(text, reply_markup=reply_markup)
@router.callback_query(F.data.startswith("add_word_"), WordsStates.viewing_words)
async def add_single_word(callback: CallbackQuery, state: FSMContext):
"""Добавить одно слово из подборки"""
word_index = int(callback.data.split("_")[2])
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
if word_index >= len(words):
await callback.answer("❌ Ошибка: слово не найдено")
return
word_data = words[word_index]
async with async_session_maker() as session:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
await callback.answer(f"Слово '{word_data['word']}' уже в словаре", show_alert=True)
return
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data['translation'],
transcription=word_data.get('transcription'),
examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [],
source=WordSource.SUGGESTED,
category=data.get('theme', 'general'),
difficulty=None
)
await callback.answer(f"✅ Слово '{word_data['word']}' добавлено в словарь")
@router.callback_query(F.data == "add_all_words", WordsStates.viewing_words)
async def add_all_words(callback: CallbackQuery, state: FSMContext):
"""Добавить все слова из подборки"""
data = await state.get_data()
words = data.get('words', [])
user_id = data.get('user_id')
theme = data.get('theme', 'general')
added_count = 0
skipped_count = 0
async with async_session_maker() as session:
for word_data in words:
# Проверяем, нет ли уже такого слова
existing = await VocabularyService.get_word_by_original(
session, user_id, word_data['word']
)
if existing:
skipped_count += 1
continue
# Добавляем слово
await VocabularyService.add_word(
session=session,
user_id=user_id,
word_original=word_data['word'],
word_translation=word_data['translation'],
transcription=word_data.get('transcription'),
examples=[{"en": word_data.get('example', ''), "ru": ""}] if word_data.get('example') else [],
source=WordSource.SUGGESTED,
category=theme,
difficulty=None
)
added_count += 1
result_text = f"✅ Добавлено слов: <b>{added_count}</b>"
if skipped_count > 0:
result_text += f"\n⚠️ Пропущено (уже в словаре): {skipped_count}"
await callback.message.edit_reply_markup(reply_markup=None)
await callback.message.answer(result_text)
await state.clear()
await callback.answer()
@router.callback_query(F.data == "close_words", WordsStates.viewing_words)
async def close_words(callback: CallbackQuery, state: FSMContext):
"""Закрыть подборку слов"""
await callback.message.delete()
await state.clear()
await callback.answer()