From 697644269ef09d913be18adbdf7028826472d9f2 Mon Sep 17 00:00:00 2001 From: polina Date: Sun, 21 Dec 2025 23:52:10 +0300 Subject: [PATCH 1/6] started working on telegram bot --- .gitignore | 9 +++ create_database.py | 93 ++++++++++++++++++++++ create_tables.py | 31 ++++++++ requirements.txt | 9 +++ tg_bot/config/__init__.py | 0 tg_bot/config/constants.py | 0 tg_bot/config/settings.py | 41 ++++++++++ tg_bot/infrastructure/database/__init__.py | 0 tg_bot/infrastructure/database/database.py | 15 ++++ tg_bot/infrastructure/database/models.py | 39 +++++++++ 10 files changed, 237 insertions(+) create mode 100644 .gitignore create mode 100644 create_database.py create mode 100755 create_tables.py create mode 100644 requirements.txt create mode 100644 tg_bot/config/__init__.py create mode 100644 tg_bot/config/constants.py create mode 100644 tg_bot/config/settings.py create mode 100644 tg_bot/infrastructure/database/__init__.py create mode 100644 tg_bot/infrastructure/database/database.py create mode 100644 tg_bot/infrastructure/database/models.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f99d40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.env +venv/ +.venv/ diff --git a/create_database.py b/create_database.py new file mode 100644 index 0000000..30ddf29 --- /dev/null +++ b/create_database.py @@ -0,0 +1,93 @@ +import os +import sys +from sqlalchemy import create_engine, inspect +from sqlalchemy.orm import declarative_base, Session +from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text +import uuid +from datetime import datetime + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DB_PATH = os.path.join(BASE_DIR, 'data', 'bot.db') +DATABASE_URL = f"sqlite:///{DB_PATH}" + +os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + +if os.path.exists(DB_PATH): + try: + temp_engine = create_engine(DATABASE_URL) + inspector = inspect(temp_engine) + tables = inspector.get_table_names() + if tables: + sys.exit(0) + except: + pass + + choice = input("Перезаписать БД? (y/N): ") + if choice.lower() != 'y': + sys.exit(0) + +engine = create_engine(DATABASE_URL, echo=False) +Base = declarative_base() + +class UserModel(Base): + __tablename__ = "users" + + user_id = Column("user_id", String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + telegram_id = Column("telegram_id", String(100), nullable=False, unique=True) + created_at = Column("created_at", DateTime, default=datetime.utcnow, nullable=False) + role = Column("role", String(20), default="user", nullable=False) + is_premium = Column(Boolean, default=False, nullable=False) + premium_until = Column(DateTime, nullable=True) + questions_used = Column(Integer, default=0, nullable=False) + username = Column(String(100), nullable=True) + first_name = Column(String(100), nullable=True) + last_name = Column(String(100), nullable=True) + +class PaymentModel(Base): + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, autoincrement=True) + payment_id = Column(String(36), default=lambda: str(uuid.uuid4()), nullable=False, unique=True) + user_id = Column(Integer, nullable=False) + amount = Column(String(20), nullable=False) + currency = Column(String(3), default="RUB", nullable=False) + status = Column(String(20), default="pending", nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + yookassa_payment_id = Column(String(100), unique=True, nullable=True) + description = Column(Text, nullable=True) + +try: + Base.metadata.create_all(bind=engine) + + session = Session(bind=engine) + + existing = session.query(UserModel).filter_by(telegram_id="123456789").first() + if not existing: + test_user = UserModel( + telegram_id="123456789", + username="test_user", + first_name="Test", + last_name="User", + is_premium=True + ) + session.add(test_user) + + existing_payment = session.query(PaymentModel).filter_by(yookassa_payment_id="test_yoo_001").first() + if not existing_payment: + test_payment = PaymentModel( + user_id=123456789, + amount="500.00", + status="succeeded", + description="Test payment", + yookassa_payment_id="test_yoo_001" + ) + session.add(test_payment) + + session.commit() + session.close() + +except Exception as e: + print(f"Ошибка: {e}") + import traceback + traceback.print_exc() + diff --git a/create_tables.py b/create_tables.py new file mode 100755 index 0000000..0bca2f4 --- /dev/null +++ b/create_tables.py @@ -0,0 +1,31 @@ +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + from tg_bot.infrastructure.database.database import engine, Base + from tg_bot.infrastructure.database import models + + print("СОЗДАНИЕ ТАБЛИЦ БАЗЫ ДАННЫХ") + Base.metadata.create_all(bind=engine) + + print("Таблицы успешно созданы!") + print(" • users") + print(" • payments") + print() + print(f"База данных: {engine.url}") + + db_path = "data/bot.db" + if os.path.exists(db_path): + size = os.path.getsize(db_path) + print(f"Размер файла: {size} байт") + else: + print("Файл БД не найден, но таблицы созданы") + +except Exception as e: + print(f"Ошибка: {e}") + import traceback + traceback.print_exc() + +print("=" * 50) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0c4aad2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-dotenv>=1.0.0 +aiogram>=3.10.0 +sqlalchemy>=2.0.0 +yookassa>=2.4.0 +fastapi>=0.104.0 +uvicorn>=0.24.0 +python-multipart>=0.0.6 \ No newline at end of file diff --git a/tg_bot/config/__init__.py b/tg_bot/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/config/constants.py b/tg_bot/config/constants.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/config/settings.py b/tg_bot/config/settings.py new file mode 100644 index 0000000..9bacdbf --- /dev/null +++ b/tg_bot/config/settings.py @@ -0,0 +1,41 @@ +import os +from typing import List, Optional +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + extra="allow" + ) + + APP_NAME: str = "VibeLawyerBot" + VERSION: str = "0.1.0" + DEBUG: bool = True + TELEGRAM_BOT_TOKEN: str = "" + FREE_QUESTIONS_LIMIT: int = 5 + PAYMENT_AMOUNT: float = 500.0 + DATABASE_URL: str = "sqlite:///data/bot.db" + LOG_LEVEL: str = "INFO" + LOG_FILE: str = "logs/bot.log" + + YOOKASSA_SHOP_ID: str = "1230200" + YOOKASSA_SECRET_KEY: str = "test_GVoixmlp0FqohXcyFzFHbRlAUoA3B1I2aMtAkAE_ubw" + YOOKASSA_RETURN_URL: str = "https://t.me/vibelawyer_bot" + YOOKASSA_WEBHOOK_SECRET: Optional[str] = None + + ADMIN_IDS_STR: str = "" + + @property + def ADMIN_IDS(self) -> List[int]: + if not self.ADMIN_IDS_STR: + return [] + try: + return [int(x.strip()) for x in self.ADMIN_IDS_STR.split(",")] + except: + return [] + + +settings = Settings() \ No newline at end of file diff --git a/tg_bot/infrastructure/database/__init__.py b/tg_bot/infrastructure/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/infrastructure/database/database.py b/tg_bot/infrastructure/database/database.py new file mode 100644 index 0000000..8044e09 --- /dev/null +++ b/tg_bot/infrastructure/database/database.py @@ -0,0 +1,15 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from tg_bot.config.settings import settings + +engine = create_engine( + settings.DATABASE_URL, + echo=settings.DEBUG +) + +SessionLocal = sessionmaker(bind=engine) + +def create_tables(): + from .models import Base + Base.metadata.create_all(bind=engine) + print(f"Таблицы созданы: {settings.DATABASE_URL}") \ No newline at end of file diff --git a/tg_bot/infrastructure/database/models.py b/tg_bot/infrastructure/database/models.py new file mode 100644 index 0000000..f681729 --- /dev/null +++ b/tg_bot/infrastructure/database/models.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime +from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + + +class UserModel(Base): + __tablename__ = "users" + user_id = Column("user_id", String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + telegram_id = Column("telegram_id", String(100), nullable=False, unique=True) + created_at = Column("created_at", DateTime, default=datetime.utcnow, nullable=False) + role = Column("role", String(20), default="user", nullable=False) + + is_premium = Column(Boolean, default=False, nullable=False) + premium_until = Column(DateTime, nullable=True) + questions_used = Column(Integer, default=0, nullable=False) + + username = Column(String(100), nullable=True) + first_name = Column(String(100), nullable=True) + last_name = Column(String(100), nullable=True) + + +class PaymentModel(Base): + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, autoincrement=True) + payment_id = Column(String(36), default=lambda: str(uuid.uuid4()), nullable=False, unique=True) + user_id = Column(Integer, nullable=False) + amount = Column(String(20), nullable=False) + currency = Column(String(3), default="RUB", nullable=False) + status = Column(String(20), default="pending", nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + yookassa_payment_id = Column(String(100), unique=True, nullable=True) + description = Column(Text, nullable=True) + + def __repr__(self): + return f"" \ No newline at end of file From cd28ba0fbd9921302d36b1930bdca7763e5954a2 Mon Sep 17 00:00:00 2001 From: polina Date: Mon, 22 Dec 2025 08:26:01 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B=D0=B9?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tg_bot/infrastructure/telegram/__init__.py | 0 tg_bot/infrastructure/telegram/bot.py | 51 ++++++++++++ .../telegram/handlers/__init__.py | 0 .../telegram/handlers/help_handler.py | 82 +++++++++++++++++++ .../telegram/handlers/start_handler.py | 70 ++++++++++++++++ .../telegram/handlers/stats_handler.py | 65 +++++++++++++++ tg_bot/main.py | 43 ++++++++++ 7 files changed, 311 insertions(+) create mode 100644 tg_bot/infrastructure/telegram/__init__.py create mode 100644 tg_bot/infrastructure/telegram/bot.py create mode 100644 tg_bot/infrastructure/telegram/handlers/__init__.py create mode 100644 tg_bot/infrastructure/telegram/handlers/help_handler.py create mode 100644 tg_bot/infrastructure/telegram/handlers/start_handler.py create mode 100644 tg_bot/infrastructure/telegram/handlers/stats_handler.py create mode 100644 tg_bot/main.py diff --git a/tg_bot/infrastructure/telegram/__init__.py b/tg_bot/infrastructure/telegram/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py new file mode 100644 index 0000000..9f7a44b --- /dev/null +++ b/tg_bot/infrastructure/telegram/bot.py @@ -0,0 +1,51 @@ +import logging +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.client.default import DefaultBotProperties + +from tg_bot.config.settings import settings +from tg_bot.infrastructure.telegram.handlers import ( + start_handler, + help_handler, + stats_handler +) + +logger = logging.getLogger(__name__) + + +async def create_bot() -> tuple[Bot, Dispatcher]: + bot = Bot( + token=settings.TELEGRAM_BOT_TOKEN, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + ) + dp = Dispatcher() + dp.include_router(start_handler.router) + dp.include_router(help_handler.router) + dp.include_router(stats_handler.router) + return bot, dp + + +async def start_bot(): + try: + bot, dp = await create_bot() + + try: + webhook_info = await bot.get_webhook_info() + if webhook_info.url: + await bot.delete_webhook(drop_pending_updates=True) + except Exception: + pass + print("=" * 50) + print("Telegram бот запускается") + print(f"Бот: @vibelawyer_bot") + print(f"Лимит: {settings.FREE_QUESTIONS_LIMIT} вопросов") + print(f"Команды: /start, /help, /buy, /stats, /mycollections, /search") + print("=" * 50) + + await dp.start_polling(bot, drop_pending_updates=True) + + except Exception as e: + logger.error(f"Ошибка запуска: {e}") + raise + finally: + await bot.session.close() \ No newline at end of file diff --git a/tg_bot/infrastructure/telegram/handlers/__init__.py b/tg_bot/infrastructure/telegram/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/infrastructure/telegram/handlers/help_handler.py b/tg_bot/infrastructure/telegram/handlers/help_handler.py new file mode 100644 index 0000000..8a92920 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/help_handler.py @@ -0,0 +1,82 @@ + +from aiogram import Router, types +from aiogram.filters import Command +from aiogram.types import Message + +from tg_bot.config.settings import settings + +router = Router() + + +@router.message(Command("help")) +async def cmd_help(message: Message): + help_text = ( + f"VibeLawyerBot - помощь\n\n" + + f"Основные команды:\n" + f"• /start - начать работу с ботом\n" + f"• /help - показать это сообщение\n" + f"• /buy - купить подписку\n" + f"• /stats - статистика и лимиты\n" + f"• /mypayments - история платежей\n\n" + f"Работа с коллекциями:\n" + f"• /mycollections - показать мои коллекции документов\n" + f"• /search - поиск документов в коллекции\n\n" + + f"Как работает бот:\n" + f"1. У вас есть {settings.FREE_QUESTIONS_LIMIT} бесплатных вопросов\n" + f"2. Бот ищет ответы в ваших коллекциях документов\n" + f"3. После исчерпания лимита нужна подписка\n" + f"4. Подписка даёт неограниченный доступ\n\n" + f"О коллекциях:\n" + f"• Администратор загружает документы в коллекции\n" + f"• Вам предоставляется доступ к коллекциям\n" + f"• При задаче вопроса бот ищет ответы в ваших коллекциях\n" + f"• Используйте /mycollections для просмотра коллекций\n\n" + + f"Оплата (тестовый режим):\n" + f"• Безопасно через ЮKассу\n" + f"• Сразу после оплаты доступ открывается\n" + f"• Тестовые карты для проверки:\n" + f" Успешная оплата: 5555 5555 5555 4477\n" + f" Срок: любой будущий (напр. 12/30)\n" + f" CVV: любой 3 цифры (напр. 123)\n\n" + f" Отказ в оплате: 5555 5555 5555 4445\n" + f" Срок: любой будущий\n" + f" CVV: любой 3 цифры\n\n" + f"• Поддержка: @vibelawyer_support\n\n" + + f"Задавайте юридические вопросы, и бот поможет с ответами!" + ) + + await message.answer(help_text, parse_mode="HTML") + + +@router.message(Command("testcards")) +async def cmd_testcards(message: Message): + testcards_text = ( + f"Тестовые банковские карты для оплаты\n\n" + + f"Для тестирования оплаты используйте:\n\n" + + f"Карта для успешной оплаты:\n" + f"• Номер: 5555 5555 5555 4477\n" + f"• Срок действия: ЛЮБОЙ будущий (например: 12/30)\n" + f"• CVV код: ЛЮБЫЕ 3 цифры (например: 123)\n" + f"• Результат: Оплата пройдёт успешно\n\n" + + f"Карта для отказа в оплате:\n" + f"• Номер: 5555 5555 5555 4445\n" + f"• Срок действия: ЛЮБОЙ будущий\n" + f"• CVV код: ЛЮБЫЕ 3 цифры\n" + f"• Результат: Оплата будет отклонена\n\n" + + f"Важно:\n" + f"• Это тестовые карты, реальные деньги не списываются\n" + f"• Используются только для проверки работы оплаты\n" + f"• После успешной тестовой оплаты premium активируется\n\n" + + f"Для оплаты подписки используйте команду /buy" + ) + + await message.answer(testcards_text, parse_mode="HTML") diff --git a/tg_bot/infrastructure/telegram/handlers/start_handler.py b/tg_bot/infrastructure/telegram/handlers/start_handler.py new file mode 100644 index 0000000..71be4c8 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/start_handler.py @@ -0,0 +1,70 @@ +from aiogram import Router, types +from aiogram.filters import Command +from aiogram.types import Message +from datetime import datetime + +from tg_bot.config.settings import settings +from tg_bot.infrastructure.database.database import SessionLocal +from tg_bot.infrastructure.database.models import UserModel + +router = Router() + +@router.message(Command("start")) +async def cmd_start(message: Message): + + user_id = message.from_user.id + username = message.from_user.username or "" + first_name = message.from_user.first_name or "" + last_name = message.from_user.last_name or "" + session = SessionLocal() + try: + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if not user: + user = UserModel( + telegram_id=str(user_id), + username=username, + first_name=first_name, + last_name=last_name + ) + session.add(user) + session.commit() + print(f"Новый пользователь: {user_id}") + else: + user.username = username + user.first_name = first_name + user.last_name = last_name + session.commit() + + except Exception as e: + print(f"Ошибка сохранения пользователя: {e}") + session.rollback() + finally: + session.close() + welcome_text = ( + f"Привет, {first_name}!\n\n" + f"Я VibeLawyerBot - ваш помощник в юридических вопросах.\n\n" + + f"Как я работаю:\n" + f"1. Администратор загружает документы в коллекции\n" + f"2. Вы задаёте вопрос на любую юридическую тему\n" + f"3. Я ищу ответы в ваших коллекциях документов\n" + f"4. Даю развернутый ответ на основе найденных документов\n" + f"5. Первые {settings.FREE_QUESTIONS_LIMIT} вопросов - бесплатно\n" + f"6. Для продолжения нужна подписка (/buy)\n\n" + + f"Основные команды:\n" + f"• /help - подробная помощь\n" + f"• /buy - купить подписку\n" + f"• /stats - ваша статистика\n" + f"• /mypayments - история платежей\n" + f"• /mycollections - мои коллекции документов\n" + f"• /search - поиск в коллекции\n\n" + + f"Готовы начать? Просто напишите ваш вопрос!\n\n" + f"Для получения полного доступа используйте /buy" + ) + + await message.answer(welcome_text, parse_mode="HTML") diff --git a/tg_bot/infrastructure/telegram/handlers/stats_handler.py b/tg_bot/infrastructure/telegram/handlers/stats_handler.py new file mode 100644 index 0000000..3ce7534 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/stats_handler.py @@ -0,0 +1,65 @@ + +from aiogram import Router, types +from aiogram.filters import Command +from aiogram.types import Message + +from tg_bot.config.settings import settings +from tg_bot.infrastructure.database.database import SessionLocal +from tg_bot.infrastructure.database.models import UserModel + +router = Router() + + +@router.message(Command("stats")) +async def cmd_stats(message: Message): + user_id = message.from_user.id + + session = SessionLocal() + try: + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if user: + stats_text = ( + f"Ваша статистика\n\n" + f"Основное:\n" + f"• ID: {user_id}\n" + f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" + f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\n" + ) + + if user.is_premium: + stats_text += ( + f"Premium статус:\n" + f"• Активен до: {user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n" + f"• Лимит вопросов: безлимитно\n\n" + ) + else: + remaining = max(0, settings.FREE_QUESTIONS_LIMIT - user.questions_used) + stats_text += ( + f"Бесплатный доступ:\n" + f"• Осталось вопросов: {remaining}\n" + f"• Для безлимита: /buy\n\n" + ) + + else: + stats_text = ( + f"Добро пожаловать!\n\n" + f"Вы новый пользователь.\n" + f"• Ваш ID: {user_id}\n" + f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n" + f"• Для начала работы просто задайте вопрос!\n\n" + f"Используйте /buy для получения полного доступа" + ) + + await message.answer(stats_text, parse_mode="HTML") + + except Exception as e: + await message.answer( + f"Ошибка получения статистики\n\n" + f"Попробуйте позже.", + parse_mode="HTML" + ) + finally: + session.close() diff --git a/tg_bot/main.py b/tg_bot/main.py new file mode 100644 index 0000000..fa162e7 --- /dev/null +++ b/tg_bot/main.py @@ -0,0 +1,43 @@ +import asyncio +import logging +import sys +import os + +current_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.dirname(current_dir) +sys.path.insert(0, parent_dir) + +from tg_bot.config.settings import settings + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(settings.LOG_FILE), + logging.StreamHandler() + ] +) +logger = logging.getLogger("vibelawyer_bot") + + +async def main(): + logger.info("=" * 50) + logger.info(f"Запуск {settings.APP_NAME} v{settings.VERSION}") + logger.info(f"Режим: {'РАЗРАБОТКА' if settings.DEBUG else 'ПРОДАКШН'}") + logger.info(f"Лимит вопросов: {settings.FREE_QUESTIONS_LIMIT}") + logger.info("=" * 50) + + try: + from tg_bot.infrastructure.telegram.bot import start_bot + await start_bot() + + except KeyboardInterrupt: + logger.info("Бот остановлен пользователем") + print("\nБот остановлен") + except Exception as e: + logger.error(f"Ошибка запуска: {e}") + print(f"Ошибка запуска: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From ba244e324a5d6f0af0dc10335799ec0c3958c676 Mon Sep 17 00:00:00 2001 From: polina Date: Mon, 22 Dec 2025 13:46:17 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=BE=D0=BF=D1=80=D0=BE=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tg_bot/application/__init__.py | 0 tg_bot/application/services/__init__.py | 0 tg_bot/application/services/rag_service.py | 139 ++++++++ tg_bot/config/settings.py | 3 + tg_bot/domain/__init__.py | 0 tg_bot/domain/services/__init__.py | 0 tg_bot/domain/services/user_service.py | 29 ++ tg_bot/infrastructure/external/__init__.py | 0 .../external/deepseek_client.py | 172 ++++++++++ tg_bot/infrastructure/telegram/bot.py | 4 +- .../telegram/handlers/question_handler.py | 306 ++++++++++++++++++ 11 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 tg_bot/application/__init__.py create mode 100644 tg_bot/application/services/__init__.py create mode 100644 tg_bot/application/services/rag_service.py create mode 100644 tg_bot/domain/__init__.py create mode 100644 tg_bot/domain/services/__init__.py create mode 100644 tg_bot/domain/services/user_service.py create mode 100644 tg_bot/infrastructure/external/__init__.py create mode 100644 tg_bot/infrastructure/external/deepseek_client.py create mode 100644 tg_bot/infrastructure/telegram/handlers/question_handler.py diff --git a/tg_bot/application/__init__.py b/tg_bot/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/application/services/__init__.py b/tg_bot/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/application/services/rag_service.py b/tg_bot/application/services/rag_service.py new file mode 100644 index 0000000..c5771ed --- /dev/null +++ b/tg_bot/application/services/rag_service.py @@ -0,0 +1,139 @@ +import aiohttp +from tg_bot.infrastructure.external.deepseek_client import DeepSeekClient +from tg_bot.config.settings import settings + +BACKEND_URL = "http://localhost:8001/api/v1" + + +class RAGService: + + def __init__(self): + self.deepseek_client = DeepSeekClient() + + async def search_documents_in_collections( + self, + user_telegram_id: str, + query: str, + limit_per_collection: int = 5 + ) -> list[dict]: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/users/telegram/{user_telegram_id}" + ) as user_response: + if user_response.status != 200: + return [] + + user_data = await user_response.json() + user_uuid = str(user_data.get("user_id")) + + if not user_uuid: + return [] + + async with session.get( + f"{BACKEND_URL}/collections/", + headers={"X-Telegram-ID": user_telegram_id} + ) as collections_response: + if collections_response.status != 200: + return [] + + collections = await collections_response.json() + + all_documents = [] + for collection in collections: + collection_id = collection.get("collection_id") + if not collection_id: + continue + + try: + async with aiohttp.ClientSession() as search_session: + async with search_session.get( + f"{BACKEND_URL}/documents/collection/{collection_id}", + params={"search": query, "limit": limit_per_collection}, + headers={"X-Telegram-ID": user_telegram_id} + ) as search_response: + if search_response.status == 200: + documents = await search_response.json() + for doc in documents: + doc["collection_name"] = collection.get("name", "Unknown") + all_documents.append(doc) + except Exception as e: + print(f"Error searching collection {collection_id}: {e}") + continue + + return all_documents[:20] + + except Exception as e: + print(f"Error searching documents: {e}") + return [] + + async def generate_answer_with_rag( + self, + question: str, + user_telegram_id: str + ) -> dict: + documents = await self.search_documents_in_collections( + user_telegram_id, + question + ) + + context_parts = [] + sources = [] + + for doc in documents[:5]: + title = doc.get("title", "Без названия") + content = doc.get("content", "")[:1000] + collection_name = doc.get("collection_name", "Unknown") + + context_parts.append(f"Документ: {title}\nКоллекция: {collection_name}\nСодержание: {content[:500]}...") + sources.append({ + "title": title, + "collection": collection_name, + "document_id": doc.get("document_id") + }) + + context = "\n\n".join(context_parts) if context_parts else "Релевантные документы не найдены." + + system_prompt = """Ты - помощник-юрист, который отвечает на вопросы на основе предоставленных документов. +Используй информацию из документов для формирования точного и полезного ответа. +Если в документах нет информации для ответа, честно скажи об этом.""" + + user_prompt = f"""Контекст из документов: +{context} + +Вопрос пользователя: {question} + +Ответь на вопрос, используя информацию из предоставленных документов. Если информации недостаточно, укажи это.""" + + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + response = await self.deepseek_client.chat_completion( + messages=messages, + temperature=0.7, + max_tokens=2000 + ) + + return { + "answer": response.get("content", "Failed to generate answer"), + "sources": sources, + "usage": response.get("usage", {}) + } + + except Exception as e: + print(f"Error generating answer: {e}") + if documents: + return { + "answer": f"Found {len(documents)} documents but failed to generate answer", + "sources": sources[:3], + "usage": {} + } + else: + return { + "answer": "No relevant documents found", + "sources": [], + "usage": {} + } diff --git a/tg_bot/config/settings.py b/tg_bot/config/settings.py index 9bacdbf..908bfd5 100644 --- a/tg_bot/config/settings.py +++ b/tg_bot/config/settings.py @@ -26,6 +26,9 @@ class Settings(BaseSettings): YOOKASSA_RETURN_URL: str = "https://t.me/vibelawyer_bot" YOOKASSA_WEBHOOK_SECRET: Optional[str] = None + DEEPSEEK_API_KEY: Optional[str] = None + DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions" + ADMIN_IDS_STR: str = "" @property diff --git a/tg_bot/domain/__init__.py b/tg_bot/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/domain/services/__init__.py b/tg_bot/domain/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/domain/services/user_service.py b/tg_bot/domain/services/user_service.py new file mode 100644 index 0000000..17d4e33 --- /dev/null +++ b/tg_bot/domain/services/user_service.py @@ -0,0 +1,29 @@ +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from tg_bot.infrastructure.database.models import UserModel + + +class UserService: + + def __init__(self, session: Session): + self.session = session + + async def activate_premium(self, telegram_id: int) -> bool: + try: + user = self.session.query(UserModel) \ + .filter(UserModel.telegram_id == str(telegram_id)) \ + .first() + if user: + user.is_premium = True + if user.premium_until and user.premium_until > datetime.now(): + user.premium_until = user.premium_until + timedelta(days=30) + else: + user.premium_until = datetime.now() + timedelta(days=30) + self.session.commit() + return True + else: + return False + except Exception as e: + print(f"Error activating premium: {e}") + self.session.rollback() + return False diff --git a/tg_bot/infrastructure/external/__init__.py b/tg_bot/infrastructure/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/infrastructure/external/deepseek_client.py b/tg_bot/infrastructure/external/deepseek_client.py new file mode 100644 index 0000000..68bc3aa --- /dev/null +++ b/tg_bot/infrastructure/external/deepseek_client.py @@ -0,0 +1,172 @@ +import json +from typing import Optional, AsyncIterator +import httpx +from tg_bot.config.settings import settings + + +class DeepSeekAPIError(Exception): + pass + + +class DeepSeekClient: + + def __init__(self, api_key: str | None = None, api_url: str | None = None): + self.api_key = api_key or settings.DEEPSEEK_API_KEY + self.api_url = api_url or settings.DEEPSEEK_API_URL + self.timeout = 60.0 + + def _get_headers(self) -> dict[str, str]: + if not self.api_key: + raise DeepSeekAPIError("API key not set") + + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}" + } + + async def chat_completion( + self, + messages: list[dict[str, str]], + model: str = "deepseek-chat", + temperature: float = 0.7, + max_tokens: Optional[int] = None, + stream: bool = False + ) -> dict: + if not self.api_key: + return { + "content": "API key not configured", + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } + } + + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": stream + } + + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + self.api_url, + headers=self._get_headers(), + json=payload + ) + response.raise_for_status() + + data = response.json() + + if "choices" in data and len(data["choices"]) > 0: + content = data["choices"][0]["message"]["content"] + else: + raise DeepSeekAPIError("Invalid response format") + + usage = data.get("usage", {}) + + return { + "content": content, + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0) + } + } + + except httpx.HTTPStatusError as e: + error_msg = f"API error: {e.response.status_code}" + try: + error_data = e.response.json() + if "error" in error_data: + error_msg = error_data['error'].get('message', error_msg) + except: + pass + raise DeepSeekAPIError(error_msg) from e + except httpx.RequestError as e: + raise DeepSeekAPIError(f"Connection error: {str(e)}") from e + except Exception as e: + raise DeepSeekAPIError(str(e)) from e + + async def stream_chat_completion( + self, + messages: list[dict[str, str]], + model: str = "deepseek-chat", + temperature: float = 0.7, + max_tokens: Optional[int] = None + ) -> AsyncIterator[str]: + if not self.api_key: + yield "API key not configured" + return + + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True + } + + if max_tokens is not None: + payload["max_tokens"] = max_tokens + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + async with client.stream( + "POST", + self.api_url, + headers=self._get_headers(), + json=payload + ) as response: + response.raise_for_status() + + async for line in response.aiter_lines(): + if not line.strip(): + continue + + if line.startswith("data: "): + line = line[6:] + + if line.strip() == "[DONE]": + break + + try: + data = json.loads(line) + + if "choices" in data and len(data["choices"]) > 0: + delta = data["choices"][0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + except json.JSONDecodeError: + continue + + except httpx.HTTPStatusError as e: + error_msg = f"API error: {e.response.status_code}" + try: + error_data = e.response.json() + if "error" in error_data: + error_msg = error_data['error'].get('message', error_msg) + except: + pass + raise DeepSeekAPIError(error_msg) from e + except httpx.RequestError as e: + raise DeepSeekAPIError(f"Connection error: {str(e)}") from e + except Exception as e: + raise DeepSeekAPIError(str(e)) from e + + async def health_check(self) -> bool: + if not self.api_key: + return False + + try: + test_messages = [{"role": "user", "content": "test"}] + await self.chat_completion(test_messages, max_tokens=1) + return True + except Exception: + return False + diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index 9f7a44b..55596c6 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -7,7 +7,8 @@ from tg_bot.config.settings import settings from tg_bot.infrastructure.telegram.handlers import ( start_handler, help_handler, - stats_handler + stats_handler, + question_handler ) logger = logging.getLogger(__name__) @@ -22,6 +23,7 @@ async def create_bot() -> tuple[Bot, Dispatcher]: dp.include_router(start_handler.router) dp.include_router(help_handler.router) dp.include_router(stats_handler.router) + dp.include_router(question_handler.router) return bot, dp diff --git a/tg_bot/infrastructure/telegram/handlers/question_handler.py b/tg_bot/infrastructure/telegram/handlers/question_handler.py new file mode 100644 index 0000000..b2b45f0 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/question_handler.py @@ -0,0 +1,306 @@ +from aiogram import Router, types +from aiogram.types import Message +from datetime import datetime +import aiohttp +from tg_bot.config.settings import settings +from tg_bot.infrastructure.database.database import SessionLocal +from tg_bot.infrastructure.database.models import UserModel +from tg_bot.application.services.rag_service import RAGService + +router = Router() +BACKEND_URL = "http://localhost:8001/api/v1" +rag_service = RAGService() + +@router.message() +async def handle_question(message: Message): + user_id = message.from_user.id + question_text = message.text.strip() + if question_text.startswith('/'): + return + + session = SessionLocal() + try: + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if not user: + user = UserModel( + telegram_id=str(user_id), + username=message.from_user.username or "", + first_name=message.from_user.first_name or "", + last_name=message.from_user.last_name or "" + ) + session.add(user) + session.commit() + + await ensure_user_in_backend(str(user_id), message.from_user) + + if user.is_premium: + await process_premium_question(message, user, question_text, session) + + elif user.questions_used < settings.FREE_QUESTIONS_LIMIT: + await process_free_question(message, user, question_text, session) + + else: + await handle_limit_exceeded(message, user) + + except Exception as e: + print(f"Error processing question: {e}") + await message.answer( + "Произошла ошибка. Попробуйте позже.", + parse_mode="HTML" + ) + finally: + session.close() + + +async def ensure_user_in_backend(telegram_id: str, telegram_user): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/users/telegram/{telegram_id}" + ) as response: + if response.status == 200: + return + + async with session.post( + f"{BACKEND_URL}/users", + json={"telegram_id": telegram_id, "role": "user"} + ) as create_response: + if create_response.status in [200, 201]: + print(f"Пользователь {telegram_id} создан в backend") + except Exception as e: + print(f"Error creating user in backend: {e}") + + +async def process_premium_question(message: Message, user: UserModel, question_text: str, session): + user.questions_used += 1 + session.commit() + + await message.bot.send_chat_action(message.chat.id, "typing") + + try: + rag_result = await rag_service.generate_answer_with_rag( + question_text, + str(message.from_user.id) + ) + + answer = rag_result.get("answer", "Извините, не удалось сгенерировать ответ.") + sources = rag_result.get("sources", []) + + await save_conversation_to_backend( + str(message.from_user.id), + question_text, + answer, + sources + ) + + response = ( + f"Ваш вопрос:\n" + f"{question_text[:200]}\n\n" + f"Ответ:\n{answer}\n\n" + ) + + if sources: + response += f"Источники из коллекций:\n" + collections_used = {} + for source in sources[:5]: + collection_name = source.get('collection', 'Неизвестно') + if collection_name not in collections_used: + collections_used[collection_name] = [] + collections_used[collection_name].append(source.get('title', 'Без названия')) + + for i, (collection_name, titles) in enumerate(collections_used.items(), 1): + response += f"{i}. Коллекция: {collection_name}\n" + for title in titles[:2]: + response += f" • {title}\n" + response += "\nИспользуйте /mycollections для просмотра всех коллекций\n\n" + + response += ( + f"Статус: Premium (вопросов безлимитно)\n" + f"Всего вопросов: {user.questions_used}" + ) + + except Exception as e: + print(f"Error generating answer: {e}") + response = ( + f"Ваш вопрос:\n" + f"{question_text[:200]}\n\n" + f"Ошибка при генерации ответа. Попробуйте позже.\n\n" + f"Статус: Premium\n" + f"Всего вопросов: {user.questions_used}" + ) + + await message.answer(response, parse_mode="HTML") + + +async def process_free_question(message: Message, user: UserModel, question_text: str, session): + user.questions_used += 1 + remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used + session.commit() + + await message.bot.send_chat_action(message.chat.id, "typing") + + try: + rag_result = await rag_service.generate_answer_with_rag( + question_text, + str(message.from_user.id) + ) + + answer = rag_result.get("answer", "Извините, не удалось сгенерировать ответ.") + sources = rag_result.get("sources", []) + + await save_conversation_to_backend( + str(message.from_user.id), + question_text, + answer, + sources + ) + + response = ( + f"Ваш вопрос:\n" + f"{question_text[:200]}\n\n" + f"Ответ:\n{answer}\n\n" + ) + + if sources: + response += f"Источники из коллекций:\n" + collections_used = {} + for source in sources[:5]: + collection_name = source.get('collection', 'Неизвестно') + if collection_name not in collections_used: + collections_used[collection_name] = [] + collections_used[collection_name].append(source.get('title', 'Без названия')) + + for i, (collection_name, titles) in enumerate(collections_used.items(), 1): + response += f"{i}. Коллекция: {collection_name}\n" + for title in titles[:2]: + response += f" • {title}\n" + response += "\nИспользуйте /mycollections для просмотра всех коллекций\n\n" + + response += ( + f"Статус: Бесплатный доступ\n" + f"Использовано вопросов: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n" + f"Осталось бесплатных: {remaining}\n\n" + ) + + if remaining <= 3 and remaining > 0: + response += f"Осталось мало вопросов! Для продолжения используйте /buy\n\n" + + response += f"Для безлимитного доступа: /buy" + + except Exception as e: + print(f"Error generating answer: {e}") + response = ( + f"Ваш вопрос:\n" + f"{question_text[:200]}\n\n" + f"Ошибка при генерации ответа. Попробуйте позже.\n\n" + f"Статус: Бесплатный доступ\n" + f"Использовано вопросов: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n" + f"Осталось бесплатных: {remaining}\n\n" + f"Для безлимитного доступа: /buy" + ) + + await message.answer(response, parse_mode="HTML") + + +async def save_conversation_to_backend(telegram_id: str, question: str, answer: str, sources: list): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/users/telegram/{telegram_id}" + ) as user_response: + if user_response.status != 200: + return + user_data = await user_response.json() + user_uuid = user_data.get("user_id") + + async with session.get( + f"{BACKEND_URL}/collections/", + headers={"X-Telegram-ID": telegram_id} + ) as collections_response: + collections = [] + if collections_response.status == 200: + collections = await collections_response.json() + + collection_id = None + if collections: + collection_id = collections[0].get("collection_id") + else: + async with session.post( + f"{BACKEND_URL}/collections", + json={ + "name": "Основная коллекция", + "description": "Коллекция по умолчанию", + "is_public": False + }, + headers={"X-Telegram-ID": telegram_id} + ) as create_collection_response: + if create_collection_response.status in [200, 201]: + collection_data = await create_collection_response.json() + collection_id = collection_data.get("collection_id") + + if not collection_id: + return + + async with session.post( + f"{BACKEND_URL}/conversations", + json={"collection_id": str(collection_id)}, + headers={"X-Telegram-ID": telegram_id} + ) as conversation_response: + if conversation_response.status not in [200, 201]: + return + conversation_data = await conversation_response.json() + conversation_id = conversation_data.get("conversation_id") + + if not conversation_id: + return + + await session.post( + f"{BACKEND_URL}/messages", + json={ + "conversation_id": str(conversation_id), + "content": question, + "role": "user" + }, + headers={"X-Telegram-ID": telegram_id} + ) + + await session.post( + f"{BACKEND_URL}/messages", + json={ + "conversation_id": str(conversation_id), + "content": answer, + "role": "assistant", + "sources": {"documents": sources} + }, + headers={"X-Telegram-ID": telegram_id} + ) + + except Exception as e: + print(f"Error saving conversation: {e}") + + +async def handle_limit_exceeded(message: Message, user: UserModel): + response = ( + f"Лимит бесплатных вопросов исчерпан!\n\n" + + f"Ваша статистика:\n" + f"• Использовано вопросов: {user.questions_used}\n" + f"• Бесплатный лимит: {settings.FREE_QUESTIONS_LIMIT}\n\n" + + f"Что делать дальше?\n" + f"1. Купите подписку командой /buy\n" + f"2. Получите неограниченный доступ к вопросам\n" + f"3. Продолжайте использовать бот без ограничений\n\n" + + f"Подписка включает:\n" + f"• Неограниченное количество вопросов\n" + f"• Приоритетную обработку\n" + f"• Доступ ко всем функциям\n\n" + + f"Нажмите /buy чтобы продолжить" + ) + + await message.answer(response, parse_mode="HTML") From fab9bd42cbe7376dad6a6a4a55c1b31e90162df5 Mon Sep 17 00:00:00 2001 From: polina Date: Mon, 22 Dec 2025 16:51:45 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20=D1=8D=D0=BA=D0=B2=D0=B0=D0=B9=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tg_bot/infrastructure/telegram/bot.py | 4 +- .../telegram/handlers/buy_handler.py | 283 ++++++++++++++++++ tg_bot/payment/__init__.py | 0 tg_bot/payment/webhooks/__init__.py | 0 tg_bot/payment/webhooks/handler.py | 70 +++++ tg_bot/payment/yookassa/__init__.py | 0 tg_bot/payment/yookassa/client.py | 55 ++++ 7 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 tg_bot/infrastructure/telegram/handlers/buy_handler.py create mode 100644 tg_bot/payment/__init__.py create mode 100644 tg_bot/payment/webhooks/__init__.py create mode 100644 tg_bot/payment/webhooks/handler.py create mode 100644 tg_bot/payment/yookassa/__init__.py create mode 100644 tg_bot/payment/yookassa/client.py diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index 55596c6..fee0fde 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -8,7 +8,8 @@ from tg_bot.infrastructure.telegram.handlers import ( start_handler, help_handler, stats_handler, - question_handler + question_handler, + buy_handler ) logger = logging.getLogger(__name__) @@ -24,6 +25,7 @@ async def create_bot() -> tuple[Bot, Dispatcher]: dp.include_router(help_handler.router) dp.include_router(stats_handler.router) dp.include_router(question_handler.router) + dp.include_router(buy_handler.router) return bot, dp diff --git a/tg_bot/infrastructure/telegram/handlers/buy_handler.py b/tg_bot/infrastructure/telegram/handlers/buy_handler.py new file mode 100644 index 0000000..444ffd9 --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/buy_handler.py @@ -0,0 +1,283 @@ +from aiogram import Router, types +from aiogram.filters import Command +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton +from decimal import Decimal +from tg_bot.config.settings import settings +from tg_bot.payment.yookassa.client import yookassa_client +from tg_bot.infrastructure.database.database import SessionLocal +from tg_bot.infrastructure.database.models import PaymentModel, UserModel +import uuid +from datetime import datetime, timedelta + +router = Router() + + +@router.message(Command("buy")) +async def cmd_buy(message: Message): + user_id = message.from_user.id + username = message.from_user.username or f"user_{user_id}" + + session = SessionLocal() + try: + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if user and user.is_premium and user.premium_until and user.premium_until > datetime.now(): + days_left = (user.premium_until - datetime.now()).days + await message.answer( + f"У вас уже есть активная подписка!\n\n" + f"• Статус: Premium активен\n" + f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" + f"• Осталось дней: {days_left}\n\n" + f"Новая подписка будет добавлена к текущей.", + parse_mode="HTML" + ) + finally: + session.close() + + await message.answer( + "*Создаю ссылку для оплаты...*\n\n" + "Пожалуйста, подождите несколько секунд.", + parse_mode="Markdown" + ) + + try: + payment_data = await yookassa_client.create_payment( + amount=Decimal(str(settings.PAYMENT_AMOUNT)), + description=f"Подписка VibeLawyerBot для @{username}", + user_id=user_id + ) + + session = SessionLocal() + try: + payment = PaymentModel( + payment_id=str(uuid.uuid4()), + user_id=user_id, + amount=str(settings.PAYMENT_AMOUNT), + currency="RUB", + status="pending", + yookassa_payment_id=payment_data["id"], + description="Оплата подписки VibeLawyerBot" + ) + session.add(payment) + session.commit() + print(f"Платёж сохранён в БД: {payment.payment_id}") + except Exception as e: + print(f"Ошибка сохранения платежа в БД: {e}") + session.rollback() + finally: + session.close() + + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Оплатить онлайн", + url=payment_data["confirmation_url"] + ) + ], + [ + InlineKeyboardButton( + text="Проверить статус оплаты", + callback_data=f"check_payment:{payment_data['id']}" + ) + ] + ] + ) + response_text = ( + f"Оплата подписки VibeLawyerBot\n\n" + f"Детали платежа:\n" + f"• Сумма: {settings.PAYMENT_AMOUNT} руб.\n" + f"• Описание: Подписка на 30 дней\n" + f"• ID платежа: {payment_data['id'][:20]}...\n\n" + f"Что даёт подписка:\n" + f"• Неограниченное число вопросов\n" + f"• Приоритетная обработка\n" + f"• Доступ ко всем функциям\n\n" + f"После оплаты доступ активируется автоматически в течение 1-2 минут." + ) + + await message.answer( + response_text, + parse_mode="HTML", + reply_markup=keyboard, + disable_web_page_preview=True + ) + await message.answer( + "Инструкция по оплате:\n\n" + "1. Нажмите кнопку 'Оплатить онлайн'\n" + "2. Введите данные банковской карты\n" + "3. Подтвердите оплату\n" + "4. После успешной оплаты нажмите 'Проверить статус оплаты'\n\n" + "Тестовые карты для проверки:\n" + "• 5555 5555 5555 4477 - успешная оплата\n" + " Срок: любой будущий (напр. 12/30)\n" + " CVV: любые 3 цифры (напр. 123)\n\n" + "Это тестовые карты, реальные деньги не списываются!", + parse_mode="HTML" + ) + + except Exception as e: + print(f"Ошибка создания платежа: {e}") + await message.answer( + "Произошла ошибка при создании платежа\n\n" + "Пожалуйста, попробуйте позже или обратитесь к администратору.\n\n" + f"Ошибка: {str(e)[:100]}", + parse_mode="HTML" + ) + + +@router.callback_query(lambda c: c.data.startswith("check_payment:")) +async def check_payment_status(callback_query: types.CallbackQuery): + yookassa_id = callback_query.data.split(":")[1] + user_id = callback_query.from_user.id + + await callback_query.answer("Проверяю статус оплаты...") + + try: + from yookassa import Payment as YooPayment + payment = YooPayment.find_one(yookassa_id) + + if payment.status == "succeeded": + session = SessionLocal() + try: + db_payment = session.query(PaymentModel).filter_by( + yookassa_payment_id=yookassa_id + ).first() + + if db_payment: + db_payment.status = "succeeded" + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if user: + user.is_premium = True + if user.premium_until and user.premium_until > datetime.now(): + user.premium_until = user.premium_until + timedelta(days=30) + else: + user.premium_until = datetime.now() + timedelta(days=30) + + session.commit() + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + await callback_query.message.answer( + "Оплата подтверждена!\n\n" + f"Ваш premium-доступ активирован до: " + f"{user.premium_until.strftime('%d.%m.%Y')}\n\n" + "Теперь вы можете:\n" + "• Задавать неограниченное количество вопросов\n" + "• Получать приоритетные ответы\n" + "• Использовать все функции бота\n\n" + "Спасибо за покупку!", + parse_mode="HTML" + ) + else: + await callback_query.message.answer( + "Платёж найден в ЮKассе, но не в нашей БД\n\n" + "Пожалуйста, обратитесь к администратору.", + parse_mode="HTML" + ) + finally: + session.close() + + elif payment.status == "pending": + await callback_query.message.answer( + "Платёж ещё не завершён\n\n" + "Если вы уже оплатили, пожалуйста, подождите 1-2 минуты " + "и проверьте статус снова.\n\n" + "Проверьте правильность данных карты:\n" + "• Срок действия должен быть будущим\n" + "• CVV - 3 цифры на обратной стороне карты", + parse_mode="HTML" + ) + else: + await callback_query.message.answer( + f"Статус платежа: {payment.status}\n\n" + "Попробуйте оплатить ещё раз или обратитесь в поддержку.\n\n" + "Для теста используйте карту:\n" + "5555 5555 5555 4477\n" + "Срок: 12/30, CVV: 123", + parse_mode="HTML" + ) + + except Exception as e: + print(f"Ошибка проверки статуса: {e}") + await callback_query.message.answer( + "Не удалось проверить статус платежа\n\n" + "Попробуйте позже или обратитесь к администратору.", + parse_mode="HTML" + ) + + +@router.message(Command("mypayments")) +async def cmd_my_payments(message: Message): + user_id = message.from_user.id + + session = SessionLocal() + try: + payments = session.query(PaymentModel).filter_by( + user_id=user_id + ).order_by(PaymentModel.created_at.desc()).limit(10).all() + + if not payments: + await message.answer( + "У вас пока нет платежей\n\n" + "Используйте команду /buy чтобы оформить подписку.", + parse_mode="HTML" + ) + return + + response = ["Ваши последние платежи:\n"] + + for i, payment in enumerate(payments, 1): + status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка" + response.append( + f"\n{i}. {payment.amount} руб. ({status_text})\n" + f"Статус: {payment.status}\n" + f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"ID: {payment.payment_id[:8]}..." + ) + + response.append("\n\nПолный доступ открывается после успешной оплаты") + + await message.answer( + "\n".join(response), + parse_mode="HTML" + ) + + finally: + session.close() + + +@router.message(Command("testcards")) +async def cmd_testcards(message: Message): + testcards_text = ( + f"Тестовые банковские карты для оплаты\n\n" + + f"Для тестирования оплаты используйте:\n\n" + + f"Карта для успешной оплаты:\n" + f"• Номер: 5555 5555 5555 4477\n" + f"• Срок действия: ЛЮБОЙ будущий (например: 12/30)\n" + f"• CVV код: ЛЮБЫЕ 3 цифры (например: 123)\n" + f"• Результат: Оплата пройдёт успешно\n\n" + + f"Карта для отказа в оплате:\n" + f"• Номер: 5555 5555 5555 4445\n" + f"• Срок действия: ЛЮБОЙ будущий\n" + f"• CVV код: ЛЮБЫЕ 3 цифры\n" + f"• Результат: Оплата будет отклонена\n\n" + + f"Важно:\n" + f"• Это тестовые карты, реальные деньги не списываются\n" + f"• Используются только для проверки работы оплаты\n" + f"• После успешной тестовой оплаты premium активируется\n\n" + + f"Для оплаты подписки используйте команду /buy" + ) + + await message.answer(testcards_text, parse_mode="HTML") diff --git a/tg_bot/payment/__init__.py b/tg_bot/payment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/payment/webhooks/__init__.py b/tg_bot/payment/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/payment/webhooks/handler.py b/tg_bot/payment/webhooks/handler.py new file mode 100644 index 0000000..6bf0c99 --- /dev/null +++ b/tg_bot/payment/webhooks/handler.py @@ -0,0 +1,70 @@ +import json +from fastapi import APIRouter, Request, HTTPException +from fastapi.responses import JSONResponse +import sys +import os +from datetime import datetime, timedelta + +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +router = APIRouter() + + +@router.post("/payment/webhook") +async def handle_yookassa_webhook(request: Request): + try: + data = await request.json() + event_type = data.get("event") + + print(f"Webhook received: {event_type}") + try: + from tg_bot.config.settings import settings + from tg_bot.domain.services.user_service import UserService + from tg_bot.infrastructure.database.database import SessionLocal + from tg_bot.infrastructure.database.models import UserModel + from aiogram import Bot + + session = SessionLocal() + if event_type == "payment.succeeded": + payment = data.get("object", {}) + user_id = payment.get("metadata", {}).get("user_id") + + if user_id: + user_service = UserService(session) + success = await user_service.activate_premium(int(user_id)) + if success: + print(f"Premium activated for user {user_id}") + + user = session.query(UserModel).filter_by( + telegram_id=str(user_id) + ).first() + + if user and settings.TELEGRAM_BOT_TOKEN: + try: + bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) + premium_until = user.premium_until or datetime.now() + timedelta(days=30) + + notification = ( + f"Оплата подтверждена!\n\n" + f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" + ) + + await bot.send_message( + chat_id=int(user_id), + text=notification, + parse_mode="HTML" + ) + print(f"Notification sent to user {user_id}") + await bot.session.close() + except Exception as e: + print(f"Error sending notification: {e}") + else: + print(f"User {user_id} not found") + session.close() + + except ImportError as e: + print(f"Import error: {e}") + return JSONResponse({"status": "ok", "message": "Webhook processed"}) + + except Exception as e: + print(f"Error processing webhook: {e}") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/tg_bot/payment/yookassa/__init__.py b/tg_bot/payment/yookassa/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tg_bot/payment/yookassa/client.py b/tg_bot/payment/yookassa/client.py new file mode 100644 index 0000000..3e3a09c --- /dev/null +++ b/tg_bot/payment/yookassa/client.py @@ -0,0 +1,55 @@ +from decimal import Decimal +import uuid +from typing import Dict, Any +from yookassa import Configuration, Payment as YooPayment +from tg_bot.config.settings import settings + + +class YookassaClient: + + def __init__(self): + Configuration.configure( + account_id=settings.YOOKASSA_SHOP_ID, + secret_key=settings.YOOKASSA_SECRET_KEY + ) + + async def create_payment( + self, + amount: Decimal, + description: str, + user_id: int + ) -> Dict[str, Any]: + try: + payment = YooPayment.create({ + "amount": { + "value": f"{amount:.2f}", + "currency": "RUB" + }, + "payment_method_data": { + "type": "bank_card" + }, + "confirmation": { + "type": "redirect", + "return_url": settings.YOOKASSA_RETURN_URL + }, + "capture": True, + "description": description, + "metadata": { + "user_id": str(user_id), + "telegram_payment": "true" + }, + "save_payment_method": False + }) + return { + "id": payment.id, + "status": payment.status, + "confirmation_url": payment.confirmation.confirmation_url, + "amount": payment.amount.value, + "description": payment.description, + "metadata": payment.metadata + } + except Exception as e: + print(f"Error creating payment: {e}") + raise + +yookassa_client = YookassaClient() From 56489de4f2af6a660a610bc049f9b4fba9571bb8 Mon Sep 17 00:00:00 2001 From: polina Date: Mon, 22 Dec 2025 18:45:43 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D1=84=D0=B8=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tg_bot/infrastructure/telegram/bot.py | 4 +- .../telegram/handlers/collection_handler.py | 183 ++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tg_bot/infrastructure/telegram/handlers/collection_handler.py diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index fee0fde..606c203 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -9,7 +9,8 @@ from tg_bot.infrastructure.telegram.handlers import ( help_handler, stats_handler, question_handler, - buy_handler + buy_handler, + collection_handler ) logger = logging.getLogger(__name__) @@ -26,6 +27,7 @@ async def create_bot() -> tuple[Bot, Dispatcher]: dp.include_router(stats_handler.router) dp.include_router(question_handler.router) dp.include_router(buy_handler.router) + dp.include_router(collection_handler.router) return bot, dp diff --git a/tg_bot/infrastructure/telegram/handlers/collection_handler.py b/tg_bot/infrastructure/telegram/handlers/collection_handler.py new file mode 100644 index 0000000..8f270ae --- /dev/null +++ b/tg_bot/infrastructure/telegram/handlers/collection_handler.py @@ -0,0 +1,183 @@ +from aiogram import Router +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery +from aiogram.filters import Command +import aiohttp + +router = Router() + +BACKEND_URL = "http://localhost:8001/api/v1" + + +async def get_user_collections(telegram_id: str): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/collections/", + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return [] + except Exception as e: + print(f"Error getting collections: {e}") + return [] + + +async def get_collection_documents(collection_id: str, telegram_id: str): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/documents/collection/{collection_id}", + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return [] + except Exception as e: + print(f"Error getting documents: {e}") + return [] + + +async def search_in_collection(collection_id: str, query: str, telegram_id: str): + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{BACKEND_URL}/documents/collection/{collection_id}", + params={"search": query}, + headers={"X-Telegram-ID": telegram_id} + ) as response: + if response.status == 200: + return await response.json() + return [] + except Exception as e: + print(f"Error searching: {e}") + return [] + + +@router.message(Command("mycollections")) +async def cmd_mycollections(message: Message): + telegram_id = str(message.from_user.id) + collections = await get_user_collections(telegram_id) + + if not collections: + await message.answer( + "У вас пока нет коллекций\n\n" + "Обратитесь к администратору для создания коллекций и добавления документов.", + parse_mode="HTML" + ) + return + + response = "Ваши коллекции документов:\n\n" + keyboard_buttons = [] + + for i, collection in enumerate(collections[:10], 1): + name = collection.get("name", "Без названия") + description = collection.get("description", "") + collection_id = collection.get("collection_id") + + response += f"{i}. {name}\n" + if description: + response += f" {description[:50]}...\n" + response += f" ID: {collection_id}\n\n" + + keyboard_buttons.append([ + InlineKeyboardButton( + text=f"{name}", + callback_data=f"collection:{collection_id}" + ) + ]) + + keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons) + + response += "Нажмите на коллекцию, чтобы посмотреть документы" + + await message.answer(response, parse_mode="HTML", reply_markup=keyboard) + + +@router.message(Command("search")) +async def cmd_search(message: Message): + parts = message.text.split(maxsplit=2) + if len(parts) < 3: + telegram_id = str(message.from_user.id) + collections = await get_user_collections(telegram_id) + + if not collections: + await message.answer( + "Использование: /search <collection_id> <запрос>\n\n" + "У вас пока нет коллекций. Обратитесь к администратору.", + parse_mode="HTML" + ) + return + + response = "Выберите коллекцию для поиска:\n\n" + response += "Использование: /search <collection_id> <запрос>\n\n" + response += "Доступные коллекции:\n" + for collection in collections[:5]: + name = collection.get("name", "Без названия") + collection_id = collection.get("collection_id") + response += f"• {name}\n {collection_id}\n\n" + response += "Пример: /search " + collections[0].get("collection_id", "")[:8] + "... Как оформить договор?" + + await message.answer(response, parse_mode="HTML") + return + + collection_id = parts[1] + query = parts[2] + telegram_id = str(message.from_user.id) + + await message.bot.send_chat_action(message.chat.id, "typing") + + results = await search_in_collection(collection_id, query, telegram_id) + + if not results: + await message.answer( + f"Ничего не найдено\n\n" + f"По запросу \"{query}\" в коллекции ничего не найдено.\n\n" + f"Попробуйте другой запрос или используйте /mycollections для просмотра доступных коллекций.", + parse_mode="HTML" + ) + return + + response = f"Результаты поиска: \"{query}\"\n\n" + for i, doc in enumerate(results[:5], 1): + title = doc.get("title", "Без названия") + content = doc.get("content", "")[:200] + response += f"{i}. {title}\n" + response += f" {content}...\n\n" + + await message.answer(response, parse_mode="HTML") + + +@router.callback_query(lambda c: c.data.startswith("collection:")) +async def show_collection_documents(callback: CallbackQuery): + collection_id = callback.data.split(":")[1] + telegram_id = str(callback.from_user.id) + + await callback.answer("Загружаю документы...") + + documents = await get_collection_documents(collection_id, telegram_id) + + if not documents: + await callback.message.answer( + f"Коллекция пуста\n\n" + f"В этой коллекции пока нет документов.\n" + f"Обратитесь к администратору для добавления документов.", + parse_mode="HTML" + ) + return + + response = f"Документы в коллекции:\n\n" + for i, doc in enumerate(documents[:10], 1): + title = doc.get("title", "Без названия") + content_preview = doc.get("content", "")[:100] + response += f"{i}. {title}\n" + if content_preview: + response += f" {content_preview}...\n" + response += "\n" + + if len(documents) > 10: + response += f"\nПоказано 10 из {len(documents)} документов" + + await callback.message.answer(response, parse_mode="HTML") + + From af78fc633f8ed0807868b9249bc19c6d66cbe61a Mon Sep 17 00:00:00 2001 From: polina Date: Mon, 22 Dec 2025 20:56:53 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 2 + tg_bot/domain/services/user_service.py | 52 ++++- tg_bot/infrastructure/database/database.py | 18 +- tg_bot/infrastructure/telegram/bot.py | 4 +- .../telegram/handlers/buy_handler.py | 213 +++++++++--------- .../telegram/handlers/question_handler.py | 74 +++--- .../telegram/handlers/start_handler.py | 45 ++-- .../telegram/handlers/stats_handler.py | 86 ++++--- tg_bot/payment/webhooks/handler.py | 67 +++--- 9 files changed, 289 insertions(+), 272 deletions(-) diff --git a/requirements.txt b/requirements.txt index 0c4aad2..502efcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,8 @@ pydantic-settings>=2.1.0 python-dotenv>=1.0.0 aiogram>=3.10.0 sqlalchemy>=2.0.0 +aiosqlite>=0.19.0 +httpx>=0.25.0 yookassa>=2.4.0 fastapi>=0.104.0 uvicorn>=0.24.0 diff --git a/tg_bot/domain/services/user_service.py b/tg_bot/domain/services/user_service.py index 17d4e33..77c5dbc 100644 --- a/tg_bot/domain/services/user_service.py +++ b/tg_bot/domain/services/user_service.py @@ -1,29 +1,67 @@ -from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from datetime import datetime, timedelta +from typing import Optional from tg_bot.infrastructure.database.models import UserModel class UserService: - def __init__(self, session: Session): + def __init__(self, session: AsyncSession): self.session = session + async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[UserModel]: + result = await self.session.execute( + select(UserModel).filter_by(telegram_id=str(telegram_id)) + ) + return result.scalar_one_or_none() + + async def get_or_create_user( + self, + telegram_id: int, + username: str = "", + first_name: str = "", + last_name: str = "" + ) -> UserModel: + user = await self.get_user_by_telegram_id(telegram_id) + if not user: + user = UserModel( + telegram_id=str(telegram_id), + username=username, + first_name=first_name, + last_name=last_name + ) + self.session.add(user) + await self.session.commit() + else: + user.username = username + user.first_name = first_name + user.last_name = last_name + await self.session.commit() + return user + + async def update_user_questions(self, telegram_id: int) -> bool: + user = await self.get_user_by_telegram_id(telegram_id) + if user: + user.questions_used += 1 + await self.session.commit() + return True + return False + async def activate_premium(self, telegram_id: int) -> bool: try: - user = self.session.query(UserModel) \ - .filter(UserModel.telegram_id == str(telegram_id)) \ - .first() + user = await self.get_user_by_telegram_id(telegram_id) if user: user.is_premium = True if user.premium_until and user.premium_until > datetime.now(): user.premium_until = user.premium_until + timedelta(days=30) else: user.premium_until = datetime.now() + timedelta(days=30) - self.session.commit() + await self.session.commit() return True else: return False except Exception as e: print(f"Error activating premium: {e}") - self.session.rollback() + await self.session.rollback() return False diff --git a/tg_bot/infrastructure/database/database.py b/tg_bot/infrastructure/database/database.py index 8044e09..f49a009 100644 --- a/tg_bot/infrastructure/database/database.py +++ b/tg_bot/infrastructure/database/database.py @@ -1,15 +1,19 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from tg_bot.config.settings import settings -engine = create_engine( - settings.DATABASE_URL, +database_url = settings.DATABASE_URL +if database_url.startswith("sqlite:///"): + database_url = database_url.replace("sqlite:///", "sqlite+aiosqlite:///") + +engine = create_async_engine( + database_url, echo=settings.DEBUG ) -SessionLocal = sessionmaker(bind=engine) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) -def create_tables(): +async def create_tables(): from .models import Base - Base.metadata.create_all(bind=engine) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) print(f"Таблицы созданы: {settings.DATABASE_URL}") \ No newline at end of file diff --git a/tg_bot/infrastructure/telegram/bot.py b/tg_bot/infrastructure/telegram/bot.py index 606c203..3490201 100644 --- a/tg_bot/infrastructure/telegram/bot.py +++ b/tg_bot/infrastructure/telegram/bot.py @@ -32,6 +32,7 @@ async def create_bot() -> tuple[Bot, Dispatcher]: async def start_bot(): + bot = None try: bot, dp = await create_bot() @@ -54,4 +55,5 @@ async def start_bot(): logger.error(f"Ошибка запуска: {e}") raise finally: - await bot.session.close() \ No newline at end of file + if bot: + await bot.session.close() \ No newline at end of file diff --git a/tg_bot/infrastructure/telegram/handlers/buy_handler.py b/tg_bot/infrastructure/telegram/handlers/buy_handler.py index 444ffd9..0411d00 100644 --- a/tg_bot/infrastructure/telegram/handlers/buy_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/buy_handler.py @@ -4,8 +4,10 @@ from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from decimal import Decimal from tg_bot.config.settings import settings from tg_bot.payment.yookassa.client import yookassa_client -from tg_bot.infrastructure.database.database import SessionLocal -from tg_bot.infrastructure.database.models import PaymentModel, UserModel +from tg_bot.infrastructure.database.database import AsyncSessionLocal +from tg_bot.infrastructure.database.models import PaymentModel +from tg_bot.domain.services.user_service import UserService +from sqlalchemy import select import uuid from datetime import datetime, timedelta @@ -17,24 +19,23 @@ async def cmd_buy(message: Message): user_id = message.from_user.id username = message.from_user.username or f"user_{user_id}" - session = SessionLocal() - try: - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() + async with AsyncSessionLocal() as session: + try: + user_service = UserService(session) + user = await user_service.get_user_by_telegram_id(user_id) - if user and user.is_premium and user.premium_until and user.premium_until > datetime.now(): - days_left = (user.premium_until - datetime.now()).days - await message.answer( - f"У вас уже есть активная подписка!\n\n" - f"• Статус: Premium активен\n" - f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" - f"• Осталось дней: {days_left}\n\n" - f"Новая подписка будет добавлена к текущей.", - parse_mode="HTML" - ) - finally: - session.close() + if user and user.is_premium and user.premium_until and user.premium_until > datetime.now(): + days_left = (user.premium_until - datetime.now()).days + await message.answer( + f"У вас уже есть активная подписка!\n\n" + f"• Статус: Premium активен\n" + f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" + f"• Осталось дней: {days_left}\n\n" + f"Новая подписка будет добавлена к текущей.", + parse_mode="HTML" + ) + except Exception: + pass await message.answer( "*Создаю ссылку для оплаты...*\n\n" @@ -49,25 +50,23 @@ async def cmd_buy(message: Message): user_id=user_id ) - session = SessionLocal() - try: - payment = PaymentModel( - payment_id=str(uuid.uuid4()), - user_id=user_id, - amount=str(settings.PAYMENT_AMOUNT), - currency="RUB", - status="pending", - yookassa_payment_id=payment_data["id"], - description="Оплата подписки VibeLawyerBot" - ) - session.add(payment) - session.commit() - print(f"Платёж сохранён в БД: {payment.payment_id}") - except Exception as e: - print(f"Ошибка сохранения платежа в БД: {e}") - session.rollback() - finally: - session.close() + async with AsyncSessionLocal() as session: + try: + payment = PaymentModel( + payment_id=str(uuid.uuid4()), + user_id=user_id, + amount=str(settings.PAYMENT_AMOUNT), + currency="RUB", + status="pending", + yookassa_payment_id=payment_data["id"], + description="Оплата подписки VibeLawyerBot" + ) + session.add(payment) + await session.commit() + print(f"Платёж сохранён в БД: {payment.payment_id}") + except Exception as e: + print(f"Ошибка сохранения платежа в БД: {e}") + await session.rollback() keyboard = InlineKeyboardMarkup( inline_keyboard=[ @@ -140,49 +139,42 @@ async def check_payment_status(callback_query: types.CallbackQuery): payment = YooPayment.find_one(yookassa_id) if payment.status == "succeeded": - session = SessionLocal() - try: - db_payment = session.query(PaymentModel).filter_by( - yookassa_payment_id=yookassa_id - ).first() - - if db_payment: - db_payment.status = "succeeded" - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() - - if user: - user.is_premium = True - if user.premium_until and user.premium_until > datetime.now(): - user.premium_until = user.premium_until + timedelta(days=30) - else: - user.premium_until = datetime.now() + timedelta(days=30) - - session.commit() - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() - - await callback_query.message.answer( - "Оплата подтверждена!\n\n" - f"Ваш premium-доступ активирован до: " - f"{user.premium_until.strftime('%d.%m.%Y')}\n\n" - "Теперь вы можете:\n" - "• Задавать неограниченное количество вопросов\n" - "• Получать приоритетные ответы\n" - "• Использовать все функции бота\n\n" - "Спасибо за покупку!", - parse_mode="HTML" + async with AsyncSessionLocal() as session: + try: + result = await session.execute( + select(PaymentModel).filter_by(yookassa_payment_id=yookassa_id) ) - else: - await callback_query.message.answer( - "Платёж найден в ЮKассе, но не в нашей БД\n\n" - "Пожалуйста, обратитесь к администратору.", - parse_mode="HTML" - ) - finally: - session.close() + db_payment = result.scalar_one_or_none() + + if db_payment: + db_payment.status = "succeeded" + user_service = UserService(session) + success = await user_service.activate_premium(user_id) + if success: + user = await user_service.get_user_by_telegram_id(user_id) + await session.commit() + if not user: + user = await user_service.get_user_by_telegram_id(user_id) + + await callback_query.message.answer( + "Оплата подтверждена!\n\n" + f"Ваш premium-доступ активирован до: " + f"{user.premium_until.strftime('%d.%m.%Y')}\n\n" + "Теперь вы можете:\n" + "• Задавать неограниченное количество вопросов\n" + "• Получать приоритетные ответы\n" + "• Использовать все функции бота\n\n" + "Спасибо за покупку!", + parse_mode="HTML" + ) + else: + await callback_query.message.answer( + "Платёж найден в ЮKассе, но не в нашей БД\n\n" + "Пожалуйста, обратитесь к администратору.", + parse_mode="HTML" + ) + except Exception as e: + print(f"Ошибка обработки платежа: {e}") elif payment.status == "pending": await callback_query.message.answer( @@ -212,45 +204,44 @@ async def check_payment_status(callback_query: types.CallbackQuery): parse_mode="HTML" ) - @router.message(Command("mypayments")) async def cmd_my_payments(message: Message): user_id = message.from_user.id - session = SessionLocal() - try: - payments = session.query(PaymentModel).filter_by( - user_id=user_id - ).order_by(PaymentModel.created_at.desc()).limit(10).all() + async with AsyncSessionLocal() as session: + try: + result = await session.execute( + select(PaymentModel).filter_by(user_id=user_id).order_by(PaymentModel.created_at.desc()).limit(10) + ) + payments = result.scalars().all() + + if not payments: + await message.answer( + "У вас пока нет платежей\n\n" + "Используйте команду /buy чтобы оформить подписку.", + parse_mode="HTML" + ) + return + + response = ["Ваши последние платежи:\n"] + + for i, payment in enumerate(payments, 1): + status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка" + response.append( + f"\n{i}. {payment.amount} руб. ({status_text})\n" + f"Статус: {payment.status}\n" + f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n" + f"ID: {payment.payment_id[:8]}..." + ) + + response.append("\n\nПолный доступ открывается после успешной оплаты") - if not payments: await message.answer( - "У вас пока нет платежей\n\n" - "Используйте команду /buy чтобы оформить подписку.", + "\n".join(response), parse_mode="HTML" ) - return - - response = ["Ваши последние платежи:\n"] - - for i, payment in enumerate(payments, 1): - status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка" - response.append( - f"\n{i}. {payment.amount} руб. ({status_text})\n" - f"Статус: {payment.status}\n" - f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n" - f"ID: {payment.payment_id[:8]}..." - ) - - response.append("\n\nПолный доступ открывается после успешной оплаты") - - await message.answer( - "\n".join(response), - parse_mode="HTML" - ) - - finally: - session.close() + except Exception as e: + print(f"Ошибка получения платежей: {e}") @router.message(Command("testcards")) diff --git a/tg_bot/infrastructure/telegram/handlers/question_handler.py b/tg_bot/infrastructure/telegram/handlers/question_handler.py index b2b45f0..540aac7 100644 --- a/tg_bot/infrastructure/telegram/handlers/question_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/question_handler.py @@ -3,8 +3,9 @@ from aiogram.types import Message from datetime import datetime import aiohttp from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import SessionLocal +from tg_bot.infrastructure.database.database import AsyncSessionLocal from tg_bot.infrastructure.database.models import UserModel +from tg_bot.domain.services.user_service import UserService from tg_bot.application.services.rag_service import RAGService router = Router() @@ -18,41 +19,35 @@ async def handle_question(message: Message): if question_text.startswith('/'): return - session = SessionLocal() - try: - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() + async with AsyncSessionLocal() as session: + try: + user_service = UserService(session) + user = await user_service.get_user_by_telegram_id(user_id) - if not user: - user = UserModel( - telegram_id=str(user_id), - username=message.from_user.username or "", - first_name=message.from_user.first_name or "", - last_name=message.from_user.last_name or "" + if not user: + user = await user_service.get_or_create_user( + user_id, + message.from_user.username or "", + message.from_user.first_name or "", + message.from_user.last_name or "" + ) + await ensure_user_in_backend(str(user_id), message.from_user) + + if user.is_premium: + await process_premium_question(message, user, question_text, user_service) + + elif user.questions_used < settings.FREE_QUESTIONS_LIMIT: + await process_free_question(message, user, question_text, user_service) + + else: + await handle_limit_exceeded(message, user) + + except Exception as e: + print(f"Error processing question: {e}") + await message.answer( + "Произошла ошибка. Попробуйте позже.", + parse_mode="HTML" ) - session.add(user) - session.commit() - - await ensure_user_in_backend(str(user_id), message.from_user) - - if user.is_premium: - await process_premium_question(message, user, question_text, session) - - elif user.questions_used < settings.FREE_QUESTIONS_LIMIT: - await process_free_question(message, user, question_text, session) - - else: - await handle_limit_exceeded(message, user) - - except Exception as e: - print(f"Error processing question: {e}") - await message.answer( - "Произошла ошибка. Попробуйте позже.", - parse_mode="HTML" - ) - finally: - session.close() async def ensure_user_in_backend(telegram_id: str, telegram_user): @@ -74,9 +69,8 @@ async def ensure_user_in_backend(telegram_id: str, telegram_user): print(f"Error creating user in backend: {e}") -async def process_premium_question(message: Message, user: UserModel, question_text: str, session): - user.questions_used += 1 - session.commit() +async def process_premium_question(message: Message, user: UserModel, question_text: str, user_service: UserService): + await user_service.update_user_questions(user.telegram_id) await message.bot.send_chat_action(message.chat.id, "typing") @@ -135,10 +129,10 @@ async def process_premium_question(message: Message, user: UserModel, question_t await message.answer(response, parse_mode="HTML") -async def process_free_question(message: Message, user: UserModel, question_text: str, session): - user.questions_used += 1 +async def process_free_question(message: Message, user: UserModel, question_text: str, user_service: UserService): + await user_service.update_user_questions(user.telegram_id) + user = await user_service.get_user_by_telegram_id(user.telegram_id) remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used - session.commit() await message.bot.send_chat_action(message.chat.id, "typing") diff --git a/tg_bot/infrastructure/telegram/handlers/start_handler.py b/tg_bot/infrastructure/telegram/handlers/start_handler.py index 71be4c8..8bc3a36 100644 --- a/tg_bot/infrastructure/telegram/handlers/start_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/start_handler.py @@ -4,8 +4,8 @@ from aiogram.types import Message from datetime import datetime from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import SessionLocal -from tg_bot.infrastructure.database.models import UserModel +from tg_bot.infrastructure.database.database import AsyncSessionLocal +from tg_bot.domain.services.user_service import UserService router = Router() @@ -16,33 +16,22 @@ async def cmd_start(message: Message): username = message.from_user.username or "" first_name = message.from_user.first_name or "" last_name = message.from_user.last_name or "" - session = SessionLocal() - try: - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() - - if not user: - user = UserModel( - telegram_id=str(user_id), - username=username, - first_name=first_name, - last_name=last_name + async with AsyncSessionLocal() as session: + try: + user_service = UserService(session) + existing_user = await user_service.get_user_by_telegram_id(user_id) + user = await user_service.get_or_create_user( + user_id, + username, + first_name, + last_name ) - session.add(user) - session.commit() - print(f"Новый пользователь: {user_id}") - else: - user.username = username - user.first_name = first_name - user.last_name = last_name - session.commit() - - except Exception as e: - print(f"Ошибка сохранения пользователя: {e}") - session.rollback() - finally: - session.close() + if not existing_user: + print(f"Новый пользователь: {user_id}") + + except Exception as e: + print(f"Ошибка сохранения пользователя: {e}") + await session.rollback() welcome_text = ( f"Привет, {first_name}!\n\n" f"Я VibeLawyerBot - ваш помощник в юридических вопросах.\n\n" diff --git a/tg_bot/infrastructure/telegram/handlers/stats_handler.py b/tg_bot/infrastructure/telegram/handlers/stats_handler.py index 3ce7534..58adfdc 100644 --- a/tg_bot/infrastructure/telegram/handlers/stats_handler.py +++ b/tg_bot/infrastructure/telegram/handlers/stats_handler.py @@ -4,8 +4,8 @@ from aiogram.filters import Command from aiogram.types import Message from tg_bot.config.settings import settings -from tg_bot.infrastructure.database.database import SessionLocal -from tg_bot.infrastructure.database.models import UserModel +from tg_bot.infrastructure.database.database import AsyncSessionLocal +from tg_bot.domain.services.user_service import UserService router = Router() @@ -14,52 +14,48 @@ router = Router() async def cmd_stats(message: Message): user_id = message.from_user.id - session = SessionLocal() - try: - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() + async with AsyncSessionLocal() as session: + try: + user_service = UserService(session) + user = await user_service.get_user_by_telegram_id(user_id) - if user: - stats_text = ( - f"Ваша статистика\n\n" - f"Основное:\n" - f"• ID: {user_id}\n" - f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" - f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\n" - ) - - if user.is_premium: - stats_text += ( - f"Premium статус:\n" - f"• Активен до: {user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n" - f"• Лимит вопросов: безлимитно\n\n" + if user: + stats_text = ( + f"Ваша статистика\n\n" + f"Основное:\n" + f"• ID: {user_id}\n" + f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" + f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\n" ) + + if user.is_premium: + stats_text += ( + f"Premium статус:\n" + f"• Активен до: {user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}\n" + f"• Лимит вопросов: безлимитно\n\n" + ) + else: + remaining = max(0, settings.FREE_QUESTIONS_LIMIT - user.questions_used) + stats_text += ( + f"Бесплатный доступ:\n" + f"• Осталось вопросов: {remaining}\n" + f"• Для безлимита: /buy\n\n" + ) else: - remaining = max(0, settings.FREE_QUESTIONS_LIMIT - user.questions_used) - stats_text += ( - f"Бесплатный доступ:\n" - f"• Осталось вопросов: {remaining}\n" - f"• Для безлимита: /buy\n\n" + stats_text = ( + f"Добро пожаловать!\n\n" + f"Вы новый пользователь.\n" + f"• Ваш ID: {user_id}\n" + f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n" + f"• Для начала работы просто задайте вопрос!\n\n" + f"Используйте /buy для получения полного доступа" ) - else: - stats_text = ( - f"Добро пожаловать!\n\n" - f"Вы новый пользователь.\n" - f"• Ваш ID: {user_id}\n" - f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n" - f"• Для начала работы просто задайте вопрос!\n\n" - f"Используйте /buy для получения полного доступа" + await message.answer(stats_text, parse_mode="HTML") + + except Exception as e: + await message.answer( + f"Ошибка получения статистики\n\n" + f"Попробуйте позже.", + parse_mode="HTML" ) - - await message.answer(stats_text, parse_mode="HTML") - - except Exception as e: - await message.answer( - f"Ошибка получения статистики\n\n" - f"Попробуйте позже.", - parse_mode="HTML" - ) - finally: - session.close() diff --git a/tg_bot/payment/webhooks/handler.py b/tg_bot/payment/webhooks/handler.py index 6bf0c99..c0cb1eb 100644 --- a/tg_bot/payment/webhooks/handler.py +++ b/tg_bot/payment/webhooks/handler.py @@ -19,47 +19,48 @@ async def handle_yookassa_webhook(request: Request): try: from tg_bot.config.settings import settings from tg_bot.domain.services.user_service import UserService - from tg_bot.infrastructure.database.database import SessionLocal + from tg_bot.infrastructure.database.database import AsyncSessionLocal from tg_bot.infrastructure.database.models import UserModel + from sqlalchemy import select from aiogram import Bot - session = SessionLocal() if event_type == "payment.succeeded": payment = data.get("object", {}) user_id = payment.get("metadata", {}).get("user_id") if user_id: - user_service = UserService(session) - success = await user_service.activate_premium(int(user_id)) - if success: - print(f"Premium activated for user {user_id}") - - user = session.query(UserModel).filter_by( - telegram_id=str(user_id) - ).first() - - if user and settings.TELEGRAM_BOT_TOKEN: - try: - bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) - premium_until = user.premium_until or datetime.now() + timedelta(days=30) - - notification = ( - f"Оплата подтверждена!\n\n" - f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" - ) - - await bot.send_message( - chat_id=int(user_id), - text=notification, - parse_mode="HTML" - ) - print(f"Notification sent to user {user_id}") - await bot.session.close() - except Exception as e: - print(f"Error sending notification: {e}") - else: - print(f"User {user_id} not found") - session.close() + async with AsyncSessionLocal() as session: + user_service = UserService(session) + success = await user_service.activate_premium(int(user_id)) + if success: + print(f"Premium activated for user {user_id}") + + result = await session.execute( + select(UserModel).filter_by(telegram_id=str(user_id)) + ) + user = result.scalar_one_or_none() + + if user and settings.TELEGRAM_BOT_TOKEN: + try: + bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) + premium_until = user.premium_until or datetime.now() + timedelta(days=30) + + notification = ( + f"Оплата подтверждена!\n\n" + f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" + ) + + await bot.send_message( + chat_id=int(user_id), + text=notification, + parse_mode="HTML" + ) + print(f"Notification sent to user {user_id}") + await bot.session.close() + except Exception as e: + print(f"Error sending notification: {e}") + else: + print(f"User {user_id} not found") except ImportError as e: print(f"Import error: {e}")