feat(i18n): localize start/help/menu, practice, words, import, reminder, vocabulary, tasks/stats for RU/EN/JA; add JSON-based i18n helper\n\nfeat(lang): support learning/translation languages across AI flows; hide translations with buttons; store examples per lang\n\nfeat(vocab): add source_lang and translation_lang to Vocabulary, unique constraint (user_id, source_lang, word_original); filter /vocabulary by user.learning_language\n\nchore(migrations): add Alembic setup + migration to add vocab lang columns; env.py reads app settings and supports asyncpg URLs\n\nfix(words/import): pass learning_lang + translation_lang everywhere; fix menu themes generation\n\nfeat(settings): add learning language selector; update main menu on language change

This commit is contained in:
2025-12-04 19:40:01 +03:00
parent 6223351ccf
commit 472771229f
22 changed files with 1587 additions and 471 deletions

View File

@@ -12,40 +12,30 @@ from aiogram.fsm.context import FSMContext
from database.db import async_session_maker
from services.user_service import UserService
from utils.i18n import t
router = Router()
# Тексты кнопок главного меню
BTN_ADD = " Добавить слово"
BTN_VOCAB = "📚 Словарь"
BTN_TASK = "🧠 Задание"
BTN_PRACTICE = "💬 Практика"
BTN_WORDS = "🎯 Тематические слова"
BTN_IMPORT = "📖 Импорт из текста"
BTN_STATS = "📊 Статистика"
BTN_SETTINGS = "⚙️ Настройки"
def main_menu_keyboard() -> ReplyKeyboardMarkup:
def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
"""Клавиатура с основными командами (кнопки отправляют команды)."""
return ReplyKeyboardMarkup(
resize_keyboard=True,
keyboard=[
[
KeyboardButton(text=BTN_ADD),
KeyboardButton(text=BTN_VOCAB),
KeyboardButton(text=t(lang, "menu.add")),
KeyboardButton(text=t(lang, "menu.vocab")),
],
[
KeyboardButton(text=BTN_TASK),
KeyboardButton(text=BTN_PRACTICE),
KeyboardButton(text=t(lang, "menu.task")),
KeyboardButton(text=t(lang, "menu.practice")),
],
[
KeyboardButton(text=BTN_WORDS),
KeyboardButton(text=BTN_IMPORT),
KeyboardButton(text=t(lang, "menu.words")),
KeyboardButton(text=t(lang, "menu.import")),
],
[
KeyboardButton(text=BTN_STATS),
KeyboardButton(text=BTN_SETTINGS),
KeyboardButton(text=t(lang, "menu.stats")),
KeyboardButton(text=t(lang, "menu.settings")),
],
],
)
@@ -66,129 +56,112 @@ async def cmd_start(message: Message, state: FSMContext):
username=message.from_user.username
)
lang = (user.language_interface or 'ru')
if is_new_user:
# Новый пользователь
await message.answer(
f"👋 Привет, {message.from_user.first_name}!\n\n"
f"Я бот для изучения английского языка. Помогу тебе:\n"
f"📚 Пополнять словарный запас (ручное/тематическое/из текста)\n"
f"✍️ Выполнять интерактивные задания\n"
f"💬 Практиковать язык в диалоге с AI\n"
f"📊 Отслеживать свой прогресс\n\n"
f"<b>Команды:</b>\n"
f"• /add [слово] - добавить слово\n"
f"• /words [тема] - тематическая подборка\n"
f"• /import - импорт из текста\n"
f"• /vocabulary - мой словарь\n"
f"• /task - задания\n"
f"• /practice - диалог с AI\n"
f"• /stats - статистика\n"
f"• /settings - настройки\n"
f"• /reminder - напоминания\n"
f"• /help - полная справка",
reply_markup=main_menu_keyboard(),
t(lang, "start.new_intro", first_name=message.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
# Предлагаем пройти тест уровня
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📊 Пройти тест уровня", callback_data="offer_level_test")],
[InlineKeyboardButton(text="➡️ Пропустить", callback_data="skip_level_test")]
[InlineKeyboardButton(text=t(lang, 'start.offer_btn'), callback_data="offer_level_test")],
[InlineKeyboardButton(text=t(lang, 'start.skip_btn'), callback_data="skip_level_test")]
])
await message.answer(
"🎯 <b>Определим твой уровень?</b>\n\n"
"Короткий тест (7 вопросов) поможет подобрать задания под твой уровень.\n"
"Это займёт 2-3 минуты.\n\n"
"Или можешь пропустить и установить уровень вручную позже в /settings",
reply_markup=keyboard
)
await message.answer(t(lang, "start.offer_test"), reply_markup=keyboard)
else:
# Существующий пользователь
await message.answer(
f"С возвращением, {message.from_user.first_name}! 👋\n\n"
f"Готов продолжить обучение?\n\n"
f"<b>Быстрый доступ:</b>\n"
f"• /vocabulary - посмотреть словарь\n"
f"• /task - получить задание\n"
f"• /practice - практика диалога\n"
f"• /words [тема] - тематическая подборка\n"
f"• /stats - статистика\n"
f"• /help - все команды",
reply_markup=main_menu_keyboard(),
t(lang, "start.return", first_name=message.from_user.first_name),
reply_markup=main_menu_keyboard(lang),
)
@router.message(Command("menu"))
async def cmd_menu(message: Message):
"""Показать клавиатуру с основными командами."""
await message.answer("Главное меню доступно ниже ⤵️", reply_markup=main_menu_keyboard())
# Определяем язык пользователя
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'
await message.answer(t(lang, "menu.below"), reply_markup=main_menu_keyboard(lang))
# Обработчики кнопок главного меню (по тексту)
@router.message(F.text == BTN_ADD)
def _menu_match(key: str):
labels = {t('ru', key), t('en', key), t('ja', key)}
return lambda m: m.text in labels
@router.message(_menu_match('menu.add'))
async def btn_add_pressed(message: Message, state: FSMContext):
from bot.handlers.vocabulary import AddWordStates
await message.answer(
"Отправь слово, которое хочешь добавить:\n"
"Например: <code>/add elephant</code>\n\n"
"Или просто отправь слово без команды!"
)
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'
await message.answer(t(lang, 'add.prompt'))
await state.set_state(AddWordStates.waiting_for_word)
@router.message(F.text == BTN_VOCAB)
@router.message(_menu_match('menu.vocab'))
async def btn_vocab_pressed(message: Message):
from bot.handlers.vocabulary import cmd_vocabulary
await cmd_vocabulary(message)
@router.message(F.text == BTN_TASK)
@router.message(_menu_match('menu.task'))
async def btn_task_pressed(message: Message, state: FSMContext):
from bot.handlers.tasks import cmd_task
await cmd_task(message, state)
@router.message(F.text == BTN_PRACTICE)
@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)
@router.message(F.text == BTN_IMPORT)
@router.message(_menu_match('menu.import'))
async def btn_import_pressed(message: Message, state: FSMContext):
from bot.handlers.import_text import cmd_import
await cmd_import(message, state)
@router.message(F.text == BTN_STATS)
@router.message(_menu_match('menu.stats'))
async def btn_stats_pressed(message: Message):
from bot.handlers.tasks import cmd_stats
await cmd_stats(message)
@router.message(F.text == BTN_SETTINGS)
@router.message(_menu_match('menu.settings'))
async def btn_settings_pressed(message: Message):
from bot.handlers.settings import cmd_settings
await cmd_settings(message)
@router.message(F.text == BTN_WORDS)
@router.message(_menu_match('menu.words'))
async def btn_words_pressed(message: Message, state: FSMContext):
"""Подсказать про тематические слова и показать быстрые темы."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
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'
text = (
"📚 <b>Тематические подборки слов</b>\n\n"
"Используй: <code>/words [тема]</code>\n\n"
"Популярные темы:"
t(lang, 'words.help_title') + "\n\n" +
t(lang, 'words.help_usage') + "\n\n" +
t(lang, 'words.popular')
)
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="✈️ Путешествия", callback_data="menu_theme_travel")],
[InlineKeyboardButton(text="🍔 Еда", callback_data="menu_theme_food")],
[InlineKeyboardButton(text="💼 Работа", callback_data="menu_theme_work")],
[InlineKeyboardButton(text="🌿 Природа", callback_data="menu_theme_nature")],
[InlineKeyboardButton(text="💻 Технологии", callback_data="menu_theme_technology")],
[InlineKeyboardButton(text=t(lang, 'words.topic_travel'), callback_data="menu_theme_travel")],
[InlineKeyboardButton(text=t(lang, 'words.topic_food'), callback_data="menu_theme_food")],
[InlineKeyboardButton(text=t(lang, 'words.topic_work'), callback_data="menu_theme_work")],
[InlineKeyboardButton(text=t(lang, 'words.topic_nature'), callback_data="menu_theme_nature")],
[InlineKeyboardButton(text=t(lang, 'words.topic_technology'), callback_data="menu_theme_technology")],
])
await message.answer(text, reply_markup=keyboard)
@@ -201,21 +174,29 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
from services.ai_service import ai_service
from bot.handlers.words import show_words_list, WordsStates
# Сразу отвечаем на callback, чтобы избежать таймаута
await callback.answer()
theme = callback.data.split("menu_theme_")[-1]
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.answer("Сначала /start", show_alert=True)
await callback.answer(t('ru', 'common.start_first'), show_alert=True)
return
generating = await callback.message.answer(f"🔄 Генерирую подборку слов по теме '{theme}'...")
words = await ai_service.generate_thematic_words(theme=theme, level=user.level.value, count=10)
lang = (user.language_interface or 'ru')
generating = await callback.message.answer(t(lang, 'words.generating', theme=theme))
words = await ai_service.generate_thematic_words(
theme=theme,
level=user.level.value,
count=10,
learning_lang=user.learning_language,
translation_lang=user.language_interface,
)
await generating.delete()
if not words:
await callback.message.answer("Не удалось сгенерировать подборку. Попробуй позже.")
await callback.answer()
await callback.message.answer(t(lang, 'words.generate_failed'))
return
# Сохраняем в состояние как в /words
@@ -223,30 +204,16 @@ async def pick_theme_from_menu(callback: CallbackQuery, state: FSMContext):
await state.set_state(WordsStates.viewing_words)
await show_words_list(callback.message, words, theme)
await callback.answer()
@router.message(Command("help"))
async def cmd_help(message: Message):
"""Обработчик команды /help"""
await message.answer(
"<b>📖 Справка по командам:</b>\n\n"
"<b>Управление словарём:</b>\n"
"• /add [слово] - добавить слово в словарь\n"
"• /vocabulary - просмотр словаря\n"
"• /words [тема] - тематическая подборка слов\n"
"• /import - импортировать слова из текста\n\n"
"<b>Обучение:</b>\n"
"• /task - задание (перевод, заполнение пропусков)\n"
"• /practice - диалог с ИИ (6 сценариев)\n"
"• /level_test - тест определения уровня\n\n"
"<b>Статистика:</b>\n"
"• /stats - твой прогресс\n\n"
"<b>Настройки:</b>\n"
"• /settings - уровень и язык\n"
"• /reminder - ежедневные напоминания\n\n"
"💡 Ты также можешь просто отправить мне слово, и я предложу добавить его в словарь!"
)
# Определяем язык пользователя
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'
await message.answer(t(lang, "start.help"))
@router.callback_query(F.data == "offer_level_test")
@@ -261,13 +228,8 @@ async def offer_level_test_callback(callback: CallbackQuery, state: FSMContext):
@router.callback_query(F.data == "skip_level_test")
async def skip_level_test_callback(callback: CallbackQuery):
"""Пропустить тест уровня"""
await callback.message.edit_text(
"✅ Хорошо!\n\n"
"Ты можешь пройти тест позже командой /level_test\n"
"или установить уровень вручную в /settings\n\n"
"Давай начнём! Попробуй:\n"
"• /words travel - тематическая подборка\n"
"• /practice - диалог с AI\n"
"• /add hello - добавить слово"
)
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.edit_text(t(lang, 'start.skip_msg'))
await callback.answer()