"""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} {story.title}\n" text += f"{t(lang, 'story.level')}: {story.level} • {story.word_count} {t(lang, 'story.words')}\n" text += "─" * 20 + "\n\n" text += story.content if show_translation and story.translation: text += "\n\n" + "─" * 20 text += f"\n\n🌐 {t(lang, 'story.translation')}:\n\n" text += f"{story.translation}" 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"📖 {t(lang, 'story.title')}\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"📖 {t(lang, 'story.title')}\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"📚 {t(lang, 'story.vocabulary')}\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"• {word} [{transcription}] — {translation}\n" else: text += f"• {word} — {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) new_word = 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 ) # Добавляем переводы в word_translations (разбиваем по запятой) await VocabularyService.add_translation_split( session=session, vocabulary_id=new_word.id, translation=translation, context=word_data.get('example') or word_data.get('context'), context_translation=word_data.get('example_translation') or word_data.get('context_translation'), is_primary=True ) 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: new_word = 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 ) # Добавляем переводы в word_translations (разбиваем по запятой) await VocabularyService.add_translation_split( session=session, vocabulary_id=new_word.id, translation=translation, context=word_data.get('example') or word_data.get('context'), context_translation=word_data.get('example_translation') or word_data.get('context_translation'), is_primary=True ) 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"📝 {t(lang, 'story.question')} {q_idx + 1}/{total}\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"📝 {t(lang, 'story.question')} {q_idx + 1}/{total_questions}\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} {t(lang, 'story.results_title')}\n\n" text += f"📖 {story.title}\n\n" text += f"{t(lang, 'story.correct_answers')}: {correct_answers}/{total}\n" text += f"{t(lang, 'story.accuracy')}: {percentage:.0f}%\n\n" text += f"{comment}" 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)