Files
tg_bot_language/bot/handlers/vocabulary.py
mamonov.ep f38ff2f18e feat: мини-истории, слово дня, меню практики
- Добавлены мини-истории для чтения с выбором жанра и вопросами
- Кнопка показа/скрытия перевода истории
- Количество вопросов берётся из настроек пользователя
- Слово дня генерируется глобально в 00:00 UTC
- Кнопка "Практика" открывает меню выбора режима
- Убран автоматический create_all при запуске (только миграции)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:05:38 +03:00

509 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()