Files
tg_bot_language/bot/handlers/stories.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

689 lines
24 KiB
Python
Raw 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.
"""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)