Merge remote-tracking branch 'origin/main' into luluka
This commit is contained in:
commit
6c730918ec
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
.env
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
93
create_database.py
Normal file
93
create_database.py
Normal file
@ -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()
|
||||||
|
|
||||||
31
create_tables.py
Executable file
31
create_tables.py
Executable file
@ -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)
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
pydantic>=2.5.0
|
||||||
|
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
|
||||||
|
python-multipart>=0.0.6
|
||||||
0
tg_bot/application/__init__.py
Normal file
0
tg_bot/application/__init__.py
Normal file
0
tg_bot/application/services/__init__.py
Normal file
0
tg_bot/application/services/__init__.py
Normal file
139
tg_bot/application/services/rag_service.py
Normal file
139
tg_bot/application/services/rag_service.py
Normal file
@ -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": {}
|
||||||
|
}
|
||||||
0
tg_bot/config/__init__.py
Normal file
0
tg_bot/config/__init__.py
Normal file
0
tg_bot/config/constants.py
Normal file
0
tg_bot/config/constants.py
Normal file
44
tg_bot/config/settings.py
Normal file
44
tg_bot/config/settings.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
DEEPSEEK_API_KEY: Optional[str] = None
|
||||||
|
DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions"
|
||||||
|
|
||||||
|
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()
|
||||||
0
tg_bot/domain/__init__.py
Normal file
0
tg_bot/domain/__init__.py
Normal file
0
tg_bot/domain/services/__init__.py
Normal file
0
tg_bot/domain/services/__init__.py
Normal file
67
tg_bot/domain/services/user_service.py
Normal file
67
tg_bot/domain/services/user_service.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
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: 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 = 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)
|
||||||
|
await self.session.commit()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error activating premium: {e}")
|
||||||
|
await self.session.rollback()
|
||||||
|
return False
|
||||||
0
tg_bot/infrastructure/database/__init__.py
Normal file
0
tg_bot/infrastructure/database/__init__.py
Normal file
19
tg_bot/infrastructure/database/database.py
Normal file
19
tg_bot/infrastructure/database/database.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
||||||
|
from tg_bot.config.settings import settings
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
async def create_tables():
|
||||||
|
from .models import Base
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
print(f"Таблицы созданы: {settings.DATABASE_URL}")
|
||||||
39
tg_bot/infrastructure/database/models.py
Normal file
39
tg_bot/infrastructure/database/models.py
Normal file
@ -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"<Payment(user_id={self.user_id}, amount={self.amount}, status={self.status})>"
|
||||||
0
tg_bot/infrastructure/external/__init__.py
vendored
Normal file
0
tg_bot/infrastructure/external/__init__.py
vendored
Normal file
172
tg_bot/infrastructure/external/deepseek_client.py
vendored
Normal file
172
tg_bot/infrastructure/external/deepseek_client.py
vendored
Normal file
@ -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
|
||||||
|
|
||||||
0
tg_bot/infrastructure/telegram/__init__.py
Normal file
0
tg_bot/infrastructure/telegram/__init__.py
Normal file
59
tg_bot/infrastructure/telegram/bot.py
Normal file
59
tg_bot/infrastructure/telegram/bot.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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,
|
||||||
|
question_handler,
|
||||||
|
buy_handler,
|
||||||
|
collection_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)
|
||||||
|
dp.include_router(question_handler.router)
|
||||||
|
dp.include_router(buy_handler.router)
|
||||||
|
dp.include_router(collection_handler.router)
|
||||||
|
return bot, dp
|
||||||
|
|
||||||
|
|
||||||
|
async def start_bot():
|
||||||
|
bot = None
|
||||||
|
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:
|
||||||
|
if bot:
|
||||||
|
await bot.session.close()
|
||||||
0
tg_bot/infrastructure/telegram/handlers/__init__.py
Normal file
0
tg_bot/infrastructure/telegram/handlers/__init__.py
Normal file
274
tg_bot/infrastructure/telegram/handlers/buy_handler.py
Normal file
274
tg_bot/infrastructure/telegram/handlers/buy_handler.py
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
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 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
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
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"<b>У вас уже есть активная подписка!</b>\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"
|
||||||
|
"Пожалуйста, подождите несколько секунд.",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Оплатить онлайн",
|
||||||
|
url=payment_data["confirmation_url"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Проверить статус оплаты",
|
||||||
|
callback_data=f"check_payment:{payment_data['id']}"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
response_text = (
|
||||||
|
f"<b>Оплата подписки VibeLawyerBot</b>\n\n"
|
||||||
|
f"<b>Детали платежа:</b>\n"
|
||||||
|
f"• Сумма: {settings.PAYMENT_AMOUNT} руб.\n"
|
||||||
|
f"• Описание: Подписка на 30 дней\n"
|
||||||
|
f"• ID платежа: <code>{payment_data['id'][:20]}...</code>\n\n"
|
||||||
|
f"<b>Что даёт подписка:</b>\n"
|
||||||
|
f"• Неограниченное число вопросов\n"
|
||||||
|
f"• Приоритетная обработка\n"
|
||||||
|
f"• Доступ ко всем функциям\n\n"
|
||||||
|
f"<i>После оплаты доступ активируется автоматически в течение 1-2 минут.</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
response_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=keyboard,
|
||||||
|
disable_web_page_preview=True
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
"<b>Инструкция по оплате:</b>\n\n"
|
||||||
|
"1. Нажмите кнопку 'Оплатить онлайн'\n"
|
||||||
|
"2. Введите данные банковской карты\n"
|
||||||
|
"3. Подтвердите оплату\n"
|
||||||
|
"4. После успешной оплаты нажмите 'Проверить статус оплаты'\n\n"
|
||||||
|
"<b>Тестовые карты для проверки:</b>\n"
|
||||||
|
"• <code>5555 5555 5555 4477</code> - успешная оплата\n"
|
||||||
|
" Срок: <b>любой будущий</b> (напр. 12/30)\n"
|
||||||
|
" CVV: <b>любые 3 цифры</b> (напр. 123)\n\n"
|
||||||
|
"<i>Это тестовые карты, реальные деньги не списываются!</i>",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка создания платежа: {e}")
|
||||||
|
await message.answer(
|
||||||
|
"<b>Произошла ошибка при создании платежа</b>\n\n"
|
||||||
|
"Пожалуйста, попробуйте позже или обратитесь к администратору.\n\n"
|
||||||
|
f"<code>Ошибка: {str(e)[:100]}</code>",
|
||||||
|
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":
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
result = await session.execute(
|
||||||
|
select(PaymentModel).filter_by(yookassa_payment_id=yookassa_id)
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
"<b>Оплата подтверждена!</b>\n\n"
|
||||||
|
f"Ваш premium-доступ активирован до: "
|
||||||
|
f"<b>{user.premium_until.strftime('%d.%m.%Y')}</b>\n\n"
|
||||||
|
"Теперь вы можете:\n"
|
||||||
|
"• Задавать неограниченное количество вопросов\n"
|
||||||
|
"• Получать приоритетные ответы\n"
|
||||||
|
"• Использовать все функции бота\n\n"
|
||||||
|
"<i>Спасибо за покупку!</i>",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"<b>Платёж найден в ЮKассе, но не в нашей БД</b>\n\n"
|
||||||
|
"Пожалуйста, обратитесь к администратору.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка обработки платежа: {e}")
|
||||||
|
|
||||||
|
elif payment.status == "pending":
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"<b>Платёж ещё не завершён</b>\n\n"
|
||||||
|
"Если вы уже оплатили, пожалуйста, подождите 1-2 минуты "
|
||||||
|
"и проверьте статус снова.\n\n"
|
||||||
|
"<i>Проверьте правильность данных карты:</i>\n"
|
||||||
|
"• Срок действия должен быть <b>будущим</b>\n"
|
||||||
|
"• CVV - <b>3 цифры</b> на обратной стороне карты",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await callback_query.message.answer(
|
||||||
|
f"<b>Статус платежа: {payment.status}</b>\n\n"
|
||||||
|
"Попробуйте оплатить ещё раз или обратитесь в поддержку.\n\n"
|
||||||
|
"<i>Для теста используйте карту:</i>\n"
|
||||||
|
"<code>5555 5555 5555 4477</code>\n"
|
||||||
|
"Срок: <b>12/30</b>, CVV: <b>123</b>",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка проверки статуса: {e}")
|
||||||
|
await callback_query.message.answer(
|
||||||
|
"<b>Не удалось проверить статус платежа</b>\n\n"
|
||||||
|
"Попробуйте позже или обратитесь к администратору.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.message(Command("mypayments"))
|
||||||
|
async def cmd_my_payments(message: Message):
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
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(
|
||||||
|
"<b>У вас пока нет платежей</b>\n\n"
|
||||||
|
"Используйте команду /buy чтобы оформить подписку.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = ["<b>Ваши последние платежи:</b>\n"]
|
||||||
|
|
||||||
|
for i, payment in enumerate(payments, 1):
|
||||||
|
status_text = "Успешно" if payment.status == "succeeded" else "Ожидание" if payment.status == "pending" else "Ошибка"
|
||||||
|
response.append(
|
||||||
|
f"\n<b>{i}. {payment.amount} руб. ({status_text})</b>\n"
|
||||||
|
f"Статус: {payment.status}\n"
|
||||||
|
f"Дата: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||||
|
f"ID: <code>{payment.payment_id[:8]}...</code>"
|
||||||
|
)
|
||||||
|
|
||||||
|
response.append("\n\n<i>Полный доступ открывается после успешной оплаты</i>")
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
"\n".join(response),
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка получения платежей: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("testcards"))
|
||||||
|
async def cmd_testcards(message: Message):
|
||||||
|
testcards_text = (
|
||||||
|
f"<b>Тестовые банковские карты для оплаты</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Для тестирования оплаты используйте:</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Карта для успешной оплаты:</b>\n"
|
||||||
|
f"• Номер: <code>5555 5555 5555 4477</code>\n"
|
||||||
|
f"• Срок действия: <b>ЛЮБОЙ будущий</b> (например: 12/30)\n"
|
||||||
|
f"• CVV код: <b>ЛЮБЫЕ 3 цифры</b> (например: 123)\n"
|
||||||
|
f"• Результат: Оплата пройдёт успешно\n\n"
|
||||||
|
|
||||||
|
f"<b>Карта для отказа в оплате:</b>\n"
|
||||||
|
f"• Номер: <code>5555 5555 5555 4445</code>\n"
|
||||||
|
f"• Срок действия: <b>ЛЮБОЙ будущий</b>\n"
|
||||||
|
f"• CVV код: <b>ЛЮБЫЕ 3 цифры</b>\n"
|
||||||
|
f"• Результат: Оплата будет отклонена\n\n"
|
||||||
|
|
||||||
|
f"<b>Важно:</b>\n"
|
||||||
|
f"• Это тестовые карты, реальные деньги не списываются\n"
|
||||||
|
f"• Используются только для проверки работы оплаты\n"
|
||||||
|
f"• После успешной тестовой оплаты premium активируется\n\n"
|
||||||
|
|
||||||
|
f"Для оплаты подписки используйте команду /buy"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(testcards_text, parse_mode="HTML")
|
||||||
183
tg_bot/infrastructure/telegram/handlers/collection_handler.py
Normal file
183
tg_bot/infrastructure/telegram/handlers/collection_handler.py
Normal file
@ -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(
|
||||||
|
"<b>У вас пока нет коллекций</b>\n\n"
|
||||||
|
"Обратитесь к администратору для создания коллекций и добавления документов.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = "<b>Ваши коллекции документов:</b>\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}. <b>{name}</b>\n"
|
||||||
|
if description:
|
||||||
|
response += f" <i>{description[:50]}...</i>\n"
|
||||||
|
response += f" ID: <code>{collection_id}</code>\n\n"
|
||||||
|
|
||||||
|
keyboard_buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{name}",
|
||||||
|
callback_data=f"collection:{collection_id}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
|
||||||
|
|
||||||
|
response += "<i>Нажмите на коллекцию, чтобы посмотреть документы</i>"
|
||||||
|
|
||||||
|
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(
|
||||||
|
"<b>Использование:</b> /search <collection_id> <запрос>\n\n"
|
||||||
|
"У вас пока нет коллекций. Обратитесь к администратору.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = "<b>Выберите коллекцию для поиска:</b>\n\n"
|
||||||
|
response += "Использование: /search <collection_id> <запрос>\n\n"
|
||||||
|
response += "<b>Доступные коллекции:</b>\n"
|
||||||
|
for collection in collections[:5]:
|
||||||
|
name = collection.get("name", "Без названия")
|
||||||
|
collection_id = collection.get("collection_id")
|
||||||
|
response += f"• <b>{name}</b>\n <code>{collection_id}</code>\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"<b>Ничего не найдено</b>\n\n"
|
||||||
|
f"По запросу <i>\"{query}\"</i> в коллекции ничего не найдено.\n\n"
|
||||||
|
f"Попробуйте другой запрос или используйте /mycollections для просмотра доступных коллекций.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = f"<b>Результаты поиска:</b> \"{query}\"\n\n"
|
||||||
|
for i, doc in enumerate(results[:5], 1):
|
||||||
|
title = doc.get("title", "Без названия")
|
||||||
|
content = doc.get("content", "")[:200]
|
||||||
|
response += f"{i}. <b>{title}</b>\n"
|
||||||
|
response += f" <i>{content}...</i>\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"<b>Коллекция пуста</b>\n\n"
|
||||||
|
f"В этой коллекции пока нет документов.\n"
|
||||||
|
f"Обратитесь к администратору для добавления документов.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
response = f"<b>Документы в коллекции:</b>\n\n"
|
||||||
|
for i, doc in enumerate(documents[:10], 1):
|
||||||
|
title = doc.get("title", "Без названия")
|
||||||
|
content_preview = doc.get("content", "")[:100]
|
||||||
|
response += f"{i}. <b>{title}</b>\n"
|
||||||
|
if content_preview:
|
||||||
|
response += f" <i>{content_preview}...</i>\n"
|
||||||
|
response += "\n"
|
||||||
|
|
||||||
|
if len(documents) > 10:
|
||||||
|
response += f"\n<i>Показано 10 из {len(documents)} документов</i>"
|
||||||
|
|
||||||
|
await callback.message.answer(response, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
82
tg_bot/infrastructure/telegram/handlers/help_handler.py
Normal file
82
tg_bot/infrastructure/telegram/handlers/help_handler.py
Normal file
@ -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"<b>VibeLawyerBot - помощь</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Основные команды:</b>\n"
|
||||||
|
f"• /start - начать работу с ботом\n"
|
||||||
|
f"• /help - показать это сообщение\n"
|
||||||
|
f"• /buy - купить подписку\n"
|
||||||
|
f"• /stats - статистика и лимиты\n"
|
||||||
|
f"• /mypayments - история платежей\n\n"
|
||||||
|
f"<b>Работа с коллекциями:</b>\n"
|
||||||
|
f"• /mycollections - показать мои коллекции документов\n"
|
||||||
|
f"• /search - поиск документов в коллекции\n\n"
|
||||||
|
|
||||||
|
f"<b>Как работает бот:</b>\n"
|
||||||
|
f"1. У вас есть <b>{settings.FREE_QUESTIONS_LIMIT}</b> бесплатных вопросов\n"
|
||||||
|
f"2. Бот ищет ответы в ваших коллекциях документов\n"
|
||||||
|
f"3. После исчерпания лимита нужна подписка\n"
|
||||||
|
f"4. Подписка даёт неограниченный доступ\n\n"
|
||||||
|
f"<b>О коллекциях:</b>\n"
|
||||||
|
f"• Администратор загружает документы в коллекции\n"
|
||||||
|
f"• Вам предоставляется доступ к коллекциям\n"
|
||||||
|
f"• При задаче вопроса бот ищет ответы в ваших коллекциях\n"
|
||||||
|
f"• Используйте /mycollections для просмотра коллекций\n\n"
|
||||||
|
|
||||||
|
f"<b>Оплата (тестовый режим):</b>\n"
|
||||||
|
f"• Безопасно через ЮKассу\n"
|
||||||
|
f"• Сразу после оплаты доступ открывается\n"
|
||||||
|
f"• <b>Тестовые карты для проверки:</b>\n"
|
||||||
|
f" Успешная оплата: <code>5555 5555 5555 4477</code>\n"
|
||||||
|
f" Срок: <b>любой будущий</b> (напр. 12/30)\n"
|
||||||
|
f" CVV: <b>любой 3 цифры</b> (напр. 123)\n\n"
|
||||||
|
f" Отказ в оплате: <code>5555 5555 5555 4445</code>\n"
|
||||||
|
f" Срок: <b>любой будущий</b>\n"
|
||||||
|
f" CVV: <b>любой 3 цифры</b>\n\n"
|
||||||
|
f"• Поддержка: @vibelawyer_support\n\n"
|
||||||
|
|
||||||
|
f"<i>Задавайте юридические вопросы, и бот поможет с ответами!</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(help_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("testcards"))
|
||||||
|
async def cmd_testcards(message: Message):
|
||||||
|
testcards_text = (
|
||||||
|
f"<b>Тестовые банковские карты для оплаты</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Для тестирования оплаты используйте:</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Карта для успешной оплаты:</b>\n"
|
||||||
|
f"• Номер: <code>5555 5555 5555 4477</code>\n"
|
||||||
|
f"• Срок действия: <b>ЛЮБОЙ будущий</b> (например: 12/30)\n"
|
||||||
|
f"• CVV код: <b>ЛЮБЫЕ 3 цифры</b> (например: 123)\n"
|
||||||
|
f"• Результат: Оплата пройдёт успешно\n\n"
|
||||||
|
|
||||||
|
f"<b>Карта для отказа в оплате:</b>\n"
|
||||||
|
f"• Номер: <code>5555 5555 5555 4445</code>\n"
|
||||||
|
f"• Срок действия: <b>ЛЮБОЙ будущий</b>\n"
|
||||||
|
f"• CVV код: <b>ЛЮБЫЕ 3 цифры</b>\n"
|
||||||
|
f"• Результат: Оплата будет отклонена\n\n"
|
||||||
|
|
||||||
|
f"<b>Важно:</b>\n"
|
||||||
|
f"• Это тестовые карты, реальные деньги не списываются\n"
|
||||||
|
f"• Используются только для проверки работы оплаты\n"
|
||||||
|
f"• После успешной тестовой оплаты premium активируется\n\n"
|
||||||
|
|
||||||
|
f"Для оплаты подписки используйте команду /buy"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(testcards_text, parse_mode="HTML")
|
||||||
300
tg_bot/infrastructure/telegram/handlers/question_handler.py
Normal file
300
tg_bot/infrastructure/telegram/handlers/question_handler.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
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 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()
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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, user_service: UserService):
|
||||||
|
await user_service.update_user_questions(user.telegram_id)
|
||||||
|
|
||||||
|
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"<b>Ваш вопрос:</b>\n"
|
||||||
|
f"<i>{question_text[:200]}</i>\n\n"
|
||||||
|
f"<b>Ответ:</b>\n{answer}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sources:
|
||||||
|
response += f"<b>Источники из коллекций:</b>\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}. <b>Коллекция:</b> {collection_name}\n"
|
||||||
|
for title in titles[:2]:
|
||||||
|
response += f" • {title}\n"
|
||||||
|
response += "\n<i>Используйте /mycollections для просмотра всех коллекций</i>\n\n"
|
||||||
|
|
||||||
|
response += (
|
||||||
|
f"<b>Статус:</b> Premium (вопросов безлимитно)\n"
|
||||||
|
f"<b>Всего вопросов:</b> {user.questions_used}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating answer: {e}")
|
||||||
|
response = (
|
||||||
|
f"<b>Ваш вопрос:</b>\n"
|
||||||
|
f"<i>{question_text[:200]}</i>\n\n"
|
||||||
|
f"Ошибка при генерации ответа. Попробуйте позже.\n\n"
|
||||||
|
f"<b>Статус:</b> Premium\n"
|
||||||
|
f"<b>Всего вопросов:</b> {user.questions_used}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(response, parse_mode="HTML")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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"<b>Ваш вопрос:</b>\n"
|
||||||
|
f"<i>{question_text[:200]}</i>\n\n"
|
||||||
|
f"<b>Ответ:</b>\n{answer}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if sources:
|
||||||
|
response += f"<b>Источники из коллекций:</b>\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}. <b>Коллекция:</b> {collection_name}\n"
|
||||||
|
for title in titles[:2]:
|
||||||
|
response += f" • {title}\n"
|
||||||
|
response += "\n<i>Используйте /mycollections для просмотра всех коллекций</i>\n\n"
|
||||||
|
|
||||||
|
response += (
|
||||||
|
f"<b>Статус:</b> Бесплатный доступ\n"
|
||||||
|
f"<b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n"
|
||||||
|
f"<b>Осталось бесплатных:</b> {remaining}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if remaining <= 3 and remaining > 0:
|
||||||
|
response += f"<i>Осталось мало вопросов! Для продолжения используйте /buy</i>\n\n"
|
||||||
|
|
||||||
|
response += f"<i>Для безлимитного доступа: /buy</i>"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating answer: {e}")
|
||||||
|
response = (
|
||||||
|
f"<b>Ваш вопрос:</b>\n"
|
||||||
|
f"<i>{question_text[:200]}</i>\n\n"
|
||||||
|
f"Ошибка при генерации ответа. Попробуйте позже.\n\n"
|
||||||
|
f"<b>Статус:</b> Бесплатный доступ\n"
|
||||||
|
f"<b>Использовано вопросов:</b> {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n"
|
||||||
|
f"<b>Осталось бесплатных:</b> {remaining}\n\n"
|
||||||
|
f"<i>Для безлимитного доступа: /buy</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"<b>Лимит бесплатных вопросов исчерпан!</b>\n\n"
|
||||||
|
|
||||||
|
f"<b>Ваша статистика:</b>\n"
|
||||||
|
f"• Использовано вопросов: {user.questions_used}\n"
|
||||||
|
f"• Бесплатный лимит: {settings.FREE_QUESTIONS_LIMIT}\n\n"
|
||||||
|
|
||||||
|
f"<b>Что делать дальше?</b>\n"
|
||||||
|
f"1. Купите подписку командой /buy\n"
|
||||||
|
f"2. Получите неограниченный доступ к вопросам\n"
|
||||||
|
f"3. Продолжайте использовать бот без ограничений\n\n"
|
||||||
|
|
||||||
|
f"<b>Подписка включает:</b>\n"
|
||||||
|
f"• Неограниченное количество вопросов\n"
|
||||||
|
f"• Приоритетную обработку\n"
|
||||||
|
f"• Доступ ко всем функциям\n\n"
|
||||||
|
|
||||||
|
f"<b>Нажмите /buy чтобы продолжить</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(response, parse_mode="HTML")
|
||||||
59
tg_bot/infrastructure/telegram/handlers/start_handler.py
Normal file
59
tg_bot/infrastructure/telegram/handlers/start_handler.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 AsyncSessionLocal
|
||||||
|
from tg_bot.domain.services.user_service import UserService
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
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
|
||||||
|
)
|
||||||
|
if not existing_user:
|
||||||
|
print(f"Новый пользователь: {user_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка сохранения пользователя: {e}")
|
||||||
|
await session.rollback()
|
||||||
|
welcome_text = (
|
||||||
|
f"<b>Привет, {first_name}!</b>\n\n"
|
||||||
|
f"Я <b>VibeLawyerBot</b> - ваш помощник в юридических вопросах.\n\n"
|
||||||
|
|
||||||
|
f"<b>Как я работаю:</b>\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"<b>Основные команды:</b>\n"
|
||||||
|
f"• /help - подробная помощь\n"
|
||||||
|
f"• /buy - купить подписку\n"
|
||||||
|
f"• /stats - ваша статистика\n"
|
||||||
|
f"• /mypayments - история платежей\n"
|
||||||
|
f"• /mycollections - мои коллекции документов\n"
|
||||||
|
f"• /search - поиск в коллекции\n\n"
|
||||||
|
|
||||||
|
f"<b>Готовы начать?</b> Просто напишите ваш вопрос!\n\n"
|
||||||
|
f"<i>Для получения полного доступа используйте /buy</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(welcome_text, parse_mode="HTML")
|
||||||
61
tg_bot/infrastructure/telegram/handlers/stats_handler.py
Normal file
61
tg_bot/infrastructure/telegram/handlers/stats_handler.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
|
||||||
|
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 AsyncSessionLocal
|
||||||
|
from tg_bot.domain.services.user_service import UserService
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("stats"))
|
||||||
|
async def cmd_stats(message: Message):
|
||||||
|
user_id = message.from_user.id
|
||||||
|
|
||||||
|
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"<b>Ваша статистика</b>\n\n"
|
||||||
|
f"<b>Основное:</b>\n"
|
||||||
|
f"• ID: <code>{user_id}</code>\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"<b>Premium статус:</b>\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"<b>Бесплатный доступ:</b>\n"
|
||||||
|
f"• Осталось вопросов: {remaining}\n"
|
||||||
|
f"• Для безлимита: /buy\n\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stats_text = (
|
||||||
|
f"<b>Добро пожаловать!</b>\n\n"
|
||||||
|
f"Вы новый пользователь.\n"
|
||||||
|
f"• Ваш ID: <code>{user_id}</code>\n"
|
||||||
|
f"• Бесплатных вопросов: {settings.FREE_QUESTIONS_LIMIT}\n"
|
||||||
|
f"• Для начала работы просто задайте вопрос!\n\n"
|
||||||
|
f"<i>Используйте /buy для получения полного доступа</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
await message.answer(stats_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await message.answer(
|
||||||
|
f"<b>Ошибка получения статистики</b>\n\n"
|
||||||
|
f"Попробуйте позже.",
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
43
tg_bot/main.py
Normal file
43
tg_bot/main.py
Normal file
@ -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())
|
||||||
0
tg_bot/payment/__init__.py
Normal file
0
tg_bot/payment/__init__.py
Normal file
0
tg_bot/payment/webhooks/__init__.py
Normal file
0
tg_bot/payment/webhooks/__init__.py
Normal file
71
tg_bot/payment/webhooks/handler.py
Normal file
71
tg_bot/payment/webhooks/handler.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
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 AsyncSessionLocal
|
||||||
|
from tg_bot.infrastructure.database.models import UserModel
|
||||||
|
from sqlalchemy import select
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
if event_type == "payment.succeeded":
|
||||||
|
payment = data.get("object", {})
|
||||||
|
user_id = payment.get("metadata", {}).get("user_id")
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
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"<b>Оплата подтверждена!</b>\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}")
|
||||||
|
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")
|
||||||
0
tg_bot/payment/yookassa/__init__.py
Normal file
0
tg_bot/payment/yookassa/__init__.py
Normal file
55
tg_bot/payment/yookassa/client.py
Normal file
55
tg_bot/payment/yookassa/client.py
Normal file
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user