feat: restructure menu and add file import
- Consolidate "Add word" menu with submenu (Manual, Thematic, Import) - Add file import support (.txt, .md) with AI batch translation - Add vocabulary pagination with navigation buttons - Add "Add word" button in tasks for new words mode - Fix undefined variables bug in vocabulary confirm handler - Add localization keys for add_menu in ru/en/ja 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from aiogram import Router, F
|
||||
import re
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -14,6 +15,11 @@ from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
|
||||
# Поддерживаемые расширения файлов
|
||||
SUPPORTED_EXTENSIONS = {'.txt', '.md'}
|
||||
# Разделители между словом и переводом
|
||||
WORD_SEPARATORS = re.compile(r'\s*[-–—:=\t]\s*')
|
||||
|
||||
|
||||
class ImportStates(StatesGroup):
|
||||
"""Состояния для импорта слов из текста"""
|
||||
@@ -271,3 +277,230 @@ async def close_import(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.message.delete()
|
||||
await state.clear()
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def parse_word_line(line: str) -> dict | None:
|
||||
"""
|
||||
Парсит строку формата 'слово - перевод' или 'слово : перевод'
|
||||
Или просто 'слово' (без перевода)
|
||||
Возвращает dict с word и translation (может быть None) или None если пустая строка
|
||||
"""
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'): # Пропускаем пустые и комментарии
|
||||
return None
|
||||
|
||||
# Пробуем разделить по разделителям
|
||||
parts = WORD_SEPARATORS.split(line, maxsplit=1)
|
||||
|
||||
if len(parts) == 2:
|
||||
word = parts[0].strip()
|
||||
translation = parts[1].strip()
|
||||
if word and translation:
|
||||
return {'word': word, 'translation': translation}
|
||||
|
||||
# Если разделителя нет — это просто слово без перевода
|
||||
word = line.strip()
|
||||
if word:
|
||||
return {'word': word, 'translation': None}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_file_content(content: str) -> tuple[list[dict], bool]:
|
||||
"""
|
||||
Парсит содержимое файла и возвращает список слов
|
||||
Возвращает (words, needs_translation) — нужен ли перевод через AI
|
||||
"""
|
||||
words = []
|
||||
seen = set() # Для дедупликации
|
||||
needs_translation = False
|
||||
|
||||
for line in content.split('\n'):
|
||||
parsed = parse_word_line(line)
|
||||
if parsed and parsed['word'].lower() not in seen:
|
||||
words.append(parsed)
|
||||
seen.add(parsed['word'].lower())
|
||||
if parsed['translation'] is None:
|
||||
needs_translation = True
|
||||
|
||||
return words, needs_translation
|
||||
|
||||
|
||||
@router.message(F.document)
|
||||
async def handle_file_import(message: Message, state: FSMContext, bot: Bot):
|
||||
"""Обработка файлов для импорта слов"""
|
||||
document = message.document
|
||||
|
||||
# Проверяем расширение файла
|
||||
file_name = document.file_name or ''
|
||||
file_ext = ''
|
||||
if '.' in file_name:
|
||||
file_ext = '.' + file_name.rsplit('.', 1)[-1].lower()
|
||||
|
||||
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 file_ext not in SUPPORTED_EXTENSIONS:
|
||||
await message.answer(t(lang, 'import_file.unsupported_format'))
|
||||
return
|
||||
|
||||
# Проверяем размер файла (макс 1MB)
|
||||
if document.file_size > 1024 * 1024:
|
||||
await message.answer(t(lang, 'import_file.too_large'))
|
||||
return
|
||||
|
||||
# Скачиваем файл
|
||||
try:
|
||||
file = await bot.get_file(document.file_id)
|
||||
file_content = await bot.download_file(file.file_path)
|
||||
content = file_content.read().decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
await message.answer(t(lang, 'import_file.encoding_error'))
|
||||
return
|
||||
except Exception:
|
||||
await message.answer(t(lang, 'import_file.download_error'))
|
||||
return
|
||||
|
||||
# Парсим содержимое
|
||||
words, needs_translation = parse_file_content(content)
|
||||
|
||||
if not words:
|
||||
await message.answer(t(lang, 'import_file.no_words_found'))
|
||||
return
|
||||
|
||||
# Ограничиваем количество слов
|
||||
max_words = 50 if needs_translation else 100
|
||||
if len(words) > max_words:
|
||||
words = words[:max_words]
|
||||
await message.answer(t(lang, 'import_file.truncated', n=max_words))
|
||||
|
||||
# Если нужен перевод — отправляем в AI
|
||||
if needs_translation:
|
||||
processing_msg = await message.answer(t(lang, 'import_file.translating'))
|
||||
|
||||
# Получаем переводы от AI
|
||||
words_to_translate = [w['word'] for w in words]
|
||||
translations = await ai_service.translate_words_batch(
|
||||
words=words_to_translate,
|
||||
source_lang=user.learning_language,
|
||||
translation_lang=user.language_interface
|
||||
)
|
||||
|
||||
await processing_msg.delete()
|
||||
|
||||
# Обновляем слова переводами
|
||||
if isinstance(translations, list):
|
||||
for i, word_data in enumerate(words):
|
||||
if i < len(translations):
|
||||
tr = translations[i]
|
||||
word_data['translation'] = tr.get('translation', '')
|
||||
word_data['transcription'] = tr.get('transcription', '')
|
||||
if tr.get('reading'): # Фуригана для японского
|
||||
word_data['reading'] = tr.get('reading')
|
||||
else:
|
||||
# Если AI вернул не список — пробуем сопоставить по слову
|
||||
for word_data in words:
|
||||
word_data['translation'] = ''
|
||||
word_data['transcription'] = ''
|
||||
|
||||
# Сохраняем данные в состоянии и показываем слова
|
||||
await state.update_data(
|
||||
words=words,
|
||||
user_id=user.id,
|
||||
level=get_user_level_for_language(user)
|
||||
)
|
||||
await state.set_state(ImportStates.viewing_words)
|
||||
|
||||
await show_file_words(message, words, lang)
|
||||
|
||||
|
||||
async def show_file_words(message: Message, words: list, lang: str):
|
||||
"""Показать слова из файла с кнопками для добавления"""
|
||||
# Показываем первые 20 слов в сообщении
|
||||
display_words = words[:20]
|
||||
text = t(lang, 'import_file.found_header', n=len(words)) + "\n\n"
|
||||
|
||||
for idx, word_data in enumerate(display_words, 1):
|
||||
word = word_data['word']
|
||||
translation = word_data.get('translation', '')
|
||||
transcription = word_data.get('transcription', '')
|
||||
|
||||
line = f"{idx}. <b>{word}</b>"
|
||||
if transcription:
|
||||
line += f" [{transcription}]"
|
||||
if translation:
|
||||
line += f" — {translation}"
|
||||
text += line + "\n"
|
||||
|
||||
if len(words) > 20:
|
||||
text += f"\n<i>...и ещё {len(words) - 20} слов</i>\n"
|
||||
|
||||
text += "\n" + t(lang, 'import_file.choose_action')
|
||||
|
||||
# Кнопки действий
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=t(lang, 'import_file.add_all_btn', n=len(words)),
|
||||
callback_data="import_file_all"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text=t(lang, 'words.close_btn'),
|
||||
callback_data="close_import"
|
||||
)]
|
||||
])
|
||||
|
||||
await message.answer(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "import_file_all", ImportStates.viewing_words)
|
||||
async def import_file_all_words(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить все слова из файла"""
|
||||
await callback.answer()
|
||||
|
||||
data = await state.get_data()
|
||||
words = data.get('words', [])
|
||||
user_id = data.get('user_id')
|
||||
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
async with async_session_maker() as session:
|
||||
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['word']
|
||||
)
|
||||
|
||||
if existing:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Добавляем слово
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
word_original=word_data['word'],
|
||||
word_translation=word_data.get('translation', ''),
|
||||
source_lang=user.learning_language if user else None,
|
||||
translation_lang=user.language_interface if user else None,
|
||||
transcription=word_data.get('transcription'),
|
||||
source=WordSource.IMPORT
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -30,10 +30,6 @@ def main_menu_keyboard(lang: str = 'ru') -> ReplyKeyboardMarkup:
|
||||
KeyboardButton(text=t(lang, "menu.task")),
|
||||
KeyboardButton(text=t(lang, "menu.practice")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.words")),
|
||||
KeyboardButton(text=t(lang, "menu.import")),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text=t(lang, "menu.stats")),
|
||||
KeyboardButton(text=t(lang, "menu.settings")),
|
||||
@@ -100,12 +96,109 @@ def _menu_match(key: str):
|
||||
|
||||
@router.message(_menu_match('menu.add'))
|
||||
async def btn_add_pressed(message: Message, state: FSMContext):
|
||||
from bot.handlers.vocabulary import AddWordStates
|
||||
"""Показать меню добавления слов"""
|
||||
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'))
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.manual'), callback_data="add_manual")],
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.thematic'), callback_data="add_thematic")],
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.import'), callback_data="add_import")]
|
||||
])
|
||||
|
||||
await message.answer(t(lang, 'add_menu.title'), reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "add_manual")
|
||||
async def add_manual_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить слово вручную"""
|
||||
await callback.answer()
|
||||
from bot.handlers.vocabulary import AddWordStates
|
||||
|
||||
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 state.set_state(AddWordStates.waiting_for_word)
|
||||
await callback.message.edit_text(t(lang, 'add.prompt'))
|
||||
|
||||
|
||||
@router.callback_query(F.data == "add_thematic")
|
||||
async def add_thematic_callback(callback: CallbackQuery):
|
||||
"""Тематические слова"""
|
||||
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'
|
||||
|
||||
# Показываем подсказку по использованию /words
|
||||
text = (
|
||||
t(lang, 'words.help_title') + "\n\n" +
|
||||
t(lang, 'words.help_usage') + "\n\n" +
|
||||
t(lang, 'words.help_examples') + "\n\n" +
|
||||
t(lang, 'words.help_note')
|
||||
)
|
||||
|
||||
# Популярные темы как кнопки
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text=t(lang, 'words.topic_travel'), callback_data="words_travel"),
|
||||
InlineKeyboardButton(text=t(lang, 'words.topic_food'), callback_data="words_food")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=t(lang, 'words.topic_work'), callback_data="words_work"),
|
||||
InlineKeyboardButton(text=t(lang, 'words.topic_technology'), callback_data="words_technology")
|
||||
],
|
||||
[InlineKeyboardButton(text="⬅️ " + t(lang, 'settings.back'), callback_data="back_to_add_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(text, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("words_"))
|
||||
async def words_topic_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Генерация слов по теме"""
|
||||
topic = callback.data.replace("words_", "")
|
||||
await callback.answer()
|
||||
await callback.message.delete()
|
||||
|
||||
from bot.handlers.words import generate_words_for_theme
|
||||
await generate_words_for_theme(callback.message, state, topic, callback.from_user.id)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "add_import")
|
||||
async def add_import_callback(callback: CallbackQuery):
|
||||
"""Показать меню импорта"""
|
||||
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'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'import_menu.from_text'), callback_data="import_from_text")],
|
||||
[InlineKeyboardButton(text=t(lang, 'import_menu.from_file'), callback_data="import_from_file")],
|
||||
[InlineKeyboardButton(text="⬅️ " + t(lang, 'settings.back'), callback_data="back_to_add_menu")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(t(lang, 'import_menu.title'), reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "back_to_add_menu")
|
||||
async def back_to_add_menu_callback(callback: CallbackQuery):
|
||||
"""Вернуться в меню добавления"""
|
||||
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'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.manual'), callback_data="add_manual")],
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.thematic'), callback_data="add_thematic")],
|
||||
[InlineKeyboardButton(text=t(lang, 'add_menu.import'), callback_data="add_import")]
|
||||
])
|
||||
|
||||
await callback.message.edit_text(t(lang, 'add_menu.title'), reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.message(_menu_match('menu.vocab'))
|
||||
@@ -128,8 +221,51 @@ async def btn_practice_pressed(message: Message, state: FSMContext):
|
||||
|
||||
@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)
|
||||
"""Показать меню импорта"""
|
||||
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'
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'import_menu.from_text'), callback_data="import_from_text")],
|
||||
[InlineKeyboardButton(text=t(lang, 'import_menu.from_file'), callback_data="import_from_file")]
|
||||
])
|
||||
|
||||
await message.answer(t(lang, 'import_menu.title'), reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "import_from_text")
|
||||
async def import_from_text_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Импорт из текста"""
|
||||
await callback.answer()
|
||||
from bot.handlers.import_text import ImportStates
|
||||
|
||||
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 = user.language_interface or 'ru'
|
||||
await state.set_state(ImportStates.waiting_for_text)
|
||||
await callback.message.edit_text(
|
||||
t(lang, 'import.title') + "\n\n" +
|
||||
t(lang, 'import.desc') + "\n\n" +
|
||||
t(lang, 'import.can_send') + "\n\n" +
|
||||
t(lang, 'import.cancel_hint')
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "import_from_file")
|
||||
async def import_from_file_callback(callback: CallbackQuery):
|
||||
"""Импорт из файла"""
|
||||
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.edit_text(t(lang, 'import_menu.file_hint'))
|
||||
|
||||
|
||||
@router.message(_menu_match('menu.stats'))
|
||||
|
||||
@@ -5,23 +5,27 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
from database.db import async_session_maker
|
||||
from database.models import WordSource
|
||||
from services.user_service import UserService
|
||||
from services.task_service import TaskService
|
||||
from services.vocabulary_service import VocabularyService
|
||||
from services.ai_service import ai_service
|
||||
from utils.i18n import t
|
||||
from utils.i18n import t, get_user_lang
|
||||
from utils.levels import get_user_level_for_language
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class TaskStates(StatesGroup):
|
||||
"""Состояния для прохождения заданий"""
|
||||
choosing_mode = State()
|
||||
doing_tasks = State()
|
||||
waiting_for_answer = State()
|
||||
|
||||
|
||||
@router.message(Command("task"))
|
||||
async def cmd_task(message: Message, state: FSMContext):
|
||||
"""Обработчик команды /task"""
|
||||
"""Обработчик команды /task — показываем меню выбора режима"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
@@ -29,7 +33,39 @@ async def cmd_task(message: Message, state: FSMContext):
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
# Генерируем задания разных типов
|
||||
lang = get_user_lang(user)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(
|
||||
text=t(lang, 'tasks.mode_vocabulary'),
|
||||
callback_data="task_mode_vocabulary"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text=t(lang, 'tasks.mode_new_words'),
|
||||
callback_data="task_mode_new"
|
||||
)]
|
||||
])
|
||||
|
||||
await state.update_data(user_id=user.id)
|
||||
await state.set_state(TaskStates.choosing_mode)
|
||||
await message.answer(t(lang, 'tasks.choose_mode'), reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "task_mode_vocabulary", TaskStates.choosing_mode)
|
||||
async def start_vocabulary_tasks(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)
|
||||
|
||||
# Генерируем задания по словам из словаря
|
||||
tasks = await TaskService.generate_mixed_tasks(
|
||||
session, user.id, count=5,
|
||||
learning_lang=user.learning_language,
|
||||
@@ -37,7 +73,8 @@ async def cmd_task(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
if not tasks:
|
||||
await message.answer(t(user.language_interface or 'ru', 'tasks.no_words'))
|
||||
await callback.message.edit_text(t(lang, 'tasks.no_words'))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Сохраняем задания в состоянии
|
||||
@@ -45,12 +82,70 @@ async def cmd_task(message: Message, state: FSMContext):
|
||||
tasks=tasks,
|
||||
current_task_index=0,
|
||||
correct_count=0,
|
||||
user_id=user.id
|
||||
user_id=user.id,
|
||||
mode='vocabulary'
|
||||
)
|
||||
await state.set_state(TaskStates.doing_tasks)
|
||||
|
||||
# Показываем первое задание
|
||||
await show_current_task(message, state)
|
||||
await callback.message.delete()
|
||||
await show_current_task(callback.message, state)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "task_mode_new", TaskStates.choosing_mode)
|
||||
async def start_new_words_tasks(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)
|
||||
level = get_user_level_for_language(user)
|
||||
|
||||
# Показываем индикатор загрузки
|
||||
await callback.message.edit_text(t(lang, 'tasks.generating_new'))
|
||||
|
||||
# Генерируем новые слова через AI
|
||||
words = await ai_service.generate_thematic_words(
|
||||
theme="random everyday vocabulary",
|
||||
level=level,
|
||||
count=5,
|
||||
learning_lang=user.learning_language,
|
||||
translation_lang=user.language_interface,
|
||||
)
|
||||
|
||||
if not words:
|
||||
await callback.message.edit_text(t(lang, 'tasks.generate_failed'))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Преобразуем слова в задания
|
||||
tasks = []
|
||||
translate_prompt = t(lang, 'tasks.translate_to', lang_name=t(lang, f'lang.{user.language_interface}'))
|
||||
for word in words:
|
||||
tasks.append({
|
||||
'type': 'translate',
|
||||
'question': f"{translate_prompt}: {word.get('word', '')}",
|
||||
'word': word.get('word', ''),
|
||||
'correct_answer': word.get('translation', ''),
|
||||
'transcription': word.get('transcription', '')
|
||||
})
|
||||
|
||||
await state.update_data(
|
||||
tasks=tasks,
|
||||
current_task_index=0,
|
||||
correct_count=0,
|
||||
user_id=user.id,
|
||||
mode='new_words'
|
||||
)
|
||||
await state.set_state(TaskStates.doing_tasks)
|
||||
|
||||
await callback.message.delete()
|
||||
await show_current_task(callback.message, state)
|
||||
|
||||
|
||||
async def show_current_task(message: Message, state: FSMContext):
|
||||
@@ -162,10 +257,18 @@ async def process_answer(message: Message, state: FSMContext):
|
||||
)
|
||||
|
||||
# Показываем результат и кнопку "Далее"
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||||
[InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")],
|
||||
[InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")]
|
||||
])
|
||||
mode = data.get('mode')
|
||||
buttons = [[InlineKeyboardButton(text=t(lang, 'tasks.next_btn'), callback_data="next_task")]]
|
||||
|
||||
# Для режима new_words добавляем кнопку "Добавить слово"
|
||||
if mode == 'new_words':
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=t(lang, 'tasks.add_word_btn'),
|
||||
callback_data=f"add_task_word_{current_index}"
|
||||
)])
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=t(lang, 'tasks.stop_btn'), callback_data="stop_tasks")])
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
await message.answer(result_text, reply_markup=keyboard)
|
||||
# После показа результата ждём нажатия кнопки – переключаемся в состояние doing_tasks
|
||||
@@ -180,6 +283,53 @@ async def next_task(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("add_task_word_"), TaskStates.doing_tasks)
|
||||
async def add_task_word(callback: CallbackQuery, state: FSMContext):
|
||||
"""Добавить слово из задания в словарь"""
|
||||
task_index = int(callback.data.split("_")[-1])
|
||||
data = await state.get_data()
|
||||
tasks = data.get('tasks', [])
|
||||
|
||||
if task_index >= len(tasks):
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
task = tasks[task_index]
|
||||
word = task.get('word', '')
|
||||
translation = task.get('correct_answer', '')
|
||||
transcription = task.get('transcription', '')
|
||||
|
||||
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()
|
||||
return
|
||||
|
||||
lang = get_user_lang(user)
|
||||
|
||||
# Проверяем, есть ли слово уже в словаре
|
||||
existing = await VocabularyService.get_word_by_original(session, user.id, word)
|
||||
|
||||
if existing:
|
||||
await callback.answer(t(lang, 'tasks.word_already_exists', word=word), show_alert=True)
|
||||
return
|
||||
|
||||
# Добавляем слово в словарь
|
||||
await VocabularyService.add_word(
|
||||
session=session,
|
||||
user_id=user.id,
|
||||
word_original=word,
|
||||
word_translation=translation,
|
||||
source_lang=user.learning_language,
|
||||
translation_lang=user.language_interface,
|
||||
transcription=transcription,
|
||||
source=WordSource.AI_TASK
|
||||
)
|
||||
|
||||
await callback.answer(t(lang, 'tasks.word_added', word=word), show_alert=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "stop_tasks", TaskStates.doing_tasks)
|
||||
async def stop_tasks_callback(callback: CallbackQuery, state: FSMContext):
|
||||
"""Остановить выполнение заданий через кнопку"""
|
||||
|
||||
@@ -124,6 +124,11 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
|
||||
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)
|
||||
source_lang = user.learning_language if user else 'en'
|
||||
ui_lang = user.language_interface if user else 'ru'
|
||||
|
||||
# Добавляем слово в базу
|
||||
await VocabularyService.add_word(
|
||||
session,
|
||||
@@ -140,12 +145,9 @@ async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
|
||||
)
|
||||
|
||||
# Получаем общее количество слов
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language)
|
||||
lang = ui_lang or 'ru'
|
||||
|
||||
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, 'add.added_success', word=word_data['word'], count=words_count)
|
||||
)
|
||||
@@ -164,29 +166,55 @@ async def cancel_add_word(callback: CallbackQuery, state: FSMContext):
|
||||
await callback.answer()
|
||||
|
||||
|
||||
WORDS_PER_PAGE = 10
|
||||
|
||||
|
||||
@router.message(Command("vocabulary"))
|
||||
async def cmd_vocabulary(message: Message):
|
||||
"""Обработчик команды /vocabulary"""
|
||||
await show_vocabulary_page(message, page=0)
|
||||
|
||||
|
||||
async def show_vocabulary_page(message_or_callback, page: int = 0, edit: bool = False):
|
||||
"""Показать страницу словаря"""
|
||||
# Определяем, это Message или CallbackQuery
|
||||
# В CallbackQuery from_user — это пользователь, а message.from_user — бот
|
||||
user_id = message_or_callback.from_user.id
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
user = await UserService.get_user_by_telegram_id(session, user_id)
|
||||
|
||||
if not user:
|
||||
await message.answer(t('ru', 'common.start_first'))
|
||||
if edit:
|
||||
await message_or_callback.message.edit_text(t('ru', 'common.start_first'))
|
||||
else:
|
||||
await message_or_callback.answer(t('ru', 'common.start_first'))
|
||||
return
|
||||
|
||||
# Получаем слова пользователя
|
||||
words = await VocabularyService.get_user_words(session, user.id, limit=10, learning_lang=user.learning_language)
|
||||
# Получаем слова с пагинацией
|
||||
offset = page * WORDS_PER_PAGE
|
||||
words = await VocabularyService.get_user_words(
|
||||
session, user.id,
|
||||
limit=WORDS_PER_PAGE,
|
||||
offset=offset,
|
||||
learning_lang=user.learning_language
|
||||
)
|
||||
total_count = await VocabularyService.get_words_count(session, user.id, learning_lang=user.learning_language)
|
||||
|
||||
if not words:
|
||||
lang = (user.language_interface if user else 'ru') or 'ru'
|
||||
await message.answer(t(lang, 'vocab.empty'))
|
||||
lang = get_user_lang(user)
|
||||
|
||||
if not words and page == 0:
|
||||
if edit:
|
||||
await message_or_callback.message.edit_text(t(lang, 'vocab.empty'))
|
||||
else:
|
||||
await message_or_callback.answer(t(lang, 'vocab.empty'))
|
||||
return
|
||||
|
||||
# Формируем список слов
|
||||
lang = (user.language_interface if user else 'ru') or 'ru'
|
||||
total_pages = (total_count + WORDS_PER_PAGE - 1) // WORDS_PER_PAGE
|
||||
words_list = t(lang, 'vocab.header') + "\n\n"
|
||||
for idx, word in enumerate(words, 1):
|
||||
|
||||
for idx, word in enumerate(words, start=offset + 1):
|
||||
progress = ""
|
||||
if word.times_reviewed > 0:
|
||||
accuracy = int((word.correct_answers / word.times_reviewed) * 100)
|
||||
@@ -197,9 +225,49 @@ async def cmd_vocabulary(message: Message):
|
||||
f" 🔊 [{word.transcription or ''}]{progress}\n\n"
|
||||
)
|
||||
|
||||
if total_count > 10:
|
||||
words_list += "\n" + t(lang, 'vocab.shown_last', n=total_count)
|
||||
else:
|
||||
words_list += "\n" + t(lang, 'vocab.total', n=total_count)
|
||||
words_list += t(lang, 'vocab.page_info', page=page + 1, total=total_pages, count=total_count)
|
||||
|
||||
await message.answer(words_list)
|
||||
# Кнопки пагинации
|
||||
buttons = []
|
||||
nav_row = []
|
||||
|
||||
if page > 0:
|
||||
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"vocab_page_{page - 1}"))
|
||||
|
||||
nav_row.append(InlineKeyboardButton(text=f"{page + 1}/{total_pages}", callback_data="vocab_noop"))
|
||||
|
||||
if page < total_pages - 1:
|
||||
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"vocab_page_{page + 1}"))
|
||||
|
||||
if nav_row:
|
||||
buttons.append(nav_row)
|
||||
|
||||
buttons.append([InlineKeyboardButton(text=t(lang, 'vocab.close_btn'), callback_data="vocab_close")])
|
||||
|
||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
if edit:
|
||||
await message_or_callback.message.edit_text(words_list, reply_markup=keyboard)
|
||||
else:
|
||||
await message_or_callback.answer(words_list, reply_markup=keyboard)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("vocab_page_"))
|
||||
async def vocab_page_callback(callback: CallbackQuery):
|
||||
"""Переключение страницы словаря"""
|
||||
page = int(callback.data.split("_")[-1])
|
||||
await callback.answer()
|
||||
await show_vocabulary_page(callback, page=page, edit=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "vocab_noop")
|
||||
async def vocab_noop_callback(callback: CallbackQuery):
|
||||
"""Пустой callback для кнопки с номером страницы"""
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "vocab_close")
|
||||
async def vocab_close_callback(callback: CallbackQuery):
|
||||
"""Закрыть словарь"""
|
||||
await callback.message.delete()
|
||||
await callback.answer()
|
||||
|
||||
@@ -45,6 +45,13 @@ async def cmd_words(message: Message, state: FSMContext):
|
||||
return
|
||||
|
||||
theme = command_parts[1].strip()
|
||||
await generate_words_for_theme(message, state, theme, message.from_user.id)
|
||||
|
||||
|
||||
async def generate_words_for_theme(message: Message, state: FSMContext, theme: str, user_id: int):
|
||||
"""Генерация слов по теме (вызывается из cmd_words и callback)"""
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, user_id)
|
||||
|
||||
# Показываем индикатор генерации
|
||||
lang = user.language_interface or 'ru'
|
||||
|
||||
Reference in New Issue
Block a user