feat: мини-истории, слово дня, меню практики
- Добавлены мини-истории для чтения с выбором жанра и вопросами - Кнопка показа/скрытия перевода истории - Количество вопросов берётся из настроек пользователя - Слово дня генерируется глобально в 00:00 UTC - Кнопка "Практика" открывает меню выбора режима - Убран автоматический create_all при запуске (только миграции) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
415
bot/handlers/exercises.py
Normal file
415
bot/handlers/exercises.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""Handler для грамматических упражнений."""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
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
|
||||
from utils.i18n import t, get_user_lang, get_user_translation_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
from data.grammar_rules import get_topics_for_current_level_only, get_grammar_topics_for_level
|
||||
|
||||
router = Router()
|
||||
|
||||
# Количество тем на одной странице
|
||||
TOPICS_PER_PAGE = 6
|
||||
|
||||
|
||||
class ExercisesStates(StatesGroup):
|
||||
"""Состояния для грамматических упражнений."""
|
||||
choosing_topic = State()
|
||||
viewing_rule = State()
|
||||
doing_exercise = State()
|
||||
waiting_answer = State()
|
||||
|
||||
|
||||
@router.message(Command("exercises"))
|
||||
async def cmd_exercises(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /exercises."""
|
||||
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
|
||||
|
||||
|
||||
async def show_exercises_menu(message: Message, state: FSMContext, page: int = 0, telegram_id: int = None):
|
||||
"""Показать меню выбора темы упражнений."""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
|
||||
if not user:
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
learning_lang = user.learning_language or 'en'
|
||||
level = get_user_level_for_language(user)
|
||||
|
||||
# Получаем темы для текущего уровня
|
||||
topics = get_topics_for_current_level_only(learning_lang, level)
|
||||
|
||||
if not topics:
|
||||
await message.answer(t(lang, 'exercises.no_topics'))
|
||||
return
|
||||
|
||||
# Пагинация
|
||||
total_pages = (len(topics) + TOPICS_PER_PAGE - 1) // TOPICS_PER_PAGE
|
||||
start_idx = page * TOPICS_PER_PAGE
|
||||
end_idx = start_idx + TOPICS_PER_PAGE
|
||||
page_topics = topics[start_idx:end_idx]
|
||||
|
||||
# Сохраняем в состоянии
|
||||
await state.update_data(
|
||||
topics=[t for t in topics], # Все темы
|
||||
page=page,
|
||||
level=level,
|
||||
learning_lang=learning_lang,
|
||||
user_id=user.id
|
||||
)
|
||||
await state.set_state(ExercisesStates.choosing_topic)
|
||||
|
||||
# Формируем текст
|
||||
text = (
|
||||
t(lang, 'exercises.title') + "\n\n" +
|
||||
t(lang, 'exercises.your_level', level=level) + "\n\n" +
|
||||
t(lang, 'exercises.choose_topic')
|
||||
)
|
||||
|
||||
# Формируем клавиатуру
|
||||
keyboard = []
|
||||
|
||||
for topic in page_topics:
|
||||
# Используем русское название если интерфейс на русском, иначе английское
|
||||
if lang == 'ru' and topic.get('name_ru'):
|
||||
btn_text = topic['name_ru']
|
||||
else:
|
||||
btn_text = topic['name']
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=btn_text,
|
||||
callback_data=f"exercise_topic_{topic['id']}"
|
||||
)
|
||||
])
|
||||
|
||||
# Навигация по страницам
|
||||
nav_row = []
|
||||
if page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"exercises_page_{page - 1}"))
|
||||
if total_pages > 1:
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="exercises_noop"))
|
||||
if page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"exercises_page_{page + 1}"))
|
||||
|
||||
if nav_row:
|
||||
keyboard.append(nav_row)
|
||||
|
||||
# Кнопка закрыть
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")
|
||||
])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
await message.answer(text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("exercises_page_"))
|
||||
async def exercises_page_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Переключение страницы тем."""
|
||||
page = int(callback.data.split("_")[-1])
|
||||
await callback.answer()
|
||||
|
||||
# Удаляем старое сообщение и показываем новое
|
||||
await callback.message.delete()
|
||||
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_noop")
|
||||
async def exercises_noop_callback(callback: CallbackQuery):
|
||||
"""Пустой callback для кнопки с номером страницы."""
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_close")
|
||||
async def exercises_close_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Закрыть меню упражнений."""
|
||||
await callback.message.delete()
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("exercise_topic_"))
|
||||
async def exercise_topic_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор темы для упражнения - показываем правило."""
|
||||
await callback.answer()
|
||||
topic_id = callback.data.replace("exercise_topic_", "")
|
||||
|
||||
data = await state.get_data()
|
||||
topics = data.get('topics', [])
|
||||
level = data.get('level', 'A1')
|
||||
learning_lang = data.get('learning_lang', 'en')
|
||||
user_id = data.get('user_id')
|
||||
|
||||
# Находим выбранную тему
|
||||
topic = next((tp for tp in topics if tp['id'] == topic_id), None)
|
||||
if not topic:
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
# Показываем индикатор генерации правила
|
||||
await callback.message.edit_text(t(lang, 'exercises.generating_rule'))
|
||||
|
||||
# Генерируем объяснение правила
|
||||
rule_text = await ai_service.generate_grammar_rule(
|
||||
topic_name=topic['name'],
|
||||
topic_description=topic.get('description', ''),
|
||||
level=level,
|
||||
learning_lang=learning_lang,
|
||||
ui_lang=lang,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Сохраняем данные темы в состоянии
|
||||
await state.update_data(
|
||||
current_topic=topic,
|
||||
rule_text=rule_text
|
||||
)
|
||||
await state.set_state(ExercisesStates.viewing_rule)
|
||||
|
||||
# Показываем правило с кнопкой "Начать упражнения"
|
||||
topic_display = topic.get('name_ru', topic['name']) if lang == 'ru' else topic['name']
|
||||
text = f"📖 <b>{topic_display}</b>\n\n{rule_text}"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.start_btn'), callback_data="exercises_start_tasks")],
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_start_tasks", ExercisesStates.viewing_rule)
|
||||
async def start_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать упражнения после просмотра правила."""
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
topic = data.get('current_topic', {})
|
||||
level = data.get('level', 'A1')
|
||||
learning_lang = data.get('learning_lang', 'en')
|
||||
user_id = data.get('user_id')
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
translation_lang = get_user_translation_lang(user) if user else 'ru'
|
||||
tasks_count = getattr(user, 'tasks_count', 5) or 5
|
||||
|
||||
# Показываем индикатор генерации
|
||||
await callback.message.edit_text(t(lang, 'exercises.generating'))
|
||||
|
||||
# Генерируем упражнения
|
||||
exercises = await ai_service.generate_grammar_exercise(
|
||||
topic_id=topic.get('id', ''),
|
||||
topic_name=topic.get('name', ''),
|
||||
topic_description=topic.get('description', ''),
|
||||
level=level,
|
||||
learning_lang=learning_lang,
|
||||
translation_lang=translation_lang,
|
||||
count=tasks_count,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
if not exercises:
|
||||
await callback.message.edit_text(t(lang, 'exercises.generate_failed'))
|
||||
return
|
||||
|
||||
# Сохраняем упражнения в состоянии
|
||||
await state.update_data(
|
||||
exercises=exercises,
|
||||
current_exercise=0,
|
||||
correct_count=0,
|
||||
topic_name=topic.get('name', ''),
|
||||
topic_name_ru=topic.get('name_ru', topic.get('name', ''))
|
||||
)
|
||||
await state.set_state(ExercisesStates.waiting_answer)
|
||||
|
||||
# Показываем первое упражнение
|
||||
await show_exercise(callback.message, state, lang, edit=True)
|
||||
|
||||
|
||||
async def show_exercise(message: Message, state: FSMContext, lang: str, edit: bool = False):
|
||||
"""Показать текущее упражнение."""
|
||||
data = await state.get_data()
|
||||
exercises = data.get('exercises', [])
|
||||
current = data.get('current_exercise', 0)
|
||||
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
|
||||
|
||||
if current >= len(exercises):
|
||||
# Все упражнения завершены
|
||||
await show_results(message, state, lang, edit)
|
||||
return
|
||||
|
||||
exercise = exercises[current]
|
||||
|
||||
text = (
|
||||
t(lang, 'exercises.task_header', topic=topic_name) + "\n\n" +
|
||||
f"<b>{current + 1}/{len(exercises)}</b>\n\n" +
|
||||
t(lang, 'exercises.instruction') + "\n\n" +
|
||||
f"📝 {exercise.get('sentence', '')}\n\n" +
|
||||
f"💬 <i>{exercise.get('translation', '')}</i>\n\n" +
|
||||
f"💡 {exercise.get('hint', '')}\n\n" +
|
||||
t(lang, 'exercises.write_answer')
|
||||
)
|
||||
|
||||
# Кнопки
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_back_to_topics")],
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
|
||||
])
|
||||
|
||||
if edit:
|
||||
await message.edit_text(text, reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_back_to_topics")
|
||||
async def back_to_topics_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Вернуться к выбору темы."""
|
||||
await callback.answer()
|
||||
await callback.message.delete()
|
||||
|
||||
data = await state.get_data()
|
||||
page = data.get('page', 0)
|
||||
|
||||
await show_exercises_menu(callback.message, state, page=page, telegram_id=callback.from_user.id)
|
||||
|
||||
|
||||
@router.message(ExercisesStates.waiting_answer)
|
||||
async def process_answer(message: Message, state: FSMContext):
|
||||
"""Обработка ответа пользователя."""
|
||||
user_answer = message.text.strip().lower()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
data = await state.get_data()
|
||||
exercises = data.get('exercises', [])
|
||||
current = data.get('current_exercise', 0)
|
||||
correct_count = data.get('correct_count', 0)
|
||||
|
||||
if current >= len(exercises):
|
||||
return
|
||||
|
||||
exercise = exercises[current]
|
||||
correct_answer = exercise.get('correct_answer', '').strip().lower()
|
||||
|
||||
# Проверяем ответ
|
||||
is_correct = user_answer == correct_answer
|
||||
|
||||
if is_correct:
|
||||
correct_count += 1
|
||||
result_text = t(lang, 'exercises.correct') + "\n\n"
|
||||
else:
|
||||
result_text = (
|
||||
t(lang, 'exercises.incorrect') + "\n\n" +
|
||||
t(lang, 'exercises.your_answer', answer=message.text) + "\n" +
|
||||
t(lang, 'exercises.right_answer', answer=exercise.get('correct_answer', '')) + "\n\n"
|
||||
)
|
||||
|
||||
# Добавляем объяснение
|
||||
result_text += t(lang, 'exercises.explanation', text=exercise.get('explanation', ''))
|
||||
|
||||
# Обновляем состояние
|
||||
await state.update_data(
|
||||
current_exercise=current + 1,
|
||||
correct_count=correct_count
|
||||
)
|
||||
|
||||
# Кнопка "Следующее" или "Результаты"
|
||||
if current + 1 < len(exercises):
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.next_btn'), callback_data="exercises_next")]
|
||||
])
|
||||
else:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.results_btn'), callback_data="exercises_finish")]
|
||||
])
|
||||
|
||||
await message.answer(result_text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_next")
|
||||
async def next_exercise_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Переход к следующему упражнению."""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
await show_exercise(callback.message, state, lang, edit=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_finish")
|
||||
async def finish_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать результаты."""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
# Убираем кнопку с предыдущего сообщения (оставляем фидбэк видимым)
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
# Показываем результаты новым сообщением
|
||||
await show_results(callback.message, state, lang, edit=False)
|
||||
|
||||
|
||||
async def show_results(message: Message, state: FSMContext, lang: str, edit: bool = False):
|
||||
"""Показать результаты упражнений."""
|
||||
data = await state.get_data()
|
||||
exercises = data.get('exercises', [])
|
||||
correct_count = data.get('correct_count', 0)
|
||||
total = len(exercises)
|
||||
topic_name = data.get('topic_name_ru' if lang == 'ru' else 'topic_name', '')
|
||||
|
||||
text = (
|
||||
f"🎉 <b>{topic_name}</b>\n\n" +
|
||||
t(lang, 'exercises.score', correct=correct_count, total=total)
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.back_btn'), callback_data="exercises_restart")],
|
||||
[InlineKeyboardButton(text=t(lang, 'exercises.close_btn'), callback_data="exercises_close")]
|
||||
])
|
||||
|
||||
if edit:
|
||||
await message.edit_text(text, reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "exercises_restart")
|
||||
async def restart_exercises_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Вернуться к выбору темы после завершения."""
|
||||
await callback.answer()
|
||||
await callback.message.delete()
|
||||
await show_exercises_menu(callback.message, state, page=0, telegram_id=callback.from_user.id)
|
||||
@@ -29,6 +29,40 @@ def get_scenario_name(lang: str, scenario: str) -> str:
|
||||
return t(lang, f'practice.scenario.{scenario}')
|
||||
|
||||
|
||||
async def show_practice_menu(message: Message, telegram_id: int, edit: bool = False):
|
||||
"""Показать меню выбора сценария практики"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, telegram_id)
|
||||
|
||||
if not user:
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
keyboard = []
|
||||
for scenario_id in SCENARIO_KEYS:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_scenario_name(lang, scenario_id),
|
||||
callback_data=f"scenario_{scenario_id}"
|
||||
)
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=t(lang, 'practice.custom_scenario_btn'),
|
||||
callback_data="scenario_custom"
|
||||
)
|
||||
])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
if edit:
|
||||
await message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup)
|
||||
else:
|
||||
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.message(Command("practice"))
|
||||
async def cmd_practice(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /practice"""
|
||||
@@ -39,31 +73,10 @@ async def cmd_practice(message: Message, state: FSMContext):
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Показываем выбор сценария
|
||||
keyboard = []
|
||||
for scenario_id in SCENARIO_KEYS:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_scenario_name(lang, scenario_id),
|
||||
callback_data=f"scenario_{scenario_id}"
|
||||
)
|
||||
])
|
||||
# Кнопка для своего сценария
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=t(lang, 'practice.custom_scenario_btn'),
|
||||
callback_data="scenario_custom"
|
||||
)
|
||||
])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
|
||||
await state.set_state(PracticeStates.choosing_scenario)
|
||||
|
||||
await message.answer(t(lang, 'practice.start_text'), reply_markup=reply_markup)
|
||||
await show_practice_menu(message, message.from_user.id, edit=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario)
|
||||
|
||||
@@ -13,7 +13,7 @@ from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database.db import async_session_maker
|
||||
from services.user_service import UserService
|
||||
from utils.i18n import t, get_user_translation_lang
|
||||
from utils.i18n import t, get_user_translation_lang, get_user_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
@@ -57,16 +57,19 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
resize_keyboard=True,
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.add")),
|
||||
KeyboardButton(text=t(lang, "menu.vocab")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.task")),
|
||||
KeyboardButton(text=t(lang, "menu.practice")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.exercises")),
|
||||
KeyboardButton(text=t(lang, "menu.vocab")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.add")),
|
||||
KeyboardButton(text=t(lang, "menu.stats")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.settings")),
|
||||
],
|
||||
],
|
||||
@@ -326,8 +329,74 @@ async def btn_task_pressed(message: Message, state: FSMContext):
|
||||
|
||||
@router.message(_menu_match('menu.practice'))
|
||||
async def btn_practice_pressed(message: Message, state: FSMContext):
|
||||
from bot.handlers.practice import cmd_practice
|
||||
await cmd_practice(message, state)
|
||||
"""Показать меню практики"""
|
||||
await state.clear()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=f"📖 {t(lang, 'practice_menu.stories')}",
|
||||
callback_data="practice_stories"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text=f"💬 {t(lang, 'practice_menu.ai_chat')}",
|
||||
callback_data="practice_ai"
|
||||
)],
|
||||
])
|
||||
|
||||
await message.answer(
|
||||
f"💬 <b>{t(lang, 'practice_menu.title')}</b>\n\n{t(lang, 'practice_menu.choose')}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "practice_stories")
|
||||
async def practice_stories_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Переход к мини-историям"""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user) if user else 'ru'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=f"🗣 {t(lang, 'story.genre.dialogue')}", callback_data="story_genre_dialogue"),
|
||||
InlineKeyboardButton(text=f"📰 {t(lang, 'story.genre.news')}", callback_data="story_genre_news"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=f"🎭 {t(lang, 'story.genre.story')}", callback_data="story_genre_story"),
|
||||
InlineKeyboardButton(text=f"📧 {t(lang, 'story.genre.letter')}", callback_data="story_genre_letter"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=f"🍳 {t(lang, 'story.genre.recipe')}", callback_data="story_genre_recipe"),
|
||||
],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"📖 <b>{t(lang, 'story.title')}</b>\n\n{t(lang, 'story.choose_genre')}",
|
||||
reply_markup=keyboard
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "practice_ai")
|
||||
async def practice_ai_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Переход к AI практике"""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
if user:
|
||||
from bot.handlers.practice import PracticeStates
|
||||
from utils.levels import get_user_level_for_language
|
||||
await state.update_data(user_id=user.id, level=get_user_level_for_language(user))
|
||||
await state.set_state(PracticeStates.choosing_scenario)
|
||||
|
||||
from bot.handlers.practice import show_practice_menu
|
||||
await show_practice_menu(callback.message, callback.from_user.id, edit=True)
|
||||
|
||||
|
||||
@router.message(_menu_match('menu.import'))
|
||||
@@ -391,6 +460,14 @@ async def btn_settings_pressed(message: Message):
|
||||
await cmd_settings(message)
|
||||
|
||||
|
||||
|
||||
@router.message(_menu_match('menu.exercises'))
|
||||
async def btn_exercises_pressed(message: Message, state: FSMContext):
|
||||
"""Показать меню грамматических упражнений."""
|
||||
from bot.handlers.exercises import show_exercises_menu
|
||||
await show_exercises_menu(message, state, telegram_id=message.from_user.id)
|
||||
|
||||
|
||||
@router.message(_menu_match('menu.words'))
|
||||
async def btn_words_pressed(message: Message, state: FSMContext):
|
||||
"""Подсказать про тематические слова и показать быстрые темы."""
|
||||
|
||||
688
bot/handlers/stories.py
Normal file
688
bot/handlers/stories.py
Normal file
@@ -0,0 +1,688 @@
|
||||
"""Handler для мини-историй (Reading Practice)."""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database.db import async_session_maker
|
||||
from database.models import MiniStory, StoryGenre, WordSource
|
||||
from services.user_service import UserService
|
||||
from services.vocabulary_service import VocabularyService
|
||||
from services.ai_service import ai_service
|
||||
from utils.i18n import t, get_user_lang, get_user_translation_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class StoryStates(StatesGroup):
|
||||
"""Состояния для чтения истории"""
|
||||
reading = State() # Чтение истории
|
||||
questions = State() # Ответы на вопросы
|
||||
|
||||
|
||||
GENRE_EMOJI = {
|
||||
"dialogue": "🗣",
|
||||
"news": "📰",
|
||||
"story": "🎭",
|
||||
"letter": "📧",
|
||||
"recipe": "🍳"
|
||||
}
|
||||
|
||||
|
||||
def get_genre_keyboard(lang: str) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура выбора жанра"""
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🗣 {t(lang, 'story.genre.dialogue')}",
|
||||
callback_data="story_genre_dialogue"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"📰 {t(lang, 'story.genre.news')}",
|
||||
callback_data="story_genre_news"
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🎭 {t(lang, 'story.genre.story')}",
|
||||
callback_data="story_genre_story"
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=f"📧 {t(lang, 'story.genre.letter')}",
|
||||
callback_data="story_genre_letter"
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🍳 {t(lang, 'story.genre.recipe')}",
|
||||
callback_data="story_genre_recipe"
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
|
||||
def format_story_text(story: MiniStory, lang: str, show_translation: bool = False) -> str:
|
||||
"""Форматировать текст истории"""
|
||||
emoji = GENRE_EMOJI.get(story.genre.value, "📖")
|
||||
|
||||
text = f"{emoji} <b>{story.title}</b>\n"
|
||||
text += f"<i>{t(lang, 'story.level')}: {story.level} • {story.word_count} {t(lang, 'story.words')}</i>\n"
|
||||
text += "─" * 20 + "\n\n"
|
||||
text += story.content
|
||||
|
||||
if show_translation and story.translation:
|
||||
text += "\n\n" + "─" * 20
|
||||
text += f"\n\n🌐 <b>{t(lang, 'story.translation')}:</b>\n\n"
|
||||
text += f"<i>{story.translation}</i>"
|
||||
|
||||
text += "\n\n" + "─" * 20
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def get_story_keyboard(story_id: int, lang: str, show_translation: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Клавиатура под историей"""
|
||||
# Кнопка перевода - показать или скрыть
|
||||
if show_translation:
|
||||
translation_btn = InlineKeyboardButton(
|
||||
text=f"🌐 {t(lang, 'story.hide_translation')}",
|
||||
callback_data=f"story_hide_translation_{story_id}"
|
||||
)
|
||||
else:
|
||||
translation_btn = InlineKeyboardButton(
|
||||
text=f"🌐 {t(lang, 'story.show_translation')}",
|
||||
callback_data=f"story_show_translation_{story_id}"
|
||||
)
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[translation_btn],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📝 {t(lang, 'story.questions_btn')}",
|
||||
callback_data=f"story_questions_{story_id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📚 {t(lang, 'story.vocab_btn')}",
|
||||
callback_data=f"story_vocab_{story_id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 {t(lang, 'story.new_btn')}",
|
||||
callback_data="story_new"
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
|
||||
@router.message(Command("story"))
|
||||
async def cmd_story(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /story"""
|
||||
await state.clear()
|
||||
|
||||
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(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
|
||||
text += t(lang, 'story.choose_genre')
|
||||
|
||||
await message.answer(text, reply_markup=get_genre_keyboard(lang))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "story_new")
|
||||
async def story_new_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать выбор жанра для новой истории"""
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
|
||||
text += t(lang, 'story.choose_genre')
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=get_genre_keyboard(lang))
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_genre_"))
|
||||
async def story_genre_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Генерация истории выбранного жанра"""
|
||||
await callback.answer()
|
||||
|
||||
genre = callback.data.replace("story_genre_", "")
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
learning_lang = user.learning_language or 'en'
|
||||
level = get_user_level_for_language(user)
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
|
||||
# Показываем индикатор генерации
|
||||
await callback.message.edit_text(t(lang, 'story.generating'))
|
||||
|
||||
# Получаем количество вопросов из настроек
|
||||
tasks_count = getattr(user, 'tasks_count', 5) or 5
|
||||
|
||||
# Генерируем историю
|
||||
story_data = await ai_service.generate_mini_story(
|
||||
genre=genre,
|
||||
level=level,
|
||||
learning_lang=learning_lang,
|
||||
translation_lang=translation_lang,
|
||||
user_id=user.id,
|
||||
num_questions=tasks_count
|
||||
)
|
||||
|
||||
if not story_data:
|
||||
await callback.message.edit_text(
|
||||
t(lang, 'story.failed'),
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=f"🔄 {t(lang, 'story.try_again')}",
|
||||
callback_data="story_new"
|
||||
)]
|
||||
])
|
||||
)
|
||||
return
|
||||
|
||||
# Сохраняем историю в БД
|
||||
story = MiniStory(
|
||||
user_id=user.id,
|
||||
title=story_data.get('title', 'Story'),
|
||||
content=story_data.get('content', ''),
|
||||
translation=story_data.get('translation', ''),
|
||||
genre=StoryGenre(genre),
|
||||
learning_lang=learning_lang,
|
||||
level=level,
|
||||
word_count=story_data.get('word_count', 0),
|
||||
vocabulary=story_data.get('vocabulary', []),
|
||||
questions=story_data.get('questions', [])
|
||||
)
|
||||
session.add(story)
|
||||
await session.commit()
|
||||
await session.refresh(story)
|
||||
|
||||
# Сохраняем ID истории в состоянии
|
||||
await state.update_data(story_id=story.id)
|
||||
await state.set_state(StoryStates.reading)
|
||||
|
||||
# Показываем историю
|
||||
text = format_story_text(story, lang)
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_story_keyboard(story.id, lang)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_show_translation_"))
|
||||
async def story_show_translation_callback(callback: CallbackQuery):
|
||||
"""Показать перевод истории"""
|
||||
await callback.answer()
|
||||
|
||||
story_id = int(callback.data.replace("story_show_translation_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
text = format_story_text(story, lang, show_translation=True)
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_story_keyboard(story.id, lang, show_translation=True)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_hide_translation_"))
|
||||
async def story_hide_translation_callback(callback: CallbackQuery):
|
||||
"""Скрыть перевод истории"""
|
||||
await callback.answer()
|
||||
|
||||
story_id = int(callback.data.replace("story_hide_translation_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
text = format_story_text(story, lang, show_translation=False)
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_story_keyboard(story.id, lang, show_translation=False)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_vocab_"))
|
||||
async def story_vocab_callback(callback: CallbackQuery):
|
||||
"""Показать словарь истории"""
|
||||
await callback.answer()
|
||||
|
||||
story_id = int(callback.data.replace("story_vocab_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
vocabulary = story.vocabulary or []
|
||||
|
||||
if not vocabulary:
|
||||
await callback.answer(t(lang, 'story.no_vocab'), show_alert=True)
|
||||
return
|
||||
|
||||
# Формируем текст со словами
|
||||
text = f"📚 <b>{t(lang, 'story.vocabulary')}</b>\n\n"
|
||||
|
||||
keyboard_buttons = []
|
||||
for i, word_data in enumerate(vocabulary[:10]):
|
||||
word = word_data.get('word', '')
|
||||
translation = word_data.get('translation', '')
|
||||
transcription = word_data.get('transcription', '')
|
||||
|
||||
if transcription:
|
||||
text += f"• <b>{word}</b> [{transcription}] — {translation}\n"
|
||||
else:
|
||||
text += f"• <b>{word}</b> — {translation}\n"
|
||||
|
||||
# Кнопка добавления слова
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"➕ {word}",
|
||||
callback_data=f"story_addword_{story_id}_{i}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка "Добавить все"
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"➕ {t(lang, 'story.add_all')}",
|
||||
callback_data=f"story_addall_{story_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Кнопка назад
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=f"⬅️ {t(lang, 'story.back')}",
|
||||
callback_data=f"story_back_{story_id}"
|
||||
)
|
||||
])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_addword_"))
|
||||
async def story_addword_callback(callback: CallbackQuery):
|
||||
"""Добавить одно слово из истории"""
|
||||
parts = callback.data.split("_")
|
||||
story_id = int(parts[2])
|
||||
word_idx = int(parts[3])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story or not story.vocabulary:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
if word_idx >= len(story.vocabulary):
|
||||
await callback.answer(t(lang, 'story.word_not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
word_data = story.vocabulary[word_idx]
|
||||
word = word_data.get('word', '')
|
||||
translation = word_data.get('translation', '')
|
||||
transcription = word_data.get('transcription')
|
||||
|
||||
# Проверяем, нет ли уже
|
||||
existing = await VocabularyService.get_word_by_original(
|
||||
session, user.id, word, source_lang=story.learning_lang
|
||||
)
|
||||
|
||||
if existing:
|
||||
await callback.answer(t(lang, 'words.already_exists', word=word), show_alert=True)
|
||||
return
|
||||
|
||||
# Добавляем слово
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
word_original=word,
|
||||
word_translation=translation,
|
||||
source_lang=story.learning_lang,
|
||||
translation_lang=translation_lang,
|
||||
transcription=transcription,
|
||||
difficulty_level=story.level,
|
||||
source=WordSource.IMPORT
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(t(lang, 'story.word_added', word=word), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_addall_"))
|
||||
async def story_addall_callback(callback: CallbackQuery):
|
||||
"""Добавить все слова из истории"""
|
||||
story_id = int(callback.data.replace("story_addall_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story or not story.vocabulary:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
added = 0
|
||||
|
||||
for word_data in story.vocabulary:
|
||||
word = word_data.get('word', '')
|
||||
translation = word_data.get('translation', '')
|
||||
transcription = word_data.get('transcription')
|
||||
|
||||
# Проверяем дубликаты
|
||||
existing = await VocabularyService.get_word_by_original(
|
||||
session, user.id, word, source_lang=story.learning_lang
|
||||
)
|
||||
|
||||
if not existing:
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
word_original=word,
|
||||
word_translation=translation,
|
||||
source_lang=story.learning_lang,
|
||||
translation_lang=translation_lang,
|
||||
transcription=transcription,
|
||||
difficulty_level=story.level,
|
||||
source=WordSource.IMPORT
|
||||
)
|
||||
added += 1
|
||||
|
||||
await session.commit()
|
||||
await callback.answer(t(lang, 'story.words_added', n=added), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_back_"))
|
||||
async def story_back_callback(callback: CallbackQuery):
|
||||
"""Вернуться к истории"""
|
||||
await callback.answer()
|
||||
|
||||
story_id = int(callback.data.replace("story_back_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.message.edit_text(t(lang, 'story.not_found'))
|
||||
return
|
||||
|
||||
text = format_story_text(story, lang)
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=get_story_keyboard(story.id, lang)
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_questions_"))
|
||||
async def story_questions_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать вопросы по истории"""
|
||||
await callback.answer()
|
||||
|
||||
story_id = int(callback.data.replace("story_questions_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.message.edit_text(t(lang, 'story.not_found'))
|
||||
return
|
||||
|
||||
questions = story.questions or []
|
||||
if not questions:
|
||||
await callback.answer(t(lang, 'story.no_questions'), show_alert=True)
|
||||
return
|
||||
|
||||
# Сохраняем состояние
|
||||
await state.update_data(
|
||||
story_id=story_id,
|
||||
current_question=0,
|
||||
correct_answers=0,
|
||||
total_questions=len(questions)
|
||||
)
|
||||
await state.set_state(StoryStates.questions)
|
||||
|
||||
# Показываем первый вопрос
|
||||
await show_question(callback.message, story, 0, lang, edit=True)
|
||||
|
||||
|
||||
async def show_question(message: Message, story: MiniStory, q_idx: int, lang: str, edit: bool = False):
|
||||
"""Показать вопрос"""
|
||||
questions = story.questions or []
|
||||
|
||||
if q_idx >= len(questions):
|
||||
return
|
||||
|
||||
q = questions[q_idx]
|
||||
total = len(questions)
|
||||
|
||||
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total}</b>\n\n"
|
||||
text += f"{q.get('question', '')}\n"
|
||||
|
||||
# Кнопки с вариантами ответов
|
||||
options = q.get('options', [])
|
||||
keyboard_buttons = []
|
||||
|
||||
for i, option in enumerate(options):
|
||||
keyboard_buttons.append([
|
||||
InlineKeyboardButton(
|
||||
text=option,
|
||||
callback_data=f"story_answer_{story.id}_{q_idx}_{i}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||
|
||||
if edit:
|
||||
await message.edit_text(text, reply_markup=keyboard)
|
||||
else:
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_answer_"))
|
||||
async def story_answer_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Обработка ответа на вопрос"""
|
||||
parts = callback.data.split("_")
|
||||
story_id = int(parts[2])
|
||||
q_idx = int(parts[3])
|
||||
answer_idx = int(parts[4])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
questions = story.questions or []
|
||||
if q_idx >= len(questions):
|
||||
await callback.answer(t(lang, 'story.question_not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
q = questions[q_idx]
|
||||
correct = q.get('correct', 0)
|
||||
options = q.get('options', [])
|
||||
|
||||
# Получаем данные состояния
|
||||
data = await state.get_data()
|
||||
correct_answers = data.get('correct_answers', 0)
|
||||
total_questions = data.get('total_questions', len(questions))
|
||||
|
||||
# Проверяем ответ
|
||||
is_correct = (answer_idx == correct)
|
||||
if is_correct:
|
||||
correct_answers += 1
|
||||
|
||||
# Показываем результат ответа
|
||||
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total_questions}</b>\n\n"
|
||||
text += f"{q.get('question', '')}\n\n"
|
||||
|
||||
for i, option in enumerate(options):
|
||||
if i == correct:
|
||||
text += f"✅ {option}\n"
|
||||
elif i == answer_idx and not is_correct:
|
||||
text += f"❌ {option}\n"
|
||||
else:
|
||||
text += f"○ {option}\n"
|
||||
|
||||
if is_correct:
|
||||
text += f"\n{t(lang, 'story.correct')}"
|
||||
await callback.answer("✅", show_alert=False)
|
||||
else:
|
||||
text += f"\n{t(lang, 'story.incorrect')}"
|
||||
await callback.answer("❌", show_alert=False)
|
||||
|
||||
# Обновляем состояние
|
||||
await state.update_data(correct_answers=correct_answers)
|
||||
|
||||
# Следующий вопрос или результаты
|
||||
next_q_idx = q_idx + 1
|
||||
|
||||
if next_q_idx < total_questions:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=f"➡️ {t(lang, 'story.next_question')}",
|
||||
callback_data=f"story_nextq_{story_id}_{next_q_idx}"
|
||||
)]
|
||||
])
|
||||
else:
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=f"📊 {t(lang, 'story.show_results')}",
|
||||
callback_data=f"story_results_{story_id}_{correct_answers}"
|
||||
)]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_nextq_"))
|
||||
async def story_nextq_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Следующий вопрос"""
|
||||
await callback.answer()
|
||||
|
||||
parts = callback.data.split("_")
|
||||
story_id = int(parts[2])
|
||||
q_idx = int(parts[3])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.message.edit_text(t(lang, 'story.not_found'))
|
||||
return
|
||||
|
||||
await show_question(callback.message, story, q_idx, lang, edit=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("story_results_"))
|
||||
async def story_results_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Показать результаты"""
|
||||
await callback.answer()
|
||||
await state.clear()
|
||||
|
||||
parts = callback.data.split("_")
|
||||
story_id = int(parts[2])
|
||||
correct_answers = int(parts[3])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
story = await session.get(MiniStory, story_id)
|
||||
if not story:
|
||||
await callback.message.edit_text(t(lang, 'story.not_found'))
|
||||
return
|
||||
|
||||
total = len(story.questions or [])
|
||||
|
||||
# Обновляем статус истории
|
||||
story.is_completed = True
|
||||
story.correct_answers = correct_answers
|
||||
await session.commit()
|
||||
|
||||
# Определяем эмодзи по результату
|
||||
percentage = (correct_answers / total * 100) if total > 0 else 0
|
||||
if percentage >= 80:
|
||||
emoji = "🎉"
|
||||
comment = t(lang, 'story.result_excellent')
|
||||
elif percentage >= 50:
|
||||
emoji = "👍"
|
||||
comment = t(lang, 'story.result_good')
|
||||
else:
|
||||
emoji = "📚"
|
||||
comment = t(lang, 'story.result_practice')
|
||||
|
||||
text = f"{emoji} <b>{t(lang, 'story.results_title')}</b>\n\n"
|
||||
text += f"📖 {story.title}\n\n"
|
||||
text += f"{t(lang, 'story.correct_answers')}: <b>{correct_answers}/{total}</b>\n"
|
||||
text += f"{t(lang, 'story.accuracy')}: <b>{percentage:.0f}%</b>\n\n"
|
||||
text += f"<i>{comment}</i>"
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"📚 {t(lang, 'story.vocab_btn')}",
|
||||
callback_data=f"story_vocab_{story_id}"
|
||||
),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f"🔄 {t(lang, 'story.new_btn')}",
|
||||
callback_data="story_new"
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
@@ -18,12 +18,13 @@ class AddWordStates(StatesGroup):
|
||||
"""Состояния для добавления слова"""
|
||||
waiting_for_confirmation = State()
|
||||
waiting_for_word = State()
|
||||
viewing_batch = State() # Просмотр списка слов для batch-добавления
|
||||
|
||||
|
||||
@router.message(Command("add"))
|
||||
async def cmd_add(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /add [слово]"""
|
||||
# Получаем слово из команды
|
||||
"""Обработчик команды /add [слово] или /add [слово1, слово2, ...]"""
|
||||
# Получаем слово(а) из команды
|
||||
parts = message.text.split(maxsplit=1)
|
||||
|
||||
if len(parts) < 2:
|
||||
@@ -34,15 +35,32 @@ async def cmd_add(message: Message, state: FSMContext):
|
||||
await state.set_state(AddWordStates.waiting_for_word)
|
||||
return
|
||||
|
||||
word = parts[1].strip()
|
||||
await process_word_addition(message, state, word)
|
||||
text = parts[1].strip()
|
||||
|
||||
# Проверяем, есть ли несколько слов (через запятую)
|
||||
if ',' in text:
|
||||
words = [w.strip() for w in text.split(',') if w.strip()]
|
||||
if len(words) > 1:
|
||||
await process_batch_addition(message, state, words)
|
||||
return
|
||||
|
||||
# Одно слово - стандартная обработка
|
||||
await process_word_addition(message, state, text)
|
||||
|
||||
|
||||
@router.message(AddWordStates.waiting_for_word)
|
||||
async def process_word_input(message: Message, state: FSMContext):
|
||||
"""Обработка ввода слова"""
|
||||
word = message.text.strip()
|
||||
await process_word_addition(message, state, word)
|
||||
"""Обработка ввода слова или нескольких слов"""
|
||||
text = message.text.strip()
|
||||
|
||||
# Проверяем, есть ли несколько слов (через запятую)
|
||||
if ',' in text:
|
||||
words = [w.strip() for w in text.split(',') if w.strip()]
|
||||
if len(words) > 1:
|
||||
await process_batch_addition(message, state, words)
|
||||
return
|
||||
|
||||
await process_word_addition(message, state, text)
|
||||
|
||||
|
||||
async def process_word_addition(message: Message, state: FSMContext, word: str):
|
||||
@@ -188,6 +206,201 @@ async def cancel_add_word(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
# === Batch добавление нескольких слов ===
|
||||
|
||||
async def process_batch_addition(message: Message, state: FSMContext, words: list[str]):
|
||||
"""Обработка добавления нескольких слов"""
|
||||
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(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Ограничиваем количество слов
|
||||
if len(words) > 20:
|
||||
words = words[:20]
|
||||
await message.answer(t(lang, 'add_batch.truncated', n=20))
|
||||
|
||||
# Показываем индикатор загрузки
|
||||
processing_msg = await message.answer(t(lang, 'add_batch.translating', n=len(words)))
|
||||
|
||||
# Получаем переводы через AI batch-методом
|
||||
source_lang = user.learning_language or 'en'
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
|
||||
translated_words = await ai_service.translate_words_batch(
|
||||
words=words,
|
||||
source_lang=source_lang,
|
||||
translation_lang=translation_lang,
|
||||
user_id=user.id
|
||||
)
|
||||
|
||||
await processing_msg.delete()
|
||||
|
||||
if not translated_words:
|
||||
await message.answer(t(lang, 'add_batch.failed'))
|
||||
return
|
||||
|
||||
# Сохраняем данные в состоянии
|
||||
await state.update_data(
|
||||
batch_words=translated_words,
|
||||
user_id=user.id
|
||||
)
|
||||
await state.set_state(AddWordStates.viewing_batch)
|
||||
|
||||
# Показываем список слов
|
||||
await show_batch_words(message, translated_words, lang)
|
||||
|
||||
|
||||
async def show_batch_words(message: Message, words: list, lang: str):
|
||||
"""Показать список слов для batch-добавления"""
|
||||
text = t(lang, 'add_batch.header', n=len(words)) + "\n\n"
|
||||
|
||||
for idx, word_data in enumerate(words, 1):
|
||||
word = word_data.get('word', '')
|
||||
translation = word_data.get('translation', '')
|
||||
transcription = word_data.get('transcription', '')
|
||||
|
||||
line = f"{idx}. <b>{word}</b>"
|
||||
if transcription:
|
||||
line += f" [{transcription}]"
|
||||
line += f"\n {translation}\n"
|
||||
text += line
|
||||
|
||||
text += "\n" + t(lang, 'add_batch.choose')
|
||||
|
||||
# Создаем кнопки для каждого слова (по 2 в ряд)
|
||||
keyboard = []
|
||||
for idx, word_data in enumerate(words):
|
||||
button = InlineKeyboardButton(
|
||||
text=f"➕ {word_data.get('word', '')[:15]}",
|
||||
callback_data=f"batch_word_{idx}"
|
||||
)
|
||||
if len(keyboard) == 0 or len(keyboard[-1]) == 2:
|
||||
keyboard.append([button])
|
||||
else:
|
||||
keyboard[-1].append(button)
|
||||
|
||||
# Кнопка "Добавить все"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=t(lang, 'words.add_all_btn'), callback_data="batch_add_all")
|
||||
])
|
||||
|
||||
# Кнопка "Закрыть"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=t(lang, 'words.close_btn'), callback_data="batch_close")
|
||||
])
|
||||
|
||||
reply_markup = InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
await message.answer(text, reply_markup=reply_markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("batch_word_"), AddWordStates.viewing_batch)
|
||||
async def batch_add_single(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить одно слово из batch"""
|
||||
await callback.answer()
|
||||
word_index = int(callback.data.split("_")[2])
|
||||
|
||||
data = await state.get_data()
|
||||
words = data.get('batch_words', [])
|
||||
user_id = data.get('user_id')
|
||||
|
||||
if word_index >= len(words):
|
||||
return
|
||||
|
||||
word_data = words[word_index]
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Проверяем, нет ли уже такого слова
|
||||
existing = await VocabularyService.get_word_by_original(
|
||||
session, user_id, word_data.get('word', ''), source_lang=user.learning_language
|
||||
)
|
||||
|
||||
if existing:
|
||||
await callback.answer(t(lang, 'words.already_exists', word=word_data.get('word', '')), show_alert=True)
|
||||
return
|
||||
|
||||
# Добавляем слово
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
word_original=word_data.get('word', ''),
|
||||
word_translation=word_data.get('translation', ''),
|
||||
source_lang=user.learning_language,
|
||||
translation_lang=translation_lang,
|
||||
transcription=word_data.get('transcription'),
|
||||
source=WordSource.MANUAL
|
||||
)
|
||||
|
||||
await callback.message.answer(t(lang, 'words.added_single', word=word_data.get('word', '')))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "batch_add_all", AddWordStates.viewing_batch)
|
||||
async def batch_add_all(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить все слова из batch"""
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
words = data.get('batch_words', [])
|
||||
user_id = data.get('user_id')
|
||||
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
for word_data in words:
|
||||
# Проверяем, нет ли уже такого слова
|
||||
existing = await VocabularyService.get_word_by_original(
|
||||
session, user_id, word_data.get('word', ''), source_lang=user.learning_language
|
||||
)
|
||||
|
||||
if existing:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Добавляем слово
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
word_original=word_data.get('word', ''),
|
||||
word_translation=word_data.get('translation', ''),
|
||||
source_lang=user.learning_language,
|
||||
translation_lang=translation_lang,
|
||||
transcription=word_data.get('transcription'),
|
||||
source=WordSource.MANUAL
|
||||
)
|
||||
added_count += 1
|
||||
|
||||
lang = get_user_lang(user)
|
||||
result_text = t(lang, 'import.added_count', n=added_count)
|
||||
if skipped_count > 0:
|
||||
result_text += "\n" + t(lang, 'import.skipped_count', n=skipped_count)
|
||||
|
||||
await callback.message.edit_reply_markup(reply_markup=None)
|
||||
await callback.message.answer(result_text)
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "batch_close", AddWordStates.viewing_batch)
|
||||
async def batch_close(callback: CallbackQuery, state: FSMContext):
|
||||
"""Закрыть batch добавление"""
|
||||
await callback.message.delete()
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
|
||||
WORDS_PER_PAGE = 10
|
||||
|
||||
|
||||
|
||||
139
bot/handlers/wordofday.py
Normal file
139
bot/handlers/wordofday.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Handler для функции 'Слово дня'."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
from database.db import async_session_maker
|
||||
from database.models import WordOfDay, WordSource
|
||||
from services.user_service import UserService
|
||||
from services.vocabulary_service import VocabularyService
|
||||
from services.wordofday_service import wordofday_service
|
||||
from utils.i18n import t, get_user_lang, get_user_translation_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def format_word_of_day(wod: WordOfDay, lang: str) -> str:
|
||||
"""Форматировать слово дня для отображения."""
|
||||
date_str = wod.date.strftime("%d.%m.%Y")
|
||||
|
||||
text = f"🌅 <b>{t(lang, 'wod.title')}</b> — {date_str}\n\n"
|
||||
text += f"📝 <b>{wod.word}</b>\n"
|
||||
|
||||
if wod.transcription:
|
||||
text += f"🔊 [{wod.transcription}]\n"
|
||||
|
||||
text += f"\n💬 {wod.translation}\n"
|
||||
|
||||
# Примеры
|
||||
if wod.examples:
|
||||
text += f"\n📖 <b>{t(lang, 'wod.examples')}:</b>\n"
|
||||
for ex in wod.examples[:2]:
|
||||
sentence = ex.get('sentence', '')
|
||||
translation = ex.get('translation', '')
|
||||
text += f"• <i>{sentence}</i>\n"
|
||||
if translation:
|
||||
text += f" ({translation})\n"
|
||||
|
||||
# Синонимы
|
||||
if wod.synonyms:
|
||||
text += f"\n🔗 <b>{t(lang, 'wod.synonyms')}:</b> {wod.synonyms}\n"
|
||||
|
||||
# Этимология/интересный факт
|
||||
if wod.etymology:
|
||||
text += f"\n💡 {wod.etymology}\n"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
@router.message(Command("wordofday"))
|
||||
async def cmd_wordofday(message: Message):
|
||||
"""Обработчик команды /wordofday."""
|
||||
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(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
learning_lang = user.learning_language or 'en'
|
||||
level = get_user_level_for_language(user)
|
||||
|
||||
# Получаем слово дня из глобальной таблицы
|
||||
wod = await wordofday_service.get_word_of_day(
|
||||
learning_lang=learning_lang,
|
||||
level=level
|
||||
)
|
||||
|
||||
if not wod:
|
||||
# Слово ещё не сгенерировано - показываем сообщение
|
||||
await message.answer(t(lang, 'wod.not_available'))
|
||||
return
|
||||
|
||||
# Форматируем и отправляем
|
||||
text = format_word_of_day(wod, lang)
|
||||
|
||||
# Кнопка добавления в словарь
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=t(lang, 'wod.add_btn'),
|
||||
callback_data=f"wod_add_{wod.id}"
|
||||
)]
|
||||
])
|
||||
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("wod_add_"))
|
||||
async def wod_add_callback(callback: CallbackQuery):
|
||||
"""Добавить слово дня в словарь."""
|
||||
await callback.answer()
|
||||
|
||||
wod_id = int(callback.data.replace("wod_add_", ""))
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Получаем слово дня
|
||||
wod = await session.get(WordOfDay, wod_id)
|
||||
if not wod:
|
||||
await callback.answer(t(lang, 'wod.not_found'), show_alert=True)
|
||||
return
|
||||
|
||||
# Проверяем, нет ли уже в словаре
|
||||
existing = await VocabularyService.get_word_by_original(
|
||||
session, user.id, wod.word, source_lang=wod.learning_lang
|
||||
)
|
||||
|
||||
if existing:
|
||||
await callback.answer(t(lang, 'words.already_exists', word=wod.word), show_alert=True)
|
||||
return
|
||||
|
||||
# Добавляем в словарь
|
||||
translation_lang = get_user_translation_lang(user)
|
||||
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
word_original=wod.word,
|
||||
word_translation=wod.translation,
|
||||
source_lang=wod.learning_lang,
|
||||
translation_lang=translation_lang,
|
||||
transcription=wod.transcription,
|
||||
difficulty_level=wod.level,
|
||||
source=WordSource.SUGGESTED
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Обновляем сообщение
|
||||
text = format_word_of_day(wod, lang)
|
||||
text += f"\n✅ <i>{t(lang, 'wod.added')}</i>"
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=None)
|
||||
Reference in New Issue
Block a user