- Добавлены мини-истории для чтения с выбором жанра и вопросами - Кнопка показа/скрытия перевода истории - Количество вопросов берётся из настроек пользователя - Слово дня генерируется глобально в 00:00 UTC - Кнопка "Практика" открывает меню выбора режима - Убран автоматический create_all при запуске (только миграции) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
509 lines
20 KiB
Python
509 lines
20 KiB
Python
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 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
|
||
|
||
router = Router()
|
||
|
||
|
||
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 [слово1, слово2, ...]"""
|
||
# Получаем слово(а) из команды
|
||
parts = message.text.split(maxsplit=1)
|
||
|
||
if len(parts) < 2:
|
||
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)
|
||
return
|
||
|
||
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):
|
||
"""Обработка ввода слова или нескольких слов"""
|
||
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):
|
||
"""Обработка добавления слова"""
|
||
# Получаем пользователя
|
||
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
|
||
|
||
# Проверяем, есть ли уже такое слово
|
||
existing_word = await VocabularyService.find_word(session, user.id, word, source_lang=user.learning_language)
|
||
if existing_word:
|
||
lang = get_user_lang(user)
|
||
await message.answer(t(lang, 'add.exists', word=word, translation=existing_word.word_translation))
|
||
await state.clear()
|
||
return
|
||
|
||
# Показываем индикатор загрузки
|
||
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'
|
||
processing_msg = await message.answer(t(lang, 'add.searching'))
|
||
|
||
# Получаем перевод через AI (с несколькими значениями)
|
||
async with async_session_maker() as session:
|
||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||
source_lang = user.learning_language if user else 'en'
|
||
translation_lang = get_user_translation_lang(user)
|
||
word_data = await ai_service.translate_word_with_contexts(
|
||
word, source_lang=source_lang, translation_lang=translation_lang, max_translations=3,
|
||
user_id=user.id if user else None
|
||
)
|
||
|
||
# Удаляем сообщение о загрузке
|
||
await processing_msg.delete()
|
||
|
||
# Формируем текст с переводами
|
||
translations = word_data.get("translations", [])
|
||
translations_text = ""
|
||
|
||
if translations:
|
||
# Основной перевод для backward compatibility
|
||
primary = next((tr for tr in translations if tr.get('is_primary')), translations[0])
|
||
word_data['translation'] = primary.get('translation', '')
|
||
|
||
translations_text = "\n\n" + t(lang, 'add.translations_header') + "\n"
|
||
for idx, tr in enumerate(translations, 1):
|
||
marker = "★ " if tr.get('is_primary') else ""
|
||
translations_text += f"{idx}. {marker}<b>{tr.get('translation', '')}</b>\n"
|
||
if tr.get('context'):
|
||
translations_text += f" <i>«{tr.get('context', '')}»</i>\n"
|
||
if tr.get('context_translation'):
|
||
translations_text += f" <i>({tr.get('context_translation', '')})</i>\n"
|
||
translations_text += "\n"
|
||
else:
|
||
# Fallback если нет переводов
|
||
word_data['translation'] = 'Ошибка перевода'
|
||
|
||
# Отправляем карточку слова
|
||
card_text = (
|
||
f"📝 <b>{word_data['word']}</b>\n"
|
||
f"🔊 [{word_data.get('transcription', '')}]\n\n"
|
||
f"{t(lang, 'add.level_label')}: {word_data.get('difficulty', 'A1')}"
|
||
f"{translations_text}"
|
||
f"{t(lang, 'add.confirm_question')}"
|
||
)
|
||
|
||
# Создаём inline-кнопки
|
||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
||
[
|
||
InlineKeyboardButton(text=t(lang, 'add.btn_add'), callback_data=f"add_word_confirm"),
|
||
InlineKeyboardButton(text=t(lang, 'add.btn_cancel'), callback_data="add_word_cancel")
|
||
]
|
||
])
|
||
|
||
# Сохраняем данные слова в состоянии
|
||
await state.update_data(word_data=word_data, user_id=user.id)
|
||
await state.set_state(AddWordStates.waiting_for_confirmation)
|
||
|
||
await message.answer(card_text, reply_markup=keyboard)
|
||
|
||
|
||
@router.callback_query(F.data == "add_word_confirm", AddWordStates.waiting_for_confirmation)
|
||
async def confirm_add_word(callback: CallbackQuery, state: FSMContext):
|
||
"""Подтверждение добавления слова"""
|
||
# Отвечаем сразу, запись в БД и подсчёт могут занять время
|
||
await callback.answer()
|
||
data = await state.get_data()
|
||
word_data = data.get("word_data")
|
||
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'
|
||
translation_lang = get_user_translation_lang(user)
|
||
ui_lang = get_user_lang(user)
|
||
|
||
# Добавляем слово в базу
|
||
new_word = await VocabularyService.add_word(
|
||
session,
|
||
user_id=user_id,
|
||
word_original=word_data["word"],
|
||
word_translation=word_data["translation"],
|
||
source_lang=source_lang,
|
||
translation_lang=translation_lang,
|
||
transcription=word_data.get("transcription"),
|
||
difficulty_level=word_data.get("difficulty"),
|
||
source=WordSource.MANUAL
|
||
)
|
||
|
||
# Сохраняем переводы с контекстами в отдельную таблицу
|
||
translations = word_data.get("translations", [])
|
||
if translations:
|
||
await VocabularyService.add_translations_bulk(
|
||
session,
|
||
vocabulary_id=new_word.id,
|
||
translations=translations
|
||
)
|
||
|
||
# Получаем общее количество слов
|
||
words_count = await VocabularyService.get_words_count(session, user_id, learning_lang=user.learning_language)
|
||
lang = ui_lang
|
||
|
||
await callback.message.edit_text(
|
||
t(lang, 'add.added_success', word=word_data['word'], count=words_count)
|
||
)
|
||
|
||
await state.clear()
|
||
|
||
|
||
@router.callback_query(F.data == "add_word_cancel")
|
||
async def cancel_add_word(callback: CallbackQuery, state: FSMContext):
|
||
"""Отмена добавления слова"""
|
||
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.cancelled'))
|
||
await state.clear()
|
||
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
|
||
|
||
|
||
@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, user_id)
|
||
|
||
if not user:
|
||
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
|
||
|
||
# Получаем слова с пагинацией
|
||
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)
|
||
|
||
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
|
||
|
||
# Формируем список слов
|
||
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, start=offset + 1):
|
||
progress = ""
|
||
if word.times_reviewed > 0:
|
||
accuracy = int((word.correct_answers / word.times_reviewed) * 100)
|
||
progress = " " + t(lang, 'vocab.accuracy_inline', n=accuracy)
|
||
|
||
words_list += (
|
||
f"{idx}. <b>{word.word_original}</b> — {word.word_translation}\n"
|
||
f" 🔊 [{word.transcription or ''}]{progress}\n\n"
|
||
)
|
||
|
||
words_list += t(lang, 'vocab.page_info', page=page + 1, total=total_pages, count=total_count)
|
||
|
||
# Кнопки пагинации
|
||
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()
|