Files
tg_bot_language/bot/handlers/stories.py
mamonov.ep adc8a6bf8e feat: мини-игры, premium подписка, улучшенные контексты
Мини-игры (/games):
- Speed Round: 10 раундов, 10 секунд на ответ, очки за скорость
- Match Pairs: 5 слов + 5 переводов, соединить пары

Premium-функции:
- Поля is_premium и premium_until для пользователей
- AI режим проверки ответов (учитывает синонимы)
- Batch проверка всех ответов одним запросом

Улучшения:
- Примеры использования для всех добавляемых слов
- Разбиение переводов по запятой на отдельные записи
- Полные предложения в контекстах (без ___)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 19:42:10 +03:00

709 lines
25 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.
"""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)
new_word = 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
)
# Добавляем переводы в word_translations (разбиваем по запятой)
await VocabularyService.add_translation_split(
session=session,
vocabulary_id=new_word.id,
translation=translation,
context=word_data.get('example') or word_data.get('context'),
context_translation=word_data.get('example_translation') or word_data.get('context_translation'),
is_primary=True
)
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:
new_word = 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
)
# Добавляем переводы в word_translations (разбиваем по запятой)
await VocabularyService.add_translation_split(
session=session,
vocabulary_id=new_word.id,
translation=translation,
context=word_data.get('example') or word_data.get('context'),
context_translation=word_data.get('example_translation') or word_data.get('context_translation'),
is_primary=True
)
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)