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 from utils.i18n import t, get_user_lang from utils.levels import get_user_level_for_language router = Router() class PracticeStates(StatesGroup): """Состояния для диалоговой практики""" choosing_scenario = State() entering_custom_scenario = State() in_conversation = State() # Доступные сценарии (ключи для i18n) SCENARIO_KEYS = ["restaurant", "shopping", "travel", "work", "doctor", "casual"] def get_scenario_name(lang: str, scenario: str) -> str: """Получить локализованное название сценария""" return t(lang, f'practice.scenario.{scenario}') @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(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) @router.callback_query(F.data == "scenario_custom", PracticeStates.choosing_scenario) async def request_custom_scenario(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) await callback.message.edit_text( t(lang, 'practice.custom_scenario_prompt'), reply_markup=InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=t(lang, 'settings.back'), callback_data="back_to_scenarios")] ]) ) await state.set_state(PracticeStates.entering_custom_scenario) @router.callback_query(F.data == "back_to_scenarios", PracticeStates.entering_custom_scenario) async def back_to_scenarios(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) 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" ) ]) await callback.message.edit_text( t(lang, 'practice.start_text'), reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard) ) await state.set_state(PracticeStates.choosing_scenario) @router.message(PracticeStates.entering_custom_scenario) async def handle_custom_scenario(message: Message, state: FSMContext): """Обработка ввода своего сценария""" custom_scenario = message.text.strip() if not custom_scenario or len(custom_scenario) < 3: 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) await message.answer(t(lang, 'practice.custom_scenario_too_short')) return data = await state.get_data() level = data.get('level', 'B1') async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) ui_lang = get_user_lang(user) learn_lang = (user.learning_language if user else 'en') or 'en' # Показываем индикатор thinking_msg = await message.answer(t(ui_lang, 'practice.thinking_prepare')) # Начинаем диалог с кастомным сценарием conversation_start = await ai_service.start_conversation( custom_scenario, # Передаём описание сценария напрямую level, learning_lang=learn_lang, translation_lang=ui_lang, user_id=user.id if user else None ) await thinking_msg.delete() # Сохраняем данные в состоянии await state.update_data( scenario=custom_scenario, scenario_name=custom_scenario, conversation_history=[], message_count=0 ) await state.set_state(PracticeStates.in_conversation) # Формируем сообщение ai_msg = conversation_start.get('message', '') if learn_lang.lower() == 'ja': annotated = conversation_start.get('message_annotated') if annotated: ai_msg = annotated else: fg = conversation_start.get('furigana') if fg: ai_msg = f"{ai_msg} ({fg})" text = ( f"🎭 {custom_scenario}\n\n" f"📝 {conversation_start.get('context', '')}\n\n" f"AI: {ai_msg}\n\n" f"{t(ui_lang, 'practice.hints')}\n" ) for suggestion in conversation_start.get('suggestions', []): if isinstance(suggestion, dict): if learn_lang.lower() == 'ja': learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or '' else: learn_text = suggestion.get('learn') or '' trans_text = suggestion.get('trans') or '' else: learn_text = str(suggestion) trans_text = '' if trans_text: text += f"• {learn_text} ({trans_text})\n" else: text += f"• {learn_text}\n" text += t(ui_lang, 'practice.write_or_stop') # Сохраняем перевод translations = {0: conversation_start.get('translation', '')} await state.update_data(translations=translations) keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=t(ui_lang, 'practice.show_translation_btn'), callback_data="show_tr_0")], [InlineKeyboardButton(text=t(ui_lang, 'practice.stop_btn'), callback_data="stop_practice")] ]) await message.answer(text, reply_markup=keyboard) @router.callback_query(F.data.startswith("scenario_"), PracticeStates.choosing_scenario) async def start_scenario(callback: CallbackQuery, state: FSMContext): """Начать диалог с выбранным сценарием""" # Отвечаем сразу на callback, дальнейшие операции могут занять время await callback.answer() scenario = callback.data.split("_")[1] data = await state.get_data() level = data.get('level', 'B1') # Определяем языки пользователя async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) ui_lang = get_user_lang(user) learn_lang = (user.learning_language if user else 'en') or 'en' # Удаляем клавиатуру await callback.message.edit_reply_markup(reply_markup=None) # Показываем индикатор thinking_msg = await callback.message.answer(t(ui_lang, 'practice.thinking_prepare')) # Начинаем диалог conversation_start = await ai_service.start_conversation( scenario, level, learning_lang=learn_lang, translation_lang=ui_lang, user_id=user.id if user else None ) await thinking_msg.delete() # Сохраняем данные в состоянии await state.update_data( scenario=scenario, scenario_name=scenario, conversation_history=[], message_count=0 ) await state.set_state(PracticeStates.in_conversation) # Формируем сообщение (перевод скрыт, доступен по кнопке) ai_msg = conversation_start.get('message', '') if learn_lang.lower() == 'ja': annotated = conversation_start.get('message_annotated') if annotated: ai_msg = annotated else: # Фолбэк к старому формату с общим furigana fg = conversation_start.get('furigana') if fg: ai_msg = f"{ai_msg} ({fg})" text = ( f"{get_scenario_name(ui_lang, scenario)}\n\n" f"📝 {conversation_start.get('context', '')}\n\n" f"AI: {ai_msg}\n\n" f"{t(ui_lang, 'practice.hints')}\n" ) for suggestion in conversation_start.get('suggestions', []): if isinstance(suggestion, dict): # Для японского стараемся брать learn_annotated if learn_lang.lower() == 'ja': learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or '' else: learn_text = suggestion.get('learn') or '' trans_text = suggestion.get('trans') or '' else: learn_text = str(suggestion) trans_text = '' # В спойлере — язык изучения, в скобках — перевод на язык интерфейса if trans_text: text += f"• {learn_text} ({trans_text})\n" else: text += f"• {learn_text}\n" text += t(ui_lang, 'practice.write_or_stop') # Сохраняем перевод под индексом 0 translations = {0: conversation_start.get('translation', '')} await state.update_data(translations=translations) # Кнопки управления keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=t(ui_lang, 'practice.show_translation_btn'), callback_data="show_tr_0")], [InlineKeyboardButton(text=t(ui_lang, 'practice.stop_btn'), callback_data="stop_practice")] ]) await callback.message.answer(text, reply_markup=keyboard) def get_end_keyboard(lang: str) -> InlineKeyboardMarkup: """Клавиатура после завершения диалога""" return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=t(lang, 'practice.new_practice_btn'), callback_data="new_practice")], [ InlineKeyboardButton(text=t(lang, 'practice.to_tasks_btn'), callback_data="go_tasks"), InlineKeyboardButton(text=t(lang, 'practice.to_words_btn'), callback_data="go_words") ] ]) @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() async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' end_text = ( t(lang, 'practice.end_title') + "\n\n" + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + t(lang, 'practice.end_keep') ) await message.answer(end_text, reply_markup=get_end_keyboard(lang)) @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() async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' end_text = ( t(lang, 'practice.end_title') + "\n\n" + t(lang, 'practice.end_exchanged', n=message_count) + "\n\n" + t(lang, 'practice.end_keep') ) await callback.message.answer(end_text, reply_markup=get_end_keyboard(lang)) await callback.answer() @router.callback_query(F.data == "new_practice") async def new_practice_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) if not user: await callback.message.edit_text(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 callback.message.edit_text(t(lang, 'practice.start_text'), reply_markup=reply_markup) @router.callback_query(F.data == "go_tasks") async def go_tasks_callback(callback: CallbackQuery): """Перейти к заданиям""" await callback.message.delete() await callback.answer() async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' await callback.message.answer(t(lang, 'practice.go_tasks_hint')) @router.callback_query(F.data == "go_words") async def go_words_callback(callback: CallbackQuery): """Перейти к словам""" await callback.message.delete() await callback.answer() async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' await callback.message.answer(t(lang, 'practice.go_words_hint')) @router.message(PracticeStates.in_conversation) async def handle_conversation(message: Message, state: FSMContext): """Обработка сообщений в диалоге""" user_message = message.text.strip() if not user_message: async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, message.from_user.id) await message.answer(t((user.language_interface if user else 'ru') or 'ru', 'practice.empty_prompt')) 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) # Определяем языки пользователя для ответа (до показа индикатора) async with async_session_maker() as session: user2 = await UserService.get_user_by_telegram_id(session, message.from_user.id) ui_lang2 = (user2.language_interface if user2 else 'ru') or 'ru' learn_lang2 = (user2.learning_language if user2 else 'en') or 'en' # Показываем индикатор thinking_msg = await message.answer(t(ui_lang2, 'practice.thinking')) # Добавляем сообщение пользователя в историю 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, learning_lang=learn_lang2, translation_lang=ui_lang2, user_id=user2.id if user2 else None ) 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"⚠️ {t(ui_lang2, 'practice.corrections')}\n{feedback['corrections']}\n\n" if feedback.get('comment'): text += f"💬 {feedback['comment']}\n\n" # Ответ AI (с фуриганой для японского) resp = ai_response.get('response', '') if learn_lang2.lower() == 'ja': annotated = ai_response.get('response_annotated') if annotated: resp = annotated else: fg = ai_response.get('furigana') if fg: resp = f"{resp} ({fg})" text += f"AI: {resp}\n\n" # Подсказки suggestions = ai_response.get('suggestions', []) if suggestions: text += t(ui_lang2, 'practice.hints') + "\n" for suggestion in suggestions[:3]: if isinstance(suggestion, dict): if learn_lang2.lower() == 'ja': learn_text = suggestion.get('learn_annotated') or suggestion.get('learn') or '' else: learn_text = suggestion.get('learn') or '' trans_text = suggestion.get('trans') or '' else: learn_text = str(suggestion) trans_text = '' if trans_text: text += f"• {learn_text} ({trans_text})\n" else: text += f"• {learn_text}\n" # Сохраняем перевод под новым индексом translations = data.get('translations', {}) or {} this_idx = message_count # после инкремента это текущий номер сообщения AI translations[this_idx] = ai_response.get('translation', '') await state.update_data(translations=translations) # Кнопки keyboard = InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=t(ui_lang2, 'practice.show_translation_btn'), callback_data=f"show_tr_{this_idx}")], [InlineKeyboardButton(text=t(ui_lang2, 'practice.stop_btn'), callback_data="stop_practice")] ]) await message.answer(text, reply_markup=keyboard) @router.callback_query(F.data.startswith("show_tr_"), PracticeStates.in_conversation) async def show_translation(callback: CallbackQuery, state: FSMContext): """Показать перевод для конкретного сообщения AI""" # Определяем язык интерфейса пользователя async with async_session_maker() as session: user = await UserService.get_user_by_telegram_id(session, callback.from_user.id) lang = (user.language_interface if user else 'ru') or 'ru' try: idx = int(callback.data.split("_")[-1]) except Exception: await callback.answer(t(lang, 'practice.translation_unavailable'), show_alert=True) return data = await state.get_data() translations = data.get('translations', {}) or {} tr_text = translations.get(idx) if not tr_text: await callback.answer(t(lang, 'practice.translation_unavailable'), show_alert=True) return # Вставляем перевод в существующее сообщение orig = callback.message.text or "" # Определяем язык (уже вычислен выше) marker = t(lang, 'common.translation') + ":" if marker in orig: await callback.answer(t(lang, 'practice.translation_already')) return new_text = f"{orig}\n{marker} {tr_text}" try: await callback.message.edit_text(new_text, reply_markup=callback.message.reply_markup) except Exception: # Если не удалось отредактировать (например, старое сообщение), отправим отдельным сообщением как запасной вариант await callback.message.answer(f"{marker} {tr_text}") await callback.answer()