andrewbokh #6

Merged
Arxip222 merged 4 commits from andrewbokh into main 2025-12-24 10:36:03 +03:00
31 changed files with 574 additions and 507 deletions
Showing only changes of commit 493c385cb1 - Show all commits

View File

@ -0,0 +1,28 @@
"""Add premium fields to users
Revision ID: 002
Revises: 001
Create Date: 2024-01-02 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = '002'
down_revision = '001'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('is_premium', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('users', sa.Column('premium_until', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('questions_used', sa.Integer(), nullable=False, server_default='0'))
def downgrade() -> None:
op.drop_column('users', 'questions_used')
op.drop_column('users', 'premium_until')
op.drop_column('users', 'is_premium')

View File

@ -1,4 +1,4 @@
fastapi==0.104.1
fastapi==0.100.1
uvicorn[standard]==0.24.0
sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0

23
backend/run.py Normal file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python3
import sys
import os
from pathlib import Path
backend_dir = Path(__file__).parent
sys.path.insert(0, str(backend_dir))
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"src.presentation.main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_level="info"
)

View File

@ -3,6 +3,7 @@ Use cases для работы с пользователями
"""
from uuid import UUID
from typing import Optional
from datetime import datetime, timedelta
from src.domain.entities.user import User, UserRole
from src.domain.repositories.user_repository import IUserRepository
from src.shared.exceptions import NotFoundError, ValidationError
@ -53,3 +54,26 @@ class UserUseCases:
"""Получить список пользователей"""
return await self.user_repository.list_all(skip=skip, limit=limit)
async def increment_questions_used(self, telegram_id: str) -> User:
"""Увеличить счетчик использованных вопросов"""
user = await self.user_repository.get_by_telegram_id(telegram_id)
if not user:
raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден")
user.questions_used += 1
return await self.user_repository.update(user)
async def activate_premium(self, telegram_id: str, days: int = 30) -> User:
"""Активировать premium статус"""
user = await self.user_repository.get_by_telegram_id(telegram_id)
if not user:
raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден")
user.is_premium = True
if user.premium_until and user.premium_until > datetime.utcnow():
user.premium_until = user.premium_until + timedelta(days=days)
else:
user.premium_until = datetime.utcnow() + timedelta(days=days)
return await self.user_repository.update(user)

View File

@ -20,12 +20,18 @@ class User:
telegram_id: str,
role: UserRole = UserRole.USER,
user_id: UUID | None = None,
created_at: datetime | None = None
created_at: datetime | None = None,
is_premium: bool = False,
premium_until: datetime | None = None,
questions_used: int = 0
):
self.user_id = user_id or uuid4()
self.telegram_id = telegram_id
self.role = role
self.created_at = created_at or datetime.utcnow()
self.is_premium = is_premium
self.premium_until = premium_until
self.questions_used = questions_used
def is_admin(self) -> bool:
"""проверка, является ли пользователь администратором"""

View File

@ -17,6 +17,10 @@ class UserModel(Base):
telegram_id = Column(String, unique=True, nullable=False, index=True)
role = Column(String, nullable=False, default="user")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
is_premium = Column(Boolean, default=False, nullable=False)
premium_until = Column(DateTime, nullable=True)
questions_used = Column(Integer, default=0, nullable=False)
collections = relationship("CollectionModel", back_populates="owner", cascade="all, delete-orphan")
conversations = relationship("ConversationModel", back_populates="user", cascade="all, delete-orphan")
collection_accesses = relationship("CollectionAccessModel", back_populates="user", cascade="all, delete-orphan")

View File

@ -23,7 +23,10 @@ class PostgreSQLUserRepository(IUserRepository):
user_id=user.user_id,
telegram_id=user.telegram_id,
role=user.role.value,
created_at=user.created_at
created_at=user.created_at,
is_premium=user.is_premium,
premium_until=user.premium_until,
questions_used=user.questions_used
)
self.session.add(db_user)
await self.session.commit()
@ -57,6 +60,9 @@ class PostgreSQLUserRepository(IUserRepository):
db_user.telegram_id = user.telegram_id
db_user.role = user.role.value
db_user.is_premium = user.is_premium
db_user.premium_until = user.premium_until
db_user.questions_used = user.questions_used
await self.session.commit()
await self.session.refresh(db_user)
return self._to_entity(db_user)
@ -90,6 +96,9 @@ class PostgreSQLUserRepository(IUserRepository):
user_id=db_user.user_id,
telegram_id=db_user.telegram_id,
role=UserRole(db_user.role),
created_at=db_user.created_at
created_at=db_user.created_at,
is_premium=db_user.is_premium,
premium_until=db_user.premium_until,
questions_used=db_user.questions_used
)

View File

@ -24,8 +24,8 @@ router = APIRouter(prefix="/admin", tags=["admin"])
async def admin_list_users(
skip: int = 0,
limit: int = 100,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[UserUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: UserUseCases = FromDishka()
):
"""Получить список всех пользователей (только для админов)"""
if not current_user.is_admin():
@ -38,8 +38,8 @@ async def admin_list_users(
async def admin_list_collections(
skip: int = 0,
limit: int = 100,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Получить список всех коллекций (только для админов)"""
from src.infrastructure.database.base import AsyncSessionLocal

View File

@ -24,8 +24,8 @@ router = APIRouter(prefix="/collections", tags=["collections"])
@router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
async def create_collection(
collection_data: CollectionCreate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Создать коллекцию"""
collection = await use_cases.create_collection(
@ -40,7 +40,7 @@ async def create_collection(
@router.get("/{collection_id}", response_model=CollectionResponse)
async def get_collection(
collection_id: UUID,
use_cases: FromDishka[CollectionUseCases] = FromDishka()
use_cases: CollectionUseCases = FromDishka()
):
"""Получить коллекцию по ID"""
collection = await use_cases.get_collection(collection_id)
@ -51,8 +51,8 @@ async def get_collection(
async def update_collection(
collection_id: UUID,
collection_data: CollectionUpdate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Обновить коллекцию"""
collection = await use_cases.update_collection(
@ -68,8 +68,8 @@ async def update_collection(
@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_collection(
collection_id: UUID,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Удалить коллекцию"""
await use_cases.delete_collection(collection_id, current_user.user_id)
@ -80,8 +80,8 @@ async def delete_collection(
async def list_collections(
skip: int = 0,
limit: int = 100,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Получить список коллекций, доступных пользователю"""
collections = await use_cases.list_user_collections(
@ -96,8 +96,8 @@ async def list_collections(
async def grant_access(
collection_id: UUID,
access_data: CollectionAccessGrant,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Предоставить доступ пользователю к коллекции"""
access = await use_cases.grant_access(
@ -112,8 +112,8 @@ async def grant_access(
async def revoke_access(
collection_id: UUID,
user_id: UUID,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: CollectionUseCases = FromDishka()
):
"""Отозвать доступ пользователя к коллекции"""
await use_cases.revoke_access(collection_id, user_id, current_user.user_id)

View File

@ -21,8 +21,8 @@ router = APIRouter(prefix="/conversations", tags=["conversations"])
@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
async def create_conversation(
conversation_data: ConversationCreate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[ConversationUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: ConversationUseCases = FromDishka()
):
"""Создать беседу"""
conversation = await use_cases.create_conversation(
@ -35,8 +35,8 @@ async def create_conversation(
@router.get("/{conversation_id}", response_model=ConversationResponse)
async def get_conversation(
conversation_id: UUID,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[ConversationUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: ConversationUseCases = FromDishka()
):
"""Получить беседу по ID"""
conversation = await use_cases.get_conversation(conversation_id, current_user.user_id)
@ -46,8 +46,8 @@ async def get_conversation(
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_conversation(
conversation_id: UUID,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[ConversationUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: ConversationUseCases = FromDishka()
):
"""Удалить беседу"""
await use_cases.delete_conversation(conversation_id, current_user.user_id)
@ -58,8 +58,8 @@ async def delete_conversation(
async def list_conversations(
skip: int = 0,
limit: int = 100,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[ConversationUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: ConversationUseCases = FromDishka()
):
"""Получить список бесед пользователя"""
conversations = await use_cases.list_user_conversations(

View File

@ -22,8 +22,8 @@ router = APIRouter(prefix="/documents", tags=["documents"])
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def create_document(
document_data: DocumentCreate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[DocumentUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: DocumentUseCases = FromDishka()
):
"""Создать документ"""
document = await use_cases.create_document(
@ -39,8 +39,8 @@ async def create_document(
async def upload_document(
collection_id: UUID,
file: UploadFile = File(...),
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[DocumentUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: DocumentUseCases = FromDishka()
):
"""Загрузить и распарсить PDF документ или изображение"""
if not file.filename:
@ -70,7 +70,7 @@ async def upload_document(
@router.get("/{document_id}", response_model=DocumentResponse)
async def get_document(
document_id: UUID,
use_cases: FromDishka[DocumentUseCases] = FromDishka()
use_cases: DocumentUseCases = FromDishka()
):
"""Получить документ по ID"""
document = await use_cases.get_document(document_id)
@ -81,8 +81,8 @@ async def get_document(
async def update_document(
document_id: UUID,
document_data: DocumentUpdate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[DocumentUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: DocumentUseCases = FromDishka()
):
"""Обновить документ"""
document = await use_cases.update_document(
@ -98,8 +98,8 @@ async def update_document(
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_document(
document_id: UUID,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[DocumentUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: DocumentUseCases = FromDishka()
):
"""Удалить документ"""
await use_cases.delete_document(document_id, current_user.user_id)
@ -111,7 +111,7 @@ async def list_collection_documents(
collection_id: UUID,
skip: int = 0,
limit: int = 100,
use_cases: FromDishka[DocumentUseCases] = FromDishka()
use_cases: DocumentUseCases = FromDishka()
):
"""Получить документы коллекции"""
documents = await use_cases.list_collection_documents(

View File

@ -22,8 +22,8 @@ router = APIRouter(prefix="/messages", tags=["messages"])
@router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
async def create_message(
message_data: MessageCreate,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[MessageUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: MessageUseCases = FromDishka()
):
"""Создать сообщение"""
message = await use_cases.create_message(
@ -39,7 +39,7 @@ async def create_message(
@router.get("/{message_id}", response_model=MessageResponse)
async def get_message(
message_id: UUID,
use_cases: FromDishka[MessageUseCases] = FromDishka()
use_cases: MessageUseCases = FromDishka()
):
"""Получить сообщение по ID"""
message = await use_cases.get_message(message_id)
@ -50,7 +50,7 @@ async def get_message(
async def update_message(
message_id: UUID,
message_data: MessageUpdate,
use_cases: FromDishka[MessageUseCases] = FromDishka()
use_cases: MessageUseCases = FromDishka()
):
"""Обновить сообщение"""
message = await use_cases.update_message(
@ -64,7 +64,7 @@ async def update_message(
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_message(
message_id: UUID,
use_cases: FromDishka[MessageUseCases] = FromDishka()
use_cases: MessageUseCases = FromDishka()
):
"""Удалить сообщение"""
await use_cases.delete_message(message_id)
@ -76,8 +76,8 @@ async def list_conversation_messages(
conversation_id: UUID,
skip: int = 0,
limit: int = 100,
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[MessageUseCases] = FromDishka()
current_user: User = FromDishka(),
use_cases: MessageUseCases = FromDishka()
):
"""Получить сообщения беседы"""
messages = await use_cases.list_conversation_messages(

View File

@ -21,8 +21,8 @@ router = APIRouter(prefix="/rag", tags=["rag"])
@router.post("/index", response_model=IndexDocumentResponse, status_code=status.HTTP_200_OK)
async def index_document(
body: IndexDocumentRequest,
use_cases: FromDishka[RAGUseCases] = FromDishka(),
current_user: FromDishka[User] = FromDishka(),
use_cases: RAGUseCases = FromDishka(),
current_user: User = FromDishka(),
):
"""Индексирование идет через чанкирование, далее эмбеддинг и загрузка в векторную бд"""
result = await use_cases.index_document(body.document_id)
@ -32,8 +32,8 @@ async def index_document(
@router.post("/question", response_model=RAGAnswer, status_code=status.HTTP_200_OK)
async def ask_question(
body: QuestionRequest,
use_cases: FromDishka[RAGUseCases] = FromDishka(),
current_user: FromDishka[User] = FromDishka(),
use_cases: RAGUseCases = FromDishka(),
current_user: User = FromDishka(),
):
"""Отвечает на вопрос, используя RAG в рамках беседы"""
result = await use_cases.ask_question(

View File

@ -4,10 +4,10 @@ API роутеры для работы с пользователями
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, status
from fastapi import APIRouter, status, Depends
from fastapi.responses import JSONResponse
from typing import List
from dishka.integrations.fastapi import FromDishka
from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka, inject
from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse
from src.application.use_cases.user_use_cases import UserUseCases
from src.domain.entities.user import User
@ -16,10 +16,11 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_user(
user_data: UserCreate,
use_cases: FromDishka[UserUseCases] = FromDishka()
):
use_cases: UserUseCases = FromDishka()
) -> UserResponse:
"""Создать пользователя"""
user = await use_cases.create_user(
telegram_id=user_data.telegram_id,
@ -29,17 +30,56 @@ async def create_user(
@router.get("/me", response_model=UserResponse)
@inject
async def get_current_user_info(
current_user: FromDishka[User] = FromDishka()
current_user: User = FromDishka()
):
"""Получить информацию о текущем пользователе"""
return UserResponse.from_entity(current_user)
@router.get("/telegram/{telegram_id}", response_model=UserResponse)
@inject
async def get_user_by_telegram_id(
telegram_id: str,
use_cases: UserUseCases = FromDishka()
):
"""Получить пользователя по Telegram ID"""
user = await use_cases.get_user_by_telegram_id(telegram_id)
if not user:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail=f"Пользователь с telegram_id {telegram_id} не найден")
return UserResponse.from_entity(user)
@router.post("/telegram/{telegram_id}/increment-questions", response_model=UserResponse)
@inject
async def increment_questions(
telegram_id: str,
use_cases: UserUseCases = FromDishka()
):
"""Увеличить счетчик использованных вопросов"""
user = await use_cases.increment_questions_used(telegram_id)
return UserResponse.from_entity(user)
@router.post("/telegram/{telegram_id}/activate-premium", response_model=UserResponse)
@inject
async def activate_premium(
telegram_id: str,
days: int = 30,
use_cases: UserUseCases = FromDishka()
):
"""Активировать premium статус"""
user = await use_cases.activate_premium(telegram_id, days=days)
return UserResponse.from_entity(user)
@router.get("/{user_id}", response_model=UserResponse)
@inject
async def get_user(
user_id: UUID,
use_cases: FromDishka[UserUseCases] = FromDishka()
use_cases: UserUseCases = FromDishka()
):
"""Получить пользователя по ID"""
user = await use_cases.get_user(user_id)
@ -47,10 +87,11 @@ async def get_user(
@router.put("/{user_id}", response_model=UserResponse)
@inject
async def update_user(
user_id: UUID,
user_data: UserUpdate,
use_cases: FromDishka[UserUseCases] = FromDishka()
use_cases: UserUseCases = FromDishka()
):
"""Обновить пользователя"""
user = await use_cases.update_user(
@ -62,9 +103,10 @@ async def update_user(
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_user(
user_id: UUID,
use_cases: FromDishka[UserUseCases] = FromDishka()
use_cases: UserUseCases = FromDishka()
):
"""Удалить пользователя"""
await use_cases.delete_user(user_id)
@ -72,10 +114,11 @@ async def delete_user(
@router.get("", response_model=List[UserResponse])
@inject
async def list_users(
skip: int = 0,
limit: int = 100,
use_cases: FromDishka[UserUseCases] = FromDishka()
use_cases: UserUseCases = FromDishka()
):
"""Получить список пользователей"""
users = await use_cases.list_users(skip=skip, limit=limit)

View File

@ -2,9 +2,11 @@ from __future__ import annotations
import sys
import os
from pathlib import Path
if '/app' not in sys.path:
sys.path.insert(0, '/app')
backend_dir = Path(__file__).parent.parent.parent
if str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

View File

@ -30,6 +30,9 @@ class UserResponse(BaseModel):
telegram_id: str
role: UserRole
created_at: datetime
is_premium: bool = False
premium_until: datetime | None = None
questions_used: int = 0
@classmethod
def from_entity(cls, user: "User") -> "UserResponse":
@ -38,7 +41,10 @@ class UserResponse(BaseModel):
user_id=user.user_id,
telegram_id=user.telegram_id,
role=user.role,
created_at=user.created_at
created_at=user.created_at,
is_premium=user.is_premium,
premium_until=user.premium_until,
questions_used=user.questions_used
)
class Config:

View File

@ -1,7 +1,7 @@
from dishka import Container, Provider, Scope, provide
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from src.infrastructure.database.base import AsyncSessionLocal
from src.infrastructure.repositories.postgresql.user_repository import PostgreSQLUserRepository
@ -39,8 +39,7 @@ from src.application.use_cases.rag_use_cases import RAGUseCases
class DatabaseProvider(Provider):
@provide(scope=Scope.REQUEST)
@asynccontextmanager
async def get_db(self) -> AsyncSession:
async def get_db(self) -> AsyncIterator[AsyncSession]:
async with AsyncSessionLocal() as session:
try:
yield session
@ -77,7 +76,7 @@ class RepositoryProvider(Provider):
class ServiceProvider(Provider):
@provide(scope=Scope.APP)
def get_redis_client(self) -> RedisClient:
return RedisClient()
return RedisClient(host=settings.REDIS_HOST, port=settings.REDIS_PORT)
@provide(scope=Scope.APP)
def get_cache_service(self, redis_client: RedisClient) -> CacheService:

View File

@ -2,8 +2,6 @@ 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:
@ -19,7 +17,7 @@ class RAGService:
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{BACKEND_URL}/users/telegram/{user_telegram_id}"
f"{settings.BACKEND_URL}/users/telegram/{user_telegram_id}"
) as user_response:
if user_response.status != 200:
return []
@ -31,7 +29,7 @@ class RAGService:
return []
async with session.get(
f"{BACKEND_URL}/collections/",
f"{settings.BACKEND_URL}/collections/",
headers={"X-Telegram-ID": user_telegram_id}
) as collections_response:
if collections_response.status != 200:
@ -48,7 +46,7 @@ class RAGService:
try:
async with aiohttp.ClientSession() as search_session:
async with search_session.get(
f"{BACKEND_URL}/documents/collection/{collection_id}",
f"{settings.BACKEND_URL}/documents/collection/{collection_id}",
params={"search": query, "limit": limit_per_collection},
headers={"X-Telegram-ID": user_telegram_id}
) as search_response:

View File

@ -17,7 +17,6 @@ class Settings(BaseSettings):
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"
@ -29,6 +28,8 @@ class Settings(BaseSettings):
DEEPSEEK_API_KEY: Optional[str] = None
DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions"
BACKEND_URL: str = "http://localhost:8001/api/v1"
ADMIN_IDS_STR: str = ""
@property

View File

@ -1,20 +1,60 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
import aiohttp
from datetime import datetime
from typing import Optional
from tg_bot.infrastructure.database.models import UserModel
from tg_bot.config.settings import settings
class User:
"""Модель пользователя для телеграм-бота"""
def __init__(self, data: dict):
self.user_id = data.get("user_id")
self.telegram_id = data.get("telegram_id")
self.role = data.get("role")
created_at_str = data.get("created_at")
if created_at_str:
try:
created_at_str = created_at_str.replace("Z", "+00:00")
self.created_at = datetime.fromisoformat(created_at_str)
except (ValueError, AttributeError):
self.created_at = None
else:
self.created_at = None
premium_until_str = data.get("premium_until")
if premium_until_str:
try:
premium_until_str = premium_until_str.replace("Z", "+00:00")
self.premium_until = datetime.fromisoformat(premium_until_str)
except (ValueError, AttributeError):
self.premium_until = None
else:
self.premium_until = None
self.is_premium = data.get("is_premium", False)
self.questions_used = data.get("questions_used", 0)
class UserService:
"""Сервис для работы с пользователями через API бэкенда"""
def __init__(self, session: AsyncSession):
self.session = session
def __init__(self):
self.backend_url = settings.BACKEND_URL
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_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
"""Получить пользователя по Telegram ID"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.backend_url}/users/telegram/{telegram_id}"
) as response:
if response.status == 200:
data = await response.json()
return User(data)
return None
except Exception as e:
print(f"Error getting user: {e}")
return None
async def get_or_create_user(
self,
@ -22,46 +62,45 @@ class UserService:
username: str = "",
first_name: str = "",
last_name: str = ""
) -> UserModel:
) -> User:
"""Получить или создать пользователя"""
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()
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.backend_url}/users",
json={"telegram_id": str(telegram_id), "role": "user"}
) as response:
if response.status in [200, 201]:
data = await response.json()
return User(data)
except Exception as e:
print(f"Error creating user: {e}")
raise
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
"""Увеличить счетчик использованных вопросов"""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.backend_url}/users/telegram/{telegram_id}/increment-questions"
) as response:
return response.status == 200
except Exception as e:
print(f"Error updating questions: {e}")
return False
async def activate_premium(self, telegram_id: int) -> bool:
async def activate_premium(self, telegram_id: int, days: int = 30) -> bool:
"""Активировать premium статус"""
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
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.backend_url}/users/telegram/{telegram_id}/activate-premium",
params={"days": days}
) as response:
return response.status == 200
except Exception as e:
print(f"Error activating premium: {e}")
await self.session.rollback()
return False

View File

@ -1,19 +0,0 @@
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}")

View File

@ -1,39 +0,0 @@
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})>"

View File

@ -34,8 +34,18 @@ async def create_bot() -> tuple[Bot, Dispatcher]:
async def start_bot():
bot = None
try:
if not settings.TELEGRAM_BOT_TOKEN or not settings.TELEGRAM_BOT_TOKEN.strip():
raise ValueError("TELEGRAM_BOT_TOKEN не установлен в переменных окружения или файле .env")
bot, dp = await create_bot()
try:
bot_info = await bot.get_me()
username = bot_info.username if bot_info.username else f"ID: {bot_info.id}"
logger.info(f"Бот успешно подключен: @{username}")
except Exception as e:
raise ValueError(f"Неверный токен Telegram бота: {e}")
try:
webhook_info = await bot.get_webhook_info()
if webhook_info.url:

View File

@ -4,14 +4,11 @@ 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
from datetime import datetime
router = Router()
user_service = UserService()
@router.message(Command("buy"))
@ -19,9 +16,7 @@ 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():
@ -50,23 +45,7 @@ async def cmd_buy(message: Message):
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()
print(f"Платёж создан в ЮKассе: {payment_data['id']}")
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
@ -139,27 +118,15 @@ async def check_payment_status(callback_query: types.CallbackQuery):
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)
if user:
await callback_query.message.answer(
"<b>Оплата подтверждена!</b>\n\n"
f"Ваш premium-доступ активирован до: "
f"<b>{user.premium_until.strftime('%d.%m.%Y')}</b>\n\n"
f"<b>{user.premium_until.strftime('%d.%m.%Y') if user.premium_until else 'Не указано'}</b>\n\n"
"Теперь вы можете:\n"
"• Задавать неограниченное количество вопросов\n"
"• Получать приоритетные ответы\n"
@ -169,12 +136,23 @@ async def check_payment_status(callback_query: types.CallbackQuery):
)
else:
await callback_query.message.answer(
"<b>Платёж найден в ЮKассе, но не в нашей БД</b>\n\n"
"<b>Оплата подтверждена, но не удалось активировать premium</b>\n\n"
"Пожалуйста, обратитесь к администратору.",
parse_mode="HTML"
)
else:
await callback_query.message.answer(
"<b>Оплата подтверждена, но не удалось активировать premium</b>\n\n"
"Пожалуйста, обратитесь к администратору.",
parse_mode="HTML"
)
except Exception as e:
print(f"Ошибка обработки платежа: {e}")
await callback_query.message.answer(
"<b>Ошибка активации premium</b>\n\n"
"Пожалуйста, обратитесь к администратору.",
parse_mode="HTML"
)
elif payment.status == "pending":
await callback_query.message.answer(
@ -206,42 +184,13 @@ async def check_payment_status(callback_query: types.CallbackQuery):
@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 чтобы оформить подписку.",
"<b>История платежей</b>\n\n"
"История платежей хранится в системе оплаты ЮKassa.\n"
"Для проверки статуса подписки используйте команду /stats.\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"))

View File

@ -2,17 +2,16 @@ from aiogram import Router
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.filters import Command
import aiohttp
from tg_bot.config.settings import settings
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/",
f"{settings.BACKEND_URL}/collections/",
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
@ -27,7 +26,7 @@ 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}",
f"{settings.BACKEND_URL}/documents/collection/{collection_id}",
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
@ -42,7 +41,7 @@ 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}",
f"{settings.BACKEND_URL}/documents/collection/{collection_id}",
params={"search": query},
headers={"X-Telegram-ID": telegram_id}
) as response:

View File

@ -3,14 +3,12 @@ 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.domain.services.user_service import UserService, User
from tg_bot.application.services.rag_service import RAGService
router = Router()
BACKEND_URL = "http://localhost:8001/api/v1"
rag_service = RAGService()
user_service = UserService()
@router.message()
async def handle_question(message: Message):
@ -19,9 +17,7 @@ async def handle_question(message: Message):
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:
@ -31,13 +27,12 @@ async def handle_question(message: Message):
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)
await process_premium_question(message, user, question_text)
elif user.questions_used < settings.FREE_QUESTIONS_LIMIT:
await process_free_question(message, user, question_text, user_service)
await process_free_question(message, user, question_text)
else:
await handle_limit_exceeded(message, user)
@ -50,27 +45,9 @@ async def handle_question(message: Message):
)
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)
async def process_premium_question(message: Message, user: User, question_text: str):
await user_service.update_user_questions(int(user.telegram_id))
user = await user_service.get_user_by_telegram_id(int(user.telegram_id))
await message.bot.send_chat_action(message.chat.id, "typing")
@ -129,9 +106,9 @@ 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, user_service: UserService):
await user_service.update_user_questions(user.telegram_id)
user = await user_service.get_user_by_telegram_id(user.telegram_id)
async def process_free_question(message: Message, user: User, question_text: str):
await user_service.update_user_questions(int(user.telegram_id))
user = await user_service.get_user_by_telegram_id(int(user.telegram_id))
remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used
await message.bot.send_chat_action(message.chat.id, "typing")
@ -201,9 +178,11 @@ async def process_free_question(message: Message, user: UserModel, question_text
async def save_conversation_to_backend(telegram_id: str, question: str, answer: str, sources: list):
try:
from tg_bot.config.settings import settings
backend_url = settings.BACKEND_URL
async with aiohttp.ClientSession() as session:
async with session.get(
f"{BACKEND_URL}/users/telegram/{telegram_id}"
f"{backend_url}/users/telegram/{telegram_id}"
) as user_response:
if user_response.status != 200:
return
@ -211,7 +190,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
user_uuid = user_data.get("user_id")
async with session.get(
f"{BACKEND_URL}/collections/",
f"{backend_url}/collections/",
headers={"X-Telegram-ID": telegram_id}
) as collections_response:
collections = []
@ -223,7 +202,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
collection_id = collections[0].get("collection_id")
else:
async with session.post(
f"{BACKEND_URL}/collections",
f"{backend_url}/collections",
json={
"name": "Основная коллекция",
"description": "Коллекция по умолчанию",
@ -239,7 +218,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
return
async with session.post(
f"{BACKEND_URL}/conversations",
f"{backend_url}/conversations",
json={"collection_id": str(collection_id)},
headers={"X-Telegram-ID": telegram_id}
) as conversation_response:
@ -252,7 +231,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
return
await session.post(
f"{BACKEND_URL}/messages",
f"{backend_url}/messages",
json={
"conversation_id": str(conversation_id),
"content": question,
@ -262,7 +241,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
)
await session.post(
f"{BACKEND_URL}/messages",
f"{backend_url}/messages",
json={
"conversation_id": str(conversation_id),
"content": answer,
@ -276,7 +255,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
print(f"Error saving conversation: {e}")
async def handle_limit_exceeded(message: Message, user: UserModel):
async def handle_limit_exceeded(message: Message, user: User):
response = (
f"<b>Лимит бесплатных вопросов исчерпан!</b>\n\n"

View File

@ -4,10 +4,10 @@ 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()
user_service = UserService()
@router.message(Command("start"))
async def cmd_start(message: Message):
@ -16,9 +16,7 @@ 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 ""
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,
@ -31,7 +29,6 @@ async def cmd_start(message: Message):
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"

View File

@ -4,19 +4,17 @@ 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()
user_service = UserService()
@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:

View File

@ -19,9 +19,6 @@ 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 AsyncSessionLocal
from tg_bot.infrastructure.database.models import UserModel
from sqlalchemy import select
from aiogram import Bot
if event_type == "payment.succeeded":
@ -29,16 +26,12 @@ async def handle_yookassa_webhook(request: Request):
user_id = payment.get("metadata", {}).get("user_id")
if user_id:
async with AsyncSessionLocal() as session:
user_service = UserService(session)
user_service = UserService()
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()
user = await user_service.get_user_by_telegram_id(int(user_id))
if user and settings.TELEGRAM_BOT_TOKEN:
try:
@ -60,7 +53,7 @@ async def handle_yookassa_webhook(request: Request):
except Exception as e:
print(f"Error sending notification: {e}")
else:
print(f"User {user_id} not found")
print(f"User {user_id} not found or failed to activate premium")
except ImportError as e:
print(f"Import error: {e}")

View File

@ -2,8 +2,6 @@ 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.2
yookassa>=2.4.0
aiohttp>=3.9.1

20
tg_bot/run.py Normal file
View File

@ -0,0 +1,20 @@
"""
Скрипт для запуска Telegram бота без Docker
"""
import sys
import os
from pathlib import Path
tg_bot_dir = Path(__file__).parent
sys.path.insert(0, str(tg_bot_dir))
if __name__ == "__main__":
from main import main
import asyncio
asyncio.run(main())