feat: мини-истории, слово дня, меню практики

- Добавлены мини-истории для чтения с выбором жанра и вопросами
- Кнопка показа/скрытия перевода истории
- Количество вопросов берётся из настроек пользователя
- Слово дня генерируется глобально в 00:00 UTC
- Кнопка "Практика" открывает меню выбора режима
- Убран автоматический create_all при запуске (только миграции)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-09 15:05:38 +03:00
parent 69c651c031
commit f38ff2f18e
22 changed files with 3131 additions and 77 deletions

688
bot/handlers/stories.py Normal file
View File

@@ -0,0 +1,688 @@
"""Handler для мини-историй (Reading Practice)."""
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 MiniStory, StoryGenre, 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
from utils.levels import get_user_level_for_language
router = Router()
class StoryStates(StatesGroup):
"""Состояния для чтения истории"""
reading = State() # Чтение истории
questions = State() # Ответы на вопросы
GENRE_EMOJI = {
"dialogue": "🗣",
"news": "📰",
"story": "🎭",
"letter": "📧",
"recipe": "🍳"
}
def get_genre_keyboard(lang: str) -> InlineKeyboardMarkup:
"""Клавиатура выбора жанра"""
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"🗣 {t(lang, 'story.genre.dialogue')}",
callback_data="story_genre_dialogue"
),
InlineKeyboardButton(
text=f"📰 {t(lang, 'story.genre.news')}",
callback_data="story_genre_news"
),
],
[
InlineKeyboardButton(
text=f"🎭 {t(lang, 'story.genre.story')}",
callback_data="story_genre_story"
),
InlineKeyboardButton(
text=f"📧 {t(lang, 'story.genre.letter')}",
callback_data="story_genre_letter"
),
],
[
InlineKeyboardButton(
text=f"🍳 {t(lang, 'story.genre.recipe')}",
callback_data="story_genre_recipe"
),
],
])
def format_story_text(story: MiniStory, lang: str, show_translation: bool = False) -> str:
"""Форматировать текст истории"""
emoji = GENRE_EMOJI.get(story.genre.value, "📖")
text = f"{emoji} <b>{story.title}</b>\n"
text += f"<i>{t(lang, 'story.level')}: {story.level}{story.word_count} {t(lang, 'story.words')}</i>\n"
text += "" * 20 + "\n\n"
text += story.content
if show_translation and story.translation:
text += "\n\n" + "" * 20
text += f"\n\n🌐 <b>{t(lang, 'story.translation')}:</b>\n\n"
text += f"<i>{story.translation}</i>"
text += "\n\n" + "" * 20
return text
def get_story_keyboard(story_id: int, lang: str, show_translation: bool = False) -> InlineKeyboardMarkup:
"""Клавиатура под историей"""
# Кнопка перевода - показать или скрыть
if show_translation:
translation_btn = InlineKeyboardButton(
text=f"🌐 {t(lang, 'story.hide_translation')}",
callback_data=f"story_hide_translation_{story_id}"
)
else:
translation_btn = InlineKeyboardButton(
text=f"🌐 {t(lang, 'story.show_translation')}",
callback_data=f"story_show_translation_{story_id}"
)
return InlineKeyboardMarkup(inline_keyboard=[
[translation_btn],
[
InlineKeyboardButton(
text=f"📝 {t(lang, 'story.questions_btn')}",
callback_data=f"story_questions_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"📚 {t(lang, 'story.vocab_btn')}",
callback_data=f"story_vocab_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.new_btn')}",
callback_data="story_new"
),
],
])
@router.message(Command("story"))
async def cmd_story(message: Message, state: FSMContext):
"""Обработчик команды /story"""
await state.clear()
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)
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
text += t(lang, 'story.choose_genre')
await message.answer(text, reply_markup=get_genre_keyboard(lang))
@router.callback_query(F.data == "story_new")
async def story_new_callback(callback: CallbackQuery, state: FSMContext):
"""Показать выбор жанра для новой истории"""
await callback.answer()
await state.clear()
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)
text = f"📖 <b>{t(lang, 'story.title')}</b>\n\n"
text += t(lang, 'story.choose_genre')
await callback.message.edit_text(text, reply_markup=get_genre_keyboard(lang))
@router.callback_query(F.data.startswith("story_genre_"))
async def story_genre_callback(callback: CallbackQuery, state: FSMContext):
"""Генерация истории выбранного жанра"""
await callback.answer()
genre = callback.data.replace("story_genre_", "")
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)
learning_lang = user.learning_language or 'en'
level = get_user_level_for_language(user)
translation_lang = get_user_translation_lang(user)
# Показываем индикатор генерации
await callback.message.edit_text(t(lang, 'story.generating'))
# Получаем количество вопросов из настроек
tasks_count = getattr(user, 'tasks_count', 5) or 5
# Генерируем историю
story_data = await ai_service.generate_mini_story(
genre=genre,
level=level,
learning_lang=learning_lang,
translation_lang=translation_lang,
user_id=user.id,
num_questions=tasks_count
)
if not story_data:
await callback.message.edit_text(
t(lang, 'story.failed'),
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.try_again')}",
callback_data="story_new"
)]
])
)
return
# Сохраняем историю в БД
story = MiniStory(
user_id=user.id,
title=story_data.get('title', 'Story'),
content=story_data.get('content', ''),
translation=story_data.get('translation', ''),
genre=StoryGenre(genre),
learning_lang=learning_lang,
level=level,
word_count=story_data.get('word_count', 0),
vocabulary=story_data.get('vocabulary', []),
questions=story_data.get('questions', [])
)
session.add(story)
await session.commit()
await session.refresh(story)
# Сохраняем ID истории в состоянии
await state.update_data(story_id=story.id)
await state.set_state(StoryStates.reading)
# Показываем историю
text = format_story_text(story, lang)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang)
)
@router.callback_query(F.data.startswith("story_show_translation_"))
async def story_show_translation_callback(callback: CallbackQuery):
"""Показать перевод истории"""
await callback.answer()
story_id = int(callback.data.replace("story_show_translation_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
text = format_story_text(story, lang, show_translation=True)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang, show_translation=True)
)
@router.callback_query(F.data.startswith("story_hide_translation_"))
async def story_hide_translation_callback(callback: CallbackQuery):
"""Скрыть перевод истории"""
await callback.answer()
story_id = int(callback.data.replace("story_hide_translation_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
text = format_story_text(story, lang, show_translation=False)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang, show_translation=False)
)
@router.callback_query(F.data.startswith("story_vocab_"))
async def story_vocab_callback(callback: CallbackQuery):
"""Показать словарь истории"""
await callback.answer()
story_id = int(callback.data.replace("story_vocab_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
vocabulary = story.vocabulary or []
if not vocabulary:
await callback.answer(t(lang, 'story.no_vocab'), show_alert=True)
return
# Формируем текст со словами
text = f"📚 <b>{t(lang, 'story.vocabulary')}</b>\n\n"
keyboard_buttons = []
for i, word_data in enumerate(vocabulary[:10]):
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription', '')
if transcription:
text += f"• <b>{word}</b> [{transcription}] — {translation}\n"
else:
text += f"• <b>{word}</b> — {translation}\n"
# Кнопка добавления слова
keyboard_buttons.append([
InlineKeyboardButton(
text=f" {word}",
callback_data=f"story_addword_{story_id}_{i}"
)
])
# Кнопка "Добавить все"
keyboard_buttons.append([
InlineKeyboardButton(
text=f" {t(lang, 'story.add_all')}",
callback_data=f"story_addall_{story_id}"
)
])
# Кнопка назад
keyboard_buttons.append([
InlineKeyboardButton(
text=f"⬅️ {t(lang, 'story.back')}",
callback_data=f"story_back_{story_id}"
)
])
await callback.message.edit_text(
text,
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
)
@router.callback_query(F.data.startswith("story_addword_"))
async def story_addword_callback(callback: CallbackQuery):
"""Добавить одно слово из истории"""
parts = callback.data.split("_")
story_id = int(parts[2])
word_idx = int(parts[3])
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)
story = await session.get(MiniStory, story_id)
if not story or not story.vocabulary:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
if word_idx >= len(story.vocabulary):
await callback.answer(t(lang, 'story.word_not_found'), show_alert=True)
return
word_data = story.vocabulary[word_idx]
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription')
# Проверяем, нет ли уже
existing = await VocabularyService.get_word_by_original(
session, user.id, word, source_lang=story.learning_lang
)
if existing:
await callback.answer(t(lang, 'words.already_exists', word=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,
word_translation=translation,
source_lang=story.learning_lang,
translation_lang=translation_lang,
transcription=transcription,
difficulty_level=story.level,
source=WordSource.IMPORT
)
await session.commit()
await callback.answer(t(lang, 'story.word_added', word=word), show_alert=True)
@router.callback_query(F.data.startswith("story_addall_"))
async def story_addall_callback(callback: CallbackQuery):
"""Добавить все слова из истории"""
story_id = int(callback.data.replace("story_addall_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story or not story.vocabulary:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
translation_lang = get_user_translation_lang(user)
added = 0
for word_data in story.vocabulary:
word = word_data.get('word', '')
translation = word_data.get('translation', '')
transcription = word_data.get('transcription')
# Проверяем дубликаты
existing = await VocabularyService.get_word_by_original(
session, user.id, word, source_lang=story.learning_lang
)
if not existing:
await VocabularyService.add_word(
session=session,
user_id=user.id,
word_original=word,
word_translation=translation,
source_lang=story.learning_lang,
translation_lang=translation_lang,
transcription=transcription,
difficulty_level=story.level,
source=WordSource.IMPORT
)
added += 1
await session.commit()
await callback.answer(t(lang, 'story.words_added', n=added), show_alert=True)
@router.callback_query(F.data.startswith("story_back_"))
async def story_back_callback(callback: CallbackQuery):
"""Вернуться к истории"""
await callback.answer()
story_id = int(callback.data.replace("story_back_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
text = format_story_text(story, lang)
await callback.message.edit_text(
text,
reply_markup=get_story_keyboard(story.id, lang)
)
@router.callback_query(F.data.startswith("story_questions_"))
async def story_questions_callback(callback: CallbackQuery, state: FSMContext):
"""Показать вопросы по истории"""
await callback.answer()
story_id = int(callback.data.replace("story_questions_", ""))
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
questions = story.questions or []
if not questions:
await callback.answer(t(lang, 'story.no_questions'), show_alert=True)
return
# Сохраняем состояние
await state.update_data(
story_id=story_id,
current_question=0,
correct_answers=0,
total_questions=len(questions)
)
await state.set_state(StoryStates.questions)
# Показываем первый вопрос
await show_question(callback.message, story, 0, lang, edit=True)
async def show_question(message: Message, story: MiniStory, q_idx: int, lang: str, edit: bool = False):
"""Показать вопрос"""
questions = story.questions or []
if q_idx >= len(questions):
return
q = questions[q_idx]
total = len(questions)
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total}</b>\n\n"
text += f"{q.get('question', '')}\n"
# Кнопки с вариантами ответов
options = q.get('options', [])
keyboard_buttons = []
for i, option in enumerate(options):
keyboard_buttons.append([
InlineKeyboardButton(
text=option,
callback_data=f"story_answer_{story.id}_{q_idx}_{i}"
)
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
if edit:
await message.edit_text(text, reply_markup=keyboard)
else:
await message.answer(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("story_answer_"))
async def story_answer_callback(callback: CallbackQuery, state: FSMContext):
"""Обработка ответа на вопрос"""
parts = callback.data.split("_")
story_id = int(parts[2])
q_idx = int(parts[3])
answer_idx = int(parts[4])
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.answer(t(lang, 'story.not_found'), show_alert=True)
return
questions = story.questions or []
if q_idx >= len(questions):
await callback.answer(t(lang, 'story.question_not_found'), show_alert=True)
return
q = questions[q_idx]
correct = q.get('correct', 0)
options = q.get('options', [])
# Получаем данные состояния
data = await state.get_data()
correct_answers = data.get('correct_answers', 0)
total_questions = data.get('total_questions', len(questions))
# Проверяем ответ
is_correct = (answer_idx == correct)
if is_correct:
correct_answers += 1
# Показываем результат ответа
text = f"📝 <b>{t(lang, 'story.question')} {q_idx + 1}/{total_questions}</b>\n\n"
text += f"{q.get('question', '')}\n\n"
for i, option in enumerate(options):
if i == correct:
text += f"{option}\n"
elif i == answer_idx and not is_correct:
text += f"{option}\n"
else:
text += f"{option}\n"
if is_correct:
text += f"\n{t(lang, 'story.correct')}"
await callback.answer("", show_alert=False)
else:
text += f"\n{t(lang, 'story.incorrect')}"
await callback.answer("", show_alert=False)
# Обновляем состояние
await state.update_data(correct_answers=correct_answers)
# Следующий вопрос или результаты
next_q_idx = q_idx + 1
if next_q_idx < total_questions:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"➡️ {t(lang, 'story.next_question')}",
callback_data=f"story_nextq_{story_id}_{next_q_idx}"
)]
])
else:
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(
text=f"📊 {t(lang, 'story.show_results')}",
callback_data=f"story_results_{story_id}_{correct_answers}"
)]
])
await callback.message.edit_text(text, reply_markup=keyboard)
@router.callback_query(F.data.startswith("story_nextq_"))
async def story_nextq_callback(callback: CallbackQuery, state: FSMContext):
"""Следующий вопрос"""
await callback.answer()
parts = callback.data.split("_")
story_id = int(parts[2])
q_idx = int(parts[3])
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
await show_question(callback.message, story, q_idx, lang, edit=True)
@router.callback_query(F.data.startswith("story_results_"))
async def story_results_callback(callback: CallbackQuery, state: FSMContext):
"""Показать результаты"""
await callback.answer()
await state.clear()
parts = callback.data.split("_")
story_id = int(parts[2])
correct_answers = int(parts[3])
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)
story = await session.get(MiniStory, story_id)
if not story:
await callback.message.edit_text(t(lang, 'story.not_found'))
return
total = len(story.questions or [])
# Обновляем статус истории
story.is_completed = True
story.correct_answers = correct_answers
await session.commit()
# Определяем эмодзи по результату
percentage = (correct_answers / total * 100) if total > 0 else 0
if percentage >= 80:
emoji = "🎉"
comment = t(lang, 'story.result_excellent')
elif percentage >= 50:
emoji = "👍"
comment = t(lang, 'story.result_good')
else:
emoji = "📚"
comment = t(lang, 'story.result_practice')
text = f"{emoji} <b>{t(lang, 'story.results_title')}</b>\n\n"
text += f"📖 {story.title}\n\n"
text += f"{t(lang, 'story.correct_answers')}: <b>{correct_answers}/{total}</b>\n"
text += f"{t(lang, 'story.accuracy')}: <b>{percentage:.0f}%</b>\n\n"
text += f"<i>{comment}</i>"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📚 {t(lang, 'story.vocab_btn')}",
callback_data=f"story_vocab_{story_id}"
),
],
[
InlineKeyboardButton(
text=f"🔄 {t(lang, 'story.new_btn')}",
callback_data="story_new"
),
],
])
await callback.message.edit_text(text, reply_markup=keyboard)