andrewbokh #6

Merged
Arxip222 merged 4 commits from andrewbokh into main 2025-12-24 10:36:03 +03:00
44 changed files with 2684 additions and 1255 deletions

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 uvicorn[standard]==0.24.0
sqlalchemy[asyncio]==2.0.23 sqlalchemy[asyncio]==2.0.23
asyncpg==0.29.0 asyncpg==0.29.0
@ -9,7 +9,7 @@ python-multipart==0.0.6
httpx==0.25.2 httpx==0.25.2
PyMuPDF==1.23.8 PyMuPDF==1.23.8
Pillow==10.2.0 Pillow==10.2.0
dishka==1.7.2 dishka==0.7.0
numpy==1.26.4 numpy==1.26.4
sentence-transformers==2.7.0 sentence-transformers==2.7.0
qdrant-client==1.9.0 qdrant-client==1.9.0

23
backend/run.py Normal file
View File

@ -0,0 +1,23 @@
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

@ -26,7 +26,7 @@ class CacheService:
"question": question, "question": question,
"answer": answer "answer": answer
} }
await self.reids_client.set_json(key, value, ttl or self.default_ttl) await self.redis_client.set_json(key, value, ttl or self.default_ttl)
async def invalidate_collection_cache(self, collection_id: UUID): async def invalidate_collection_cache(self, collection_id: UUID):
pattern = f"rag:answer:{collection_id}:*" pattern = f"rag:answer:{collection_id}:*"

View File

@ -139,3 +139,67 @@ class CollectionUseCases:
all_collections = {c.collection_id: c for c in owned + public + accessed_collections} all_collections = {c.collection_id: c for c in owned + public + accessed_collections}
return list(all_collections.values())[skip:skip+limit] return list(all_collections.values())[skip:skip+limit]
async def list_collection_access(self, collection_id: UUID, user_id: UUID) -> list[CollectionAccess]:
"""Получить список доступа к коллекции"""
collection = await self.get_collection(collection_id)
has_access = await self.check_access(collection_id, user_id)
if not has_access:
raise ForbiddenError("У вас нет доступа к этой коллекции")
return await self.access_repository.list_by_collection(collection_id)
async def grant_access_by_telegram_id(
self,
collection_id: UUID,
telegram_id: str,
owner_id: UUID
) -> CollectionAccess:
"""Предоставить доступ пользователю к коллекции по Telegram ID"""
collection = await self.get_collection(collection_id)
if collection.owner_id != owner_id:
raise ForbiddenError("Только владелец может предоставлять доступ")
user = await self.user_repository.get_by_telegram_id(telegram_id)
if not user:
from src.domain.entities.user import User, UserRole
import logging
logger = logging.getLogger(__name__)
logger.info(f"Creating new user with telegram_id: {telegram_id}")
user = User(telegram_id=telegram_id, role=UserRole.USER)
try:
user = await self.user_repository.create(user)
logger.info(f"User created successfully: user_id={user.user_id}, telegram_id={user.telegram_id}")
except Exception as e:
logger.error(f"Error creating user: {e}")
raise
if user.user_id == owner_id:
raise ForbiddenError("Владелец уже имеет доступ к коллекции")
existing_access = await self.access_repository.get_by_user_and_collection(user.user_id, collection_id)
if existing_access:
return existing_access
access = CollectionAccess(user_id=user.user_id, collection_id=collection_id)
return await self.access_repository.create(access)
async def revoke_access_by_telegram_id(
self,
collection_id: UUID,
telegram_id: str,
owner_id: UUID
) -> bool:
"""Отозвать доступ пользователя к коллекции по Telegram ID"""
collection = await self.get_collection(collection_id)
if collection.owner_id != owner_id:
raise ForbiddenError("Только владелец может отзывать доступ")
user = await self.user_repository.get_by_telegram_id(telegram_id)
if not user:
raise NotFoundError(f"Пользователь с telegram_id {telegram_id} не найден")
return await self.access_repository.delete_by_user_and_collection(user.user_id, collection_id)

View File

@ -6,6 +6,7 @@ from typing import BinaryIO, Optional
from src.domain.entities.document import Document from src.domain.entities.document import Document
from src.domain.repositories.document_repository import IDocumentRepository from src.domain.repositories.document_repository import IDocumentRepository
from src.domain.repositories.collection_repository import ICollectionRepository from src.domain.repositories.collection_repository import ICollectionRepository
from src.domain.repositories.collection_access_repository import ICollectionAccessRepository
from src.application.services.document_parser_service import DocumentParserService from src.application.services.document_parser_service import DocumentParserService
from src.shared.exceptions import NotFoundError, ForbiddenError from src.shared.exceptions import NotFoundError, ForbiddenError
@ -17,12 +18,25 @@ class DocumentUseCases:
self, self,
document_repository: IDocumentRepository, document_repository: IDocumentRepository,
collection_repository: ICollectionRepository, collection_repository: ICollectionRepository,
access_repository: ICollectionAccessRepository,
parser_service: DocumentParserService parser_service: DocumentParserService
): ):
self.document_repository = document_repository self.document_repository = document_repository
self.collection_repository = collection_repository self.collection_repository = collection_repository
self.access_repository = access_repository
self.parser_service = parser_service self.parser_service = parser_service
async def _check_collection_access(self, user_id: UUID, collection) -> bool:
"""Проверить доступ пользователя к коллекции"""
if collection.owner_id == user_id:
return True
if collection.is_public:
return True
access = await self.access_repository.get_by_user_and_collection(user_id, collection.collection_id)
return access is not None
async def create_document( async def create_document(
self, self,
collection_id: UUID, collection_id: UUID,
@ -55,8 +69,9 @@ class DocumentUseCases:
if not collection: if not collection:
raise NotFoundError(f"Коллекция {collection_id} не найдена") raise NotFoundError(f"Коллекция {collection_id} не найдена")
if collection.owner_id != user_id: has_access = await self._check_collection_access(user_id, collection)
raise ForbiddenError("Только владелец может добавлять документы") if not has_access:
raise ForbiddenError("У вас нет доступа к этой коллекции")
title, content = await self.parser_service.parse_pdf(file, filename) title, content = await self.parser_service.parse_pdf(file, filename)
@ -87,8 +102,11 @@ class DocumentUseCases:
document = await self.get_document(document_id) document = await self.get_document(document_id)
collection = await self.collection_repository.get_by_id(document.collection_id) collection = await self.collection_repository.get_by_id(document.collection_id)
if not collection or collection.owner_id != user_id: if not collection:
raise ForbiddenError("Только владелец коллекции может изменять документы") raise NotFoundError(f"Коллекция {document.collection_id} не найдена")
has_access = await self._check_collection_access(user_id, collection)
if not has_access:
raise ForbiddenError("У вас нет доступа к этой коллекции")
if title is not None: if title is not None:
document.title = title document.title = title

View File

@ -3,6 +3,7 @@ Use cases для работы с пользователями
""" """
from uuid import UUID from uuid import UUID
from typing import Optional from typing import Optional
from datetime import datetime, timedelta
from src.domain.entities.user import User, UserRole from src.domain.entities.user import User, UserRole
from src.domain.repositories.user_repository import IUserRepository from src.domain.repositories.user_repository import IUserRepository
from src.shared.exceptions import NotFoundError, ValidationError from src.shared.exceptions import NotFoundError, ValidationError
@ -53,3 +54,26 @@ class UserUseCases:
"""Получить список пользователей""" """Получить список пользователей"""
return await self.user_repository.list_all(skip=skip, limit=limit) 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, telegram_id: str,
role: UserRole = UserRole.USER, role: UserRole = UserRole.USER,
user_id: UUID | None = None, 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.user_id = user_id or uuid4()
self.telegram_id = telegram_id self.telegram_id = telegram_id
self.role = role self.role = role
self.created_at = created_at or datetime.utcnow() 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: def is_admin(self) -> bool:
"""проверка, является ли пользователь администратором""" """проверка, является ли пользователь администратором"""

View File

@ -17,6 +17,10 @@ class UserModel(Base):
telegram_id = Column(String, unique=True, nullable=False, index=True) telegram_id = Column(String, unique=True, nullable=False, index=True)
role = Column(String, nullable=False, default="user") role = Column(String, nullable=False, default="user")
created_at = Column(DateTime, nullable=False, default=datetime.utcnow) 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") collections = relationship("CollectionModel", back_populates="owner", cascade="all, delete-orphan")
conversations = relationship("ConversationModel", back_populates="user", 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") 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, user_id=user.user_id,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
role=user.role.value, 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) self.session.add(db_user)
await self.session.commit() await self.session.commit()
@ -57,6 +60,9 @@ class PostgreSQLUserRepository(IUserRepository):
db_user.telegram_id = user.telegram_id db_user.telegram_id = user.telegram_id
db_user.role = user.role.value 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.commit()
await self.session.refresh(db_user) await self.session.refresh(db_user)
return self._to_entity(db_user) return self._to_entity(db_user)
@ -90,6 +96,9 @@ class PostgreSQLUserRepository(IUserRepository):
user_id=db_user.user_id, user_id=db_user.user_id,
telegram_id=db_user.telegram_id, telegram_id=db_user.telegram_id,
role=UserRole(db_user.role), 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

@ -2,12 +2,12 @@
Админ-панель - упрощенная версия через API эндпоинты Админ-панель - упрощенная версия через API эндпоинты
В будущем можно интегрировать полноценную админ-панель В будущем можно интегрировать полноценную админ-панель
""" """
from __future__ import annotations from fastapi import APIRouter, HTTPException, Request
from typing import List, Annotated
from fastapi import APIRouter, HTTPException
from typing import List
from uuid import UUID from uuid import UUID
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.user_schemas import UserResponse from src.presentation.schemas.user_schemas import UserResponse
from src.presentation.schemas.collection_schemas import CollectionResponse from src.presentation.schemas.collection_schemas import CollectionResponse
from src.presentation.schemas.document_schemas import DocumentResponse from src.presentation.schemas.document_schemas import DocumentResponse
@ -21,13 +21,16 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users", response_model=List[UserResponse]) @router.get("/users", response_model=List[UserResponse])
@inject
async def admin_list_users( async def admin_list_users(
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[UserUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[UserUseCases] = FromDishka()
): ):
"""Получить список всех пользователей (только для админов)""" """Получить список всех пользователей (только для админов)"""
current_user = await get_current_user(request, user_repo)
if not current_user.is_admin(): if not current_user.is_admin():
raise HTTPException(status_code=403, detail="Требуются права администратора") raise HTTPException(status_code=403, detail="Требуются права администратора")
users = await use_cases.list_users(skip=skip, limit=limit) users = await use_cases.list_users(skip=skip, limit=limit)
@ -35,13 +38,16 @@ async def admin_list_users(
@router.get("/collections", response_model=List[CollectionResponse]) @router.get("/collections", response_model=List[CollectionResponse])
@inject
async def admin_list_collections( async def admin_list_collections(
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
): ):
"""Получить список всех коллекций (только для админов)""" """Получить список всех коллекций (только для админов)"""
current_user = await get_current_user(request, user_repo)
from src.infrastructure.database.base import AsyncSessionLocal from src.infrastructure.database.base import AsyncSessionLocal
from src.infrastructure.repositories.postgresql.collection_repository import PostgreSQLCollectionRepository from src.infrastructure.repositories.postgresql.collection_repository import PostgreSQLCollectionRepository
from sqlalchemy import select from sqlalchemy import select

View File

@ -1,19 +1,21 @@
""" """
API роутеры для работы с коллекциями API роутеры для работы с коллекциями
""" """
from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, status from fastapi import APIRouter, status, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import List from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.collection_schemas import ( from src.presentation.schemas.collection_schemas import (
CollectionCreate, CollectionCreate,
CollectionUpdate, CollectionUpdate,
CollectionResponse, CollectionResponse,
CollectionAccessGrant, CollectionAccessGrant,
CollectionAccessResponse CollectionAccessResponse,
CollectionAccessListResponse,
CollectionAccessUserInfo
) )
from src.application.use_cases.collection_use_cases import CollectionUseCases from src.application.use_cases.collection_use_cases import CollectionUseCases
from src.domain.entities.user import User from src.domain.entities.user import User
@ -22,12 +24,15 @@ router = APIRouter(prefix="/collections", tags=["collections"])
@router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=CollectionResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_collection( async def create_collection(
collection_data: CollectionCreate, collection_data: CollectionCreate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[CollectionUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Создать коллекцию""" """Создать коллекцию"""
current_user = await get_current_user(request, user_repo)
collection = await use_cases.create_collection( collection = await use_cases.create_collection(
name=collection_data.name, name=collection_data.name,
owner_id=current_user.user_id, owner_id=current_user.user_id,
@ -38,23 +43,36 @@ async def create_collection(
@router.get("/{collection_id}", response_model=CollectionResponse) @router.get("/{collection_id}", response_model=CollectionResponse)
@inject
async def get_collection( async def get_collection(
collection_id: UUID, collection_id: UUID,
use_cases: FromDishka[CollectionUseCases] = FromDishka() request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Получить коллекцию по ID""" """Получить коллекцию по ID"""
current_user = await get_current_user(request, user_repo)
collection = await use_cases.get_collection(collection_id) collection = await use_cases.get_collection(collection_id)
has_access = await use_cases.check_access(collection_id, current_user.user_id)
if not has_access:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="У вас нет доступа к этой коллекции")
return CollectionResponse.from_entity(collection) return CollectionResponse.from_entity(collection)
@router.put("/{collection_id}", response_model=CollectionResponse) @router.put("/{collection_id}", response_model=CollectionResponse)
@inject
async def update_collection( async def update_collection(
collection_id: UUID, collection_id: UUID,
collection_data: CollectionUpdate, collection_data: CollectionUpdate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[CollectionUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Обновить коллекцию""" """Обновить коллекцию"""
current_user = await get_current_user(request, user_repo)
collection = await use_cases.update_collection( collection = await use_cases.update_collection(
collection_id=collection_id, collection_id=collection_id,
user_id=current_user.user_id, user_id=current_user.user_id,
@ -66,24 +84,30 @@ async def update_collection(
@router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{collection_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_collection( async def delete_collection(
collection_id: UUID, collection_id: UUID,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[CollectionUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Удалить коллекцию""" """Удалить коллекцию"""
current_user = await get_current_user(request, user_repo)
await use_cases.delete_collection(collection_id, current_user.user_id) await use_cases.delete_collection(collection_id, current_user.user_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
@router.get("", response_model=List[CollectionResponse]) @router.get("", response_model=List[CollectionResponse])
@inject
async def list_collections( async def list_collections(
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[CollectionUseCases] = FromDishka()
): ):
"""Получить список коллекций, доступных пользователю""" """Получить список коллекций, доступных пользователю"""
current_user = await get_current_user(request, user_repo)
collections = await use_cases.list_user_collections( collections = await use_cases.list_user_collections(
user_id=current_user.user_id, user_id=current_user.user_id,
skip=skip, skip=skip,
@ -93,13 +117,16 @@ async def list_collections(
@router.post("/{collection_id}/access", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED) @router.post("/{collection_id}/access", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED)
@inject
async def grant_access( async def grant_access(
collection_id: UUID, collection_id: UUID,
access_data: CollectionAccessGrant, access_data: CollectionAccessGrant,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[CollectionUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Предоставить доступ пользователю к коллекции""" """Предоставить доступ пользователю к коллекции"""
current_user = await get_current_user(request, user_repo)
access = await use_cases.grant_access( access = await use_cases.grant_access(
collection_id=collection_id, collection_id=collection_id,
user_id=access_data.user_id, user_id=access_data.user_id,
@ -109,13 +136,91 @@ async def grant_access(
@router.delete("/{collection_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{collection_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def revoke_access( async def revoke_access(
collection_id: UUID, collection_id: UUID,
user_id: UUID, user_id: UUID,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[CollectionUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
): ):
"""Отозвать доступ пользователя к коллекции""" """Отозвать доступ пользователя к коллекции"""
current_user = await get_current_user(request, user_repo)
await use_cases.revoke_access(collection_id, user_id, current_user.user_id) await use_cases.revoke_access(collection_id, user_id, current_user.user_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
@router.get("/{collection_id}/access", response_model=List[CollectionAccessListResponse])
@inject
async def list_collection_access(
collection_id: UUID,
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
):
"""Получить список пользователей с доступом к коллекции"""
current_user = await get_current_user(request, user_repo)
accesses = await use_cases.list_collection_access(collection_id, current_user.user_id)
result = []
for access in accesses:
user = await user_repo.get_by_id(access.user_id)
if user:
user_info = CollectionAccessUserInfo(
user_id=user.user_id,
telegram_id=user.telegram_id,
role=user.role.value,
created_at=user.created_at
)
result.append(CollectionAccessListResponse(
access_id=access.access_id,
user=user_info,
collection_id=access.collection_id,
created_at=access.created_at
))
return result
@router.post("/{collection_id}/access/telegram/{telegram_id}", response_model=CollectionAccessResponse, status_code=status.HTTP_201_CREATED)
@inject
async def grant_access_by_telegram_id(
collection_id: UUID,
telegram_id: str,
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
):
"""Предоставить доступ пользователю к коллекции по Telegram ID"""
import logging
logger = logging.getLogger(__name__)
current_user = await get_current_user(request, user_repo)
logger.info(f"Granting access: collection_id={collection_id}, target_telegram_id={telegram_id}, owner_id={current_user.user_id}")
try:
access = await use_cases.grant_access_by_telegram_id(
collection_id=collection_id,
telegram_id=telegram_id,
owner_id=current_user.user_id
)
logger.info(f"Access granted successfully: access_id={access.access_id}")
return CollectionAccessResponse.from_entity(access)
except Exception as e:
logger.error(f"Error granting access: {e}", exc_info=True)
raise
@router.delete("/{collection_id}/access/telegram/{telegram_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def revoke_access_by_telegram_id(
collection_id: UUID,
telegram_id: str,
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[CollectionUseCases, FromDishka()]
):
"""Отозвать доступ пользователя к коллекции по Telegram ID"""
current_user = await get_current_user(request, user_repo)
await use_cases.revoke_access_by_telegram_id(collection_id, telegram_id, current_user.user_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)

View File

@ -1,13 +1,13 @@
""" """
API роутеры для работы с беседами API роутеры для работы с беседами
""" """
from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, status from fastapi import APIRouter, status, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import List from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.conversation_schemas import ( from src.presentation.schemas.conversation_schemas import (
ConversationCreate, ConversationCreate,
ConversationResponse ConversationResponse
@ -19,12 +19,15 @@ router = APIRouter(prefix="/conversations", tags=["conversations"])
@router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ConversationResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_conversation( async def create_conversation(
conversation_data: ConversationCreate, conversation_data: ConversationCreate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[ConversationUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[ConversationUseCases, FromDishka()]
): ):
"""Создать беседу""" """Создать беседу"""
current_user = await get_current_user(request, user_repo)
conversation = await use_cases.create_conversation( conversation = await use_cases.create_conversation(
user_id=current_user.user_id, user_id=current_user.user_id,
collection_id=conversation_data.collection_id collection_id=conversation_data.collection_id
@ -33,35 +36,44 @@ async def create_conversation(
@router.get("/{conversation_id}", response_model=ConversationResponse) @router.get("/{conversation_id}", response_model=ConversationResponse)
@inject
async def get_conversation( async def get_conversation(
conversation_id: UUID, conversation_id: UUID,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[ConversationUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[ConversationUseCases, FromDishka()]
): ):
"""Получить беседу по ID""" """Получить беседу по ID"""
current_user = await get_current_user(request, user_repo)
conversation = await use_cases.get_conversation(conversation_id, current_user.user_id) conversation = await use_cases.get_conversation(conversation_id, current_user.user_id)
return ConversationResponse.from_entity(conversation) return ConversationResponse.from_entity(conversation)
@router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{conversation_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_conversation( async def delete_conversation(
conversation_id: UUID, conversation_id: UUID,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[ConversationUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[ConversationUseCases, FromDishka()]
): ):
"""Удалить беседу""" """Удалить беседу"""
current_user = await get_current_user(request, user_repo)
await use_cases.delete_conversation(conversation_id, current_user.user_id) await use_cases.delete_conversation(conversation_id, current_user.user_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
@router.get("", response_model=List[ConversationResponse]) @router.get("", response_model=List[ConversationResponse])
@inject
async def list_conversations( async def list_conversations(
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[ConversationUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[ConversationUseCases] = FromDishka()
): ):
"""Получить список бесед пользователя""" """Получить список бесед пользователя"""
current_user = await get_current_user(request, user_repo)
conversations = await use_cases.list_user_conversations( conversations = await use_cases.list_user_conversations(
user_id=current_user.user_id, user_id=current_user.user_id,
skip=skip, skip=skip,

View File

@ -1,31 +1,35 @@
""" """
API роутеры для работы с документами API роутеры для работы с документами
""" """
from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, status, UploadFile, File from fastapi import APIRouter, status, UploadFile, File, Depends, Request, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import List from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.document_schemas import ( from src.presentation.schemas.document_schemas import (
DocumentCreate, DocumentCreate,
DocumentUpdate, DocumentUpdate,
DocumentResponse DocumentResponse
) )
from src.application.use_cases.document_use_cases import DocumentUseCases from src.application.use_cases.document_use_cases import DocumentUseCases
from src.application.use_cases.collection_use_cases import CollectionUseCases
from src.domain.entities.user import User from src.domain.entities.user import User
router = APIRouter(prefix="/documents", tags=["documents"]) router = APIRouter(prefix="/documents", tags=["documents"])
@router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_document( async def create_document(
document_data: DocumentCreate, document_data: DocumentCreate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[DocumentUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[DocumentUseCases, FromDishka()]
): ):
"""Создать документ""" """Создать документ"""
current_user = await get_current_user(request, user_repo)
document = await use_cases.create_document( document = await use_cases.create_document(
collection_id=document_data.collection_id, collection_id=document_data.collection_id,
title=document_data.title, title=document_data.title,
@ -36,13 +40,16 @@ async def create_document(
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED) @router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
@inject
async def upload_document( async def upload_document(
collection_id: UUID, collection_id: UUID = Query(...),
file: UploadFile = File(...), request: Request = None,
current_user: FromDishka[User] = FromDishka(), user_repo: Annotated[IUserRepository, FromDishka()] = None,
use_cases: FromDishka[DocumentUseCases] = FromDishka() use_cases: Annotated[DocumentUseCases, FromDishka()] = None,
file: UploadFile = File(...)
): ):
"""Загрузить и распарсить PDF документ или изображение""" """Загрузить и распарсить PDF документ или изображение"""
current_user = await get_current_user(request, user_repo)
if not file.filename: if not file.filename:
raise JSONResponse( raise JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -68,9 +75,10 @@ async def upload_document(
@router.get("/{document_id}", response_model=DocumentResponse) @router.get("/{document_id}", response_model=DocumentResponse)
@inject
async def get_document( async def get_document(
document_id: UUID, document_id: UUID,
use_cases: FromDishka[DocumentUseCases] = FromDishka() use_cases: Annotated[DocumentUseCases, FromDishka()]
): ):
"""Получить документ по ID""" """Получить документ по ID"""
document = await use_cases.get_document(document_id) document = await use_cases.get_document(document_id)
@ -78,13 +86,16 @@ async def get_document(
@router.put("/{document_id}", response_model=DocumentResponse) @router.put("/{document_id}", response_model=DocumentResponse)
@inject
async def update_document( async def update_document(
document_id: UUID, document_id: UUID,
document_data: DocumentUpdate, document_data: DocumentUpdate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[DocumentUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[DocumentUseCases, FromDishka()]
): ):
"""Обновить документ""" """Обновить документ"""
current_user = await get_current_user(request, user_repo)
document = await use_cases.update_document( document = await use_cases.update_document(
document_id=document_id, document_id=document_id,
user_id=current_user.user_id, user_id=current_user.user_id,
@ -96,24 +107,39 @@ async def update_document(
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_document( async def delete_document(
document_id: UUID, document_id: UUID,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[DocumentUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[DocumentUseCases, FromDishka()]
): ):
"""Удалить документ""" """Удалить документ"""
current_user = await get_current_user(request, user_repo)
await use_cases.delete_document(document_id, current_user.user_id) await use_cases.delete_document(document_id, current_user.user_id)
return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None) return JSONResponse(status_code=status.HTTP_204_NO_CONTENT, content=None)
@router.get("/collection/{collection_id}", response_model=List[DocumentResponse]) @router.get("/collection/{collection_id}", response_model=List[DocumentResponse])
@inject
async def list_collection_documents( async def list_collection_documents(
collection_id: UUID, collection_id: UUID,
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[DocumentUseCases, FromDishka()],
collection_use_cases: Annotated[CollectionUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
use_cases: FromDishka[DocumentUseCases] = FromDishka()
): ):
"""Получить документы коллекции""" """Получить документы коллекции"""
current_user = await get_current_user(request, user_repo)
has_access = await collection_use_cases.check_access(collection_id, current_user.user_id)
if not has_access:
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="У вас нет доступа к этой коллекции")
documents = await use_cases.list_collection_documents( documents = await use_cases.list_collection_documents(
collection_id=collection_id, collection_id=collection_id,
skip=skip, skip=skip,

View File

@ -1,13 +1,13 @@
""" """
API роутеры для работы с сообщениями API роутеры для работы с сообщениями
""" """
from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, status from fastapi import APIRouter, status, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import List from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.message_schemas import ( from src.presentation.schemas.message_schemas import (
MessageCreate, MessageCreate,
MessageUpdate, MessageUpdate,
@ -20,12 +20,15 @@ router = APIRouter(prefix="/messages", tags=["messages"])
@router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_message( async def create_message(
message_data: MessageCreate, message_data: MessageCreate,
current_user: FromDishka[User] = FromDishka(), request: Request,
use_cases: FromDishka[MessageUseCases] = FromDishka() user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[MessageUseCases, FromDishka()]
): ):
"""Создать сообщение""" """Создать сообщение"""
current_user = await get_current_user(request, user_repo)
message = await use_cases.create_message( message = await use_cases.create_message(
conversation_id=message_data.conversation_id, conversation_id=message_data.conversation_id,
content=message_data.content, content=message_data.content,
@ -37,9 +40,10 @@ async def create_message(
@router.get("/{message_id}", response_model=MessageResponse) @router.get("/{message_id}", response_model=MessageResponse)
@inject
async def get_message( async def get_message(
message_id: UUID, message_id: UUID,
use_cases: FromDishka[MessageUseCases] = FromDishka() use_cases: Annotated[MessageUseCases, FromDishka()]
): ):
"""Получить сообщение по ID""" """Получить сообщение по ID"""
message = await use_cases.get_message(message_id) message = await use_cases.get_message(message_id)
@ -47,10 +51,11 @@ async def get_message(
@router.put("/{message_id}", response_model=MessageResponse) @router.put("/{message_id}", response_model=MessageResponse)
@inject
async def update_message( async def update_message(
message_id: UUID, message_id: UUID,
message_data: MessageUpdate, message_data: MessageUpdate,
use_cases: FromDishka[MessageUseCases] = FromDishka() use_cases: Annotated[MessageUseCases, FromDishka()]
): ):
"""Обновить сообщение""" """Обновить сообщение"""
message = await use_cases.update_message( message = await use_cases.update_message(
@ -62,9 +67,10 @@ async def update_message(
@router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_message( async def delete_message(
message_id: UUID, message_id: UUID,
use_cases: FromDishka[MessageUseCases] = FromDishka() use_cases: Annotated[MessageUseCases, FromDishka()]
): ):
"""Удалить сообщение""" """Удалить сообщение"""
await use_cases.delete_message(message_id) await use_cases.delete_message(message_id)
@ -72,14 +78,17 @@ async def delete_message(
@router.get("/conversation/{conversation_id}", response_model=List[MessageResponse]) @router.get("/conversation/{conversation_id}", response_model=List[MessageResponse])
@inject
async def list_conversation_messages( async def list_conversation_messages(
conversation_id: UUID, conversation_id: UUID,
request: Request,
user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[MessageUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
current_user: FromDishka[User] = FromDishka(),
use_cases: FromDishka[MessageUseCases] = FromDishka()
): ):
"""Получить сообщения беседы""" """Получить сообщения беседы"""
current_user = await get_current_user(request, user_repo)
messages = await use_cases.list_conversation_messages( messages = await use_cases.list_conversation_messages(
conversation_id=conversation_id, conversation_id=conversation_id,
user_id=current_user.user_id, user_id=current_user.user_id,

View File

@ -1,10 +1,11 @@
""" """
API для RAG: индексация документов и ответы на вопросы API для RAG: индексация документов и ответы на вопросы
""" """
from __future__ import annotations from fastapi import APIRouter, status, Request
from typing import Annotated
from fastapi import APIRouter, status from dishka.integrations.fastapi import FromDishka, inject
from dishka.integrations.fastapi import FromDishka from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.rag_schemas import ( from src.presentation.schemas.rag_schemas import (
QuestionRequest, QuestionRequest,
RAGAnswer, RAGAnswer,
@ -19,23 +20,29 @@ router = APIRouter(prefix="/rag", tags=["rag"])
@router.post("/index", response_model=IndexDocumentResponse, status_code=status.HTTP_200_OK) @router.post("/index", response_model=IndexDocumentResponse, status_code=status.HTTP_200_OK)
@inject
async def index_document( async def index_document(
body: IndexDocumentRequest, body: IndexDocumentRequest,
use_cases: FromDishka[RAGUseCases] = FromDishka(), request: Request,
current_user: FromDishka[User] = FromDishka(), user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[RAGUseCases, FromDishka()],
): ):
"""Индексирование идет через чанкирование, далее эмбеддинг и загрузка в векторную бд""" """Индексирование идет через чанкирование, далее эмбеддинг и загрузка в векторную бд"""
current_user = await get_current_user(request, user_repo)
result = await use_cases.index_document(body.document_id) result = await use_cases.index_document(body.document_id)
return IndexDocumentResponse(**result) return IndexDocumentResponse(**result)
@router.post("/question", response_model=RAGAnswer, status_code=status.HTTP_200_OK) @router.post("/question", response_model=RAGAnswer, status_code=status.HTTP_200_OK)
@inject
async def ask_question( async def ask_question(
body: QuestionRequest, body: QuestionRequest,
use_cases: FromDishka[RAGUseCases] = FromDishka(), request: Request,
current_user: FromDishka[User] = FromDishka(), user_repo: Annotated[IUserRepository, FromDishka()],
use_cases: Annotated[RAGUseCases, FromDishka()],
): ):
"""Отвечает на вопрос, используя RAG в рамках беседы""" """Отвечает на вопрос, используя RAG в рамках беседы"""
current_user = await get_current_user(request, user_repo)
result = await use_cases.ask_question( result = await use_cases.ask_question(
conversation_id=body.conversation_id, conversation_id=body.conversation_id,
user_id=current_user.user_id, user_id=current_user.user_id,

View File

@ -1,13 +1,13 @@
""" """
API роутеры для работы с пользователями API роутеры для работы с пользователями
""" """
from __future__ import annotations
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, status from fastapi import APIRouter, status, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from typing import List from typing import List, Annotated
from dishka.integrations.fastapi import FromDishka from dishka.integrations.fastapi import FromDishka, inject
from src.domain.repositories.user_repository import IUserRepository
from src.presentation.middleware.auth_middleware import get_current_user
from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse from src.presentation.schemas.user_schemas import UserCreate, UserUpdate, UserResponse
from src.application.use_cases.user_use_cases import UserUseCases from src.application.use_cases.user_use_cases import UserUseCases
from src.domain.entities.user import User from src.domain.entities.user import User
@ -16,9 +16,10 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
@inject
async def create_user( async def create_user(
user_data: UserCreate, user_data: UserCreate,
use_cases: FromDishka[UserUseCases] = FromDishka() use_cases: Annotated[UserUseCases, FromDishka()]
): ):
"""Создать пользователя""" """Создать пользователя"""
user = await use_cases.create_user( user = await use_cases.create_user(
@ -29,17 +30,59 @@ async def create_user(
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
@inject
async def get_current_user_info( async def get_current_user_info(
current_user: FromDishka[User] = FromDishka() request,
user_repo: Annotated[IUserRepository, FromDishka()]
): ):
"""Получить информацию о текущем пользователе""" """Получить информацию о текущем пользователе"""
current_user = await get_current_user(request, user_repo)
return UserResponse.from_entity(current_user) 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: Annotated[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: Annotated[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(
use_cases: Annotated[UserUseCases, FromDishka()],
telegram_id: str,
days: int = 30,
):
"""Активировать premium статус"""
user = await use_cases.activate_premium(telegram_id, days=days)
return UserResponse.from_entity(user)
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
@inject
async def get_user( async def get_user(
user_id: UUID, user_id: UUID,
use_cases: FromDishka[UserUseCases] = FromDishka() use_cases: Annotated[UserUseCases, FromDishka()]
): ):
"""Получить пользователя по ID""" """Получить пользователя по ID"""
user = await use_cases.get_user(user_id) user = await use_cases.get_user(user_id)
@ -47,10 +90,11 @@ async def get_user(
@router.put("/{user_id}", response_model=UserResponse) @router.put("/{user_id}", response_model=UserResponse)
@inject
async def update_user( async def update_user(
user_id: UUID, user_id: UUID,
user_data: UserUpdate, user_data: UserUpdate,
use_cases: FromDishka[UserUseCases] = FromDishka() use_cases: Annotated[UserUseCases, FromDishka()]
): ):
"""Обновить пользователя""" """Обновить пользователя"""
user = await use_cases.update_user( user = await use_cases.update_user(
@ -62,9 +106,10 @@ async def update_user(
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@inject
async def delete_user( async def delete_user(
user_id: UUID, user_id: UUID,
use_cases: FromDishka[UserUseCases] = FromDishka() use_cases: Annotated[UserUseCases, FromDishka()]
): ):
"""Удалить пользователя""" """Удалить пользователя"""
await use_cases.delete_user(user_id) await use_cases.delete_user(user_id)
@ -72,10 +117,11 @@ async def delete_user(
@router.get("", response_model=List[UserResponse]) @router.get("", response_model=List[UserResponse])
@inject
async def list_users( async def list_users(
use_cases: Annotated[UserUseCases, FromDishka()],
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100
use_cases: FromDishka[UserUseCases] = FromDishka()
): ):
"""Получить список пользователей""" """Получить список пользователей"""
users = await use_cases.list_users(skip=skip, limit=limit) users = await use_cases.list_users(skip=skip, limit=limit)

View File

@ -1,10 +1,11 @@
from __future__ import annotations
import sys import sys
import os import os
import asyncio
from pathlib import Path
if '/app' not in sys.path: backend_dir = Path(__file__).parent.parent.parent
sys.path.insert(0, '/app') if str(backend_dir) not in sys.path:
sys.path.insert(0, str(backend_dir))
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -22,15 +23,17 @@ from src.infrastructure.database.base import engine, Base
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Управление жизненным циклом приложения""" """Управление жизненным циклом приложения"""
container = create_container()
setup_dishka(container, app)
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
except Exception as e: except Exception as e:
print(f"Примечание при создании таблиц: {e}") print(f"Примечание при создании таблиц: {e}")
yield yield
await container.close() if hasattr(app.state, 'container') and hasattr(app.state.container, 'close'):
if asyncio.iscoroutinefunction(app.state.container.close):
await app.state.container.close()
else:
app.state.container.close()
await engine.dispose() await engine.dispose()
@ -41,6 +44,10 @@ app = FastAPI(
lifespan=lifespan lifespan=lifespan
) )
container = create_container()
setup_dishka(container, app)
app.state.container = container
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.CORS_ORIGINS, allow_origins=settings.CORS_ORIGINS,

View File

@ -75,3 +75,22 @@ class CollectionAccessResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class CollectionAccessUserInfo(BaseModel):
"""Информация о пользователе с доступом"""
user_id: UUID
telegram_id: str
role: str
created_at: datetime
class CollectionAccessListResponse(BaseModel):
"""Схема ответа со списком доступа"""
access_id: UUID
user: CollectionAccessUserInfo
collection_id: UUID
created_at: datetime
class Config:
from_attributes = True

View File

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

View File

@ -7,18 +7,19 @@ from typing import Optional
class Settings(BaseSettings): class Settings(BaseSettings):
"""Настройки (загружаются из .env автоматически)"""
POSTGRES_HOST: str POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int POSTGRES_PORT: int = 5432
POSTGRES_USER: str POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str POSTGRES_DB: str = "lawyer_ai"
QDRANT_HOST: str QDRANT_HOST: str = "localhost"
QDRANT_PORT: int QDRANT_PORT: int = 6333
REDIS_HOST: str REDIS_HOST: str = "localhost"
REDIS_PORT: int REDIS_PORT: int = 6379
TELEGRAM_BOT_TOKEN: Optional[str] = None TELEGRAM_BOT_TOKEN: Optional[str] = None
YANDEX_OCR_API_KEY: Optional[str] = None YANDEX_OCR_API_KEY: Optional[str] = None
@ -29,11 +30,12 @@ class Settings(BaseSettings):
APP_NAME: str = "ИИ-юрист" APP_NAME: str = "ИИ-юрист"
DEBUG: bool = False DEBUG: bool = False
SECRET_KEY: str SECRET_KEY: str = "your-secret-key-change-in-production"
CORS_ORIGINS: list[str] = ["*"] CORS_ORIGINS: list[str] = ["*"]
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Вычисляемый URL подключения"""
return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" return f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
class Config: class Config:

View File

@ -1,4 +1,4 @@
from dishka import Container, Provider, Scope, provide from dishka import Container, Provider, Scope, provide, make_async_container
from fastapi import Request from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -39,13 +39,9 @@ from src.application.use_cases.rag_use_cases import RAGUseCases
class DatabaseProvider(Provider): class DatabaseProvider(Provider):
@provide(scope=Scope.REQUEST) @provide(scope=Scope.REQUEST)
@asynccontextmanager
async def get_db(self) -> AsyncSession: async def get_db(self) -> AsyncSession:
async with AsyncSessionLocal() as session: session = AsyncSessionLocal()
try: return session
yield session
finally:
await session.close()
class RepositoryProvider(Provider): class RepositoryProvider(Provider):
@ -77,7 +73,7 @@ class RepositoryProvider(Provider):
class ServiceProvider(Provider): class ServiceProvider(Provider):
@provide(scope=Scope.APP) @provide(scope=Scope.APP)
def get_redis_client(self) -> RedisClient: def get_redis_client(self) -> RedisClient:
return RedisClient() return RedisClient(host=settings.REDIS_HOST, port=settings.REDIS_PORT)
@provide(scope=Scope.APP) @provide(scope=Scope.APP)
def get_cache_service(self, redis_client: RedisClient) -> CacheService: def get_cache_service(self, redis_client: RedisClient) -> CacheService:
@ -95,8 +91,6 @@ class ServiceProvider(Provider):
def get_parser_service(self, ocr_service: YandexOCRService) -> DocumentParserService: def get_parser_service(self, ocr_service: YandexOCRService) -> DocumentParserService:
return DocumentParserService(ocr_service) return DocumentParserService(ocr_service)
class VectorServiceProvider(Provider):
@provide(scope=Scope.APP) @provide(scope=Scope.APP)
def get_qdrant_client(self) -> QdrantClient: def get_qdrant_client(self) -> QdrantClient:
return QdrantClient(host=settings.QDRANT_HOST, port=settings.QDRANT_PORT) return QdrantClient(host=settings.QDRANT_HOST, port=settings.QDRANT_PORT)
@ -134,12 +128,6 @@ class VectorServiceProvider(Provider):
splitter=text_splitter, splitter=text_splitter,
) )
class AuthProvider(Provider):
@provide(scope=Scope.REQUEST)
async def get_current_user(self, request: Request, user_repo: IUserRepository) -> User:
from src.presentation.middleware.auth_middleware import get_current_user
return await get_current_user(request, user_repo)
class UseCaseProvider(Provider): class UseCaseProvider(Provider):
@provide(scope=Scope.REQUEST) @provide(scope=Scope.REQUEST)
@ -163,9 +151,10 @@ class UseCaseProvider(Provider):
self, self,
document_repo: IDocumentRepository, document_repo: IDocumentRepository,
collection_repo: ICollectionRepository, collection_repo: ICollectionRepository,
access_repo: ICollectionAccessRepository,
parser_service: DocumentParserService parser_service: DocumentParserService
) -> DocumentUseCases: ) -> DocumentUseCases:
return DocumentUseCases(document_repo, collection_repo, parser_service) return DocumentUseCases(document_repo, collection_repo, access_repo, parser_service)
@provide(scope=Scope.REQUEST) @provide(scope=Scope.REQUEST)
def get_conversation_use_cases( def get_conversation_use_cases(
@ -197,12 +186,10 @@ class UseCaseProvider(Provider):
def create_container() -> Container: def create_container() -> Container:
container = Container() return make_async_container(
container.add_provider(DatabaseProvider()) DatabaseProvider(),
container.add_provider(RepositoryProvider()) RepositoryProvider(),
container.add_provider(ServiceProvider()) ServiceProvider(),
container.add_provider(AuthProvider()) UseCaseProvider()
container.add_provider(UseCaseProvider()) )
container.add_provider(VectorServiceProvider())
return container

View File

@ -70,6 +70,7 @@ services:
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY}
DEEPSEEK_API_URL: ${DEEPSEEK_API_URL:-https://api.deepseek.com/v1/chat/completions} DEEPSEEK_API_URL: ${DEEPSEEK_API_URL:-https://api.deepseek.com/v1/chat/completions}
YANDEX_OCR_API_KEY: ${YANDEX_OCR_API_KEY} YANDEX_OCR_API_KEY: ${YANDEX_OCR_API_KEY}
BACKEND_URL: ${BACKEND_URL:-http://backend:8000/api/v1}
DEBUG: "true" DEBUG: "true"
depends_on: depends_on:
- postgres - postgres

View File

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

View File

@ -1,9 +1,10 @@
import os
from typing import List, Optional from typing import List, Optional
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
"""Настройки приложения (загружаются из .env файла в корне проекта)"""
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
@ -13,26 +14,35 @@ class Settings(BaseSettings):
APP_NAME: str = "VibeLawyerBot" APP_NAME: str = "VibeLawyerBot"
VERSION: str = "0.1.0" VERSION: str = "0.1.0"
DEBUG: bool = True DEBUG: bool = False
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
FREE_QUESTIONS_LIMIT: int = 5 FREE_QUESTIONS_LIMIT: int = 5
PAYMENT_AMOUNT: float = 500.0 PAYMENT_AMOUNT: float = 500.0
DATABASE_URL: str = "sqlite:///data/bot.db"
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
LOG_FILE: str = "logs/bot.log" LOG_FILE: str = "logs/bot.log"
YOOKASSA_SHOP_ID: str = "1230200"
YOOKASSA_SECRET_KEY: str = "test_GVoixmlp0FqohXcyFzFHbRlAUoA3B1I2aMtAkAE_ubw" YOOKASSA_SHOP_ID: str = ""
YOOKASSA_SECRET_KEY: str = ""
YOOKASSA_RETURN_URL: str = "https://t.me/vibelawyer_bot" YOOKASSA_RETURN_URL: str = "https://t.me/vibelawyer_bot"
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
DEEPSEEK_API_KEY: Optional[str] = None DEEPSEEK_API_KEY: Optional[str] = None
DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions" DEEPSEEK_API_URL: str = "https://api.deepseek.com/v1/chat/completions"
BACKEND_URL: str = "http://localhost:8000/api/v1"
ADMIN_IDS_STR: str = "" ADMIN_IDS_STR: str = ""
@property @property
def ADMIN_IDS(self) -> List[int]: def ADMIN_IDS(self) -> List[int]:
"""Список ID администраторов из строки через запятую"""
if not self.ADMIN_IDS_STR: if not self.ADMIN_IDS_STR:
return [] return []
try: try:

View File

@ -1,20 +1,64 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select import aiohttp
from datetime import datetime, timedelta from datetime import datetime
from typing import Optional from typing import Optional
from tg_bot.infrastructure.database.models import UserModel from tg_bot.config.settings import settings
from tg_bot.infrastructure.http_client import create_http_session, normalize_backend_url
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: class UserService:
"""Сервис для работы с пользователями через API бэкенда"""
def __init__(self, session: AsyncSession): def __init__(self):
self.session = session self.backend_url = normalize_backend_url(settings.BACKEND_URL)
print(f"UserService initialized with BACKEND_URL: {self.backend_url}")
async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[UserModel]: async def get_user_by_telegram_id(self, telegram_id: int) -> Optional[User]:
result = await self.session.execute( """Получить пользователя по Telegram ID"""
select(UserModel).filter_by(telegram_id=str(telegram_id)) try:
) url = f"{self.backend_url}/users/telegram/{telegram_id}"
return result.scalar_one_or_none() async with create_http_session() as session:
async with session.get(url, ssl=False) as response:
if response.status == 200:
data = await response.json()
return User(data)
return None
except aiohttp.ClientConnectorError as e:
print(f"Backend not available at {self.backend_url}: {e}")
return None
except Exception as e:
print(f"Error getting user: {e}")
return None
async def get_or_create_user( async def get_or_create_user(
self, self,
@ -22,46 +66,64 @@ class UserService:
username: str = "", username: str = "",
first_name: str = "", first_name: str = "",
last_name: str = "" last_name: str = ""
) -> UserModel: ) -> User:
"""Получить или создать пользователя"""
user = await self.get_user_by_telegram_id(telegram_id) user = await self.get_user_by_telegram_id(telegram_id)
if not user: if not user:
user = UserModel( try:
telegram_id=str(telegram_id), async with create_http_session() as session:
username=username, async with session.post(
first_name=first_name, f"{self.backend_url}/users",
last_name=last_name json={"telegram_id": str(telegram_id), "role": "user"},
) ssl=False
self.session.add(user) ) as response:
await self.session.commit() if response.status in [200, 201]:
else: data = await response.json()
user.username = username return User(data)
user.first_name = first_name else:
user.last_name = last_name error_text = await response.text()
await self.session.commit() raise Exception(
f"Backend API returned status {response.status}: {error_text}. "
f"Make sure the backend server is running at {self.backend_url}"
)
except aiohttp.ClientConnectorError as e:
error_msg = (
f"Cannot connect to backend API at {self.backend_url}. "
f"Please ensure the backend server is running on port 8000. "
f"Start it with: cd project/backend && python run.py"
)
print(f"Error creating user: {error_msg}")
print(f"Original error: {e}")
raise ConnectionError(error_msg) from e
except Exception as e:
error_msg = f"Error creating user: {e}. Backend URL: {self.backend_url}"
print(error_msg)
raise
return user return user
async def update_user_questions(self, telegram_id: int) -> bool: 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: try:
user = await self.get_user_by_telegram_id(telegram_id) async with create_http_session() as session:
if user: async with session.post(
user.is_premium = True f"{self.backend_url}/users/telegram/{telegram_id}/increment-questions",
if user.premium_until and user.premium_until > datetime.now(): ssl=False
user.premium_until = user.premium_until + timedelta(days=30) ) as response:
else: return response.status == 200
user.premium_until = datetime.now() + timedelta(days=30) except Exception as e:
await self.session.commit() print(f"Error updating questions: {e}")
return True return False
else:
return False async def activate_premium(self, telegram_id: int, days: int = 30) -> bool:
"""Активировать premium статус"""
try:
async with create_http_session() as session:
async with session.post(
f"{self.backend_url}/users/telegram/{telegram_id}/activate-premium",
params={"days": days},
ssl=False
) as response:
return response.status == 200
except Exception as e: except Exception as e:
print(f"Error activating premium: {e}") print(f"Error activating premium: {e}")
await self.session.rollback()
return False return False

View File

@ -0,0 +1,2 @@
"""Infrastructure layer for the Telegram bot"""

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

@ -0,0 +1,88 @@
"""HTTP client utilities for making requests to the backend API"""
import aiohttp
from typing import Optional
import ssl
import os
def get_windows_host_ip() -> Optional[str]:
"""
Get the Windows host IP address when running in WSL.
In WSL2, the Windows host IP is typically the first nameserver in /etc/resolv.conf.
"""
try:
if os.path.exists("/etc/resolv.conf"):
with open("/etc/resolv.conf", "r") as f:
for line in f:
if line.startswith("nameserver"):
ip = line.split()[1]
if ip not in ["127.0.0.1", "127.0.0.53"] and not ip.startswith("fe80"):
return ip
except Exception:
pass
return None
def normalize_backend_url(url: str) -> str:
"""
Normalize backend URL for better compatibility, especially on WSL and Docker.
"""
if not ("localhost" in url or "127.0.0.1" in url):
return url
if os.path.exists("/.dockerenv"):
print(f"Warning: Running in Docker but URL contains localhost: {url}")
print("Please set BACKEND_URL environment variable in docker-compose.yml to use Docker service name (e.g., http://backend:8000/api/v1)")
return url.replace("localhost", "127.0.0.1")
try:
if os.path.exists("/proc/version"):
with open("/proc/version", "r") as f:
version_content = f.read().lower()
if "microsoft" in version_content:
windows_ip = get_windows_host_ip()
if windows_ip:
if "localhost" in url or "127.0.0.1" in url:
url = url.replace("localhost", windows_ip).replace("127.0.0.1", windows_ip)
print(f"WSL detected: Using Windows host IP {windows_ip} for backend connection")
return url
except Exception as e:
print(f"Warning: Could not detect WSL environment: {e}")
if url.startswith("http://localhost") or url.startswith("https://localhost"):
return url.replace("localhost", "127.0.0.1")
return url
def create_http_session(timeout: Optional[aiohttp.ClientTimeout] = None) -> aiohttp.ClientSession:
"""
Create a configured aiohttp ClientSession for backend API requests.
Args:
timeout: Optional timeout configuration. Defaults to 30 seconds total timeout.
Returns:
Configured aiohttp.ClientSession
"""
if timeout is None:
timeout = aiohttp.ClientTimeout(total=30, connect=10)
connector = aiohttp.TCPConnector(
ssl=False,
limit=100,
limit_per_host=30,
force_close=True,
enable_cleanup_closed=True
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
return aiohttp.ClientSession(
connector=connector,
timeout=timeout,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)

View File

@ -10,7 +10,8 @@ from tg_bot.infrastructure.telegram.handlers import (
stats_handler, stats_handler,
question_handler, question_handler,
buy_handler, buy_handler,
collection_handler collection_handler,
document_handler
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -25,17 +26,28 @@ async def create_bot() -> tuple[Bot, Dispatcher]:
dp.include_router(start_handler.router) dp.include_router(start_handler.router)
dp.include_router(help_handler.router) dp.include_router(help_handler.router)
dp.include_router(stats_handler.router) dp.include_router(stats_handler.router)
dp.include_router(question_handler.router)
dp.include_router(buy_handler.router) dp.include_router(buy_handler.router)
dp.include_router(collection_handler.router) dp.include_router(collection_handler.router)
dp.include_router(document_handler.router)
dp.include_router(question_handler.router)
return bot, dp return bot, dp
async def start_bot(): async def start_bot():
bot = None bot = None
try: 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() 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: try:
webhook_info = await bot.get_webhook_info() webhook_info = await bot.get_webhook_info()
if webhook_info.url: if webhook_info.url:

View File

@ -2,16 +2,14 @@ from aiogram import Router, types
from aiogram.filters import Command from aiogram.filters import Command
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
from decimal import Decimal from decimal import Decimal
import aiohttp
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.payment.yookassa.client import yookassa_client 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 tg_bot.domain.services.user_service import UserService
from sqlalchemy import select from datetime import datetime
import uuid
from datetime import datetime, timedelta
router = Router() router = Router()
user_service = UserService()
@router.message(Command("buy")) @router.message(Command("buy"))
@ -19,23 +17,23 @@ async def cmd_buy(message: Message):
user_id = message.from_user.id user_id = message.from_user.id
username = message.from_user.username or f"user_{user_id}" username = message.from_user.username or f"user_{user_id}"
async with AsyncSessionLocal() as session: try:
try: user = await user_service.get_user_by_telegram_id(user_id)
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(): if user and user.is_premium and user.premium_until and user.premium_until > datetime.now():
days_left = (user.premium_until - datetime.now()).days days_left = (user.premium_until - datetime.now()).days
await message.answer( await message.answer(
f"<b>У вас уже есть активная подписка!</b>\n\n" f"<b>У вас уже есть активная подписка!</b>\n\n"
f"• Статус: Premium активен\n" f"• Статус: Premium активен\n"
f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n" f"• Действует до: {user.premium_until.strftime('%d.%m.%Y')}\n"
f"• Осталось дней: {days_left}\n\n" f"• Осталось дней: {days_left}\n\n"
f"Новая подписка будет добавлена к текущей.", f"Новая подписка будет добавлена к текущей.",
parse_mode="HTML" parse_mode="HTML"
) )
except Exception: except aiohttp.ClientError as e:
pass print(f"Не удалось подключиться к backend при проверке подписки: {e}")
except Exception as e:
print(f"Ошибка при проверке подписки: {e}")
await message.answer( await message.answer(
"*Создаю ссылку для оплаты...*\n\n" "*Создаю ссылку для оплаты...*\n\n"
@ -50,23 +48,7 @@ async def cmd_buy(message: Message):
user_id=user_id user_id=user_id
) )
async with AsyncSessionLocal() as session: print(f"Платёж создан в ЮKассе: {payment_data['id']}")
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( keyboard = InlineKeyboardMarkup(
inline_keyboard=[ inline_keyboard=[
@ -139,27 +121,15 @@ async def check_payment_status(callback_query: types.CallbackQuery):
payment = YooPayment.find_one(yookassa_id) payment = YooPayment.find_one(yookassa_id)
if payment.status == "succeeded": if payment.status == "succeeded":
async with AsyncSessionLocal() as session: try:
try: success = await user_service.activate_premium(user_id)
result = await session.execute( if success:
select(PaymentModel).filter_by(yookassa_payment_id=yookassa_id) user = await user_service.get_user_by_telegram_id(user_id)
) if user:
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( await callback_query.message.answer(
"<b>Оплата подтверждена!</b>\n\n" "<b>Оплата подтверждена!</b>\n\n"
f"Ваш premium-доступ активирован до: " 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" "• Задавать неограниченное количество вопросов\n"
"• Получать приоритетные ответы\n" "• Получать приоритетные ответы\n"
@ -169,12 +139,23 @@ async def check_payment_status(callback_query: types.CallbackQuery):
) )
else: else:
await callback_query.message.answer( await callback_query.message.answer(
"<b>Платёж найден в ЮKассе, но не в нашей БД</b>\n\n" "<b>Оплата подтверждена, но не удалось активировать premium</b>\n\n"
"Пожалуйста, обратитесь к администратору.", "Пожалуйста, обратитесь к администратору.",
parse_mode="HTML" parse_mode="HTML"
) )
except Exception as e: else:
print(f"Ошибка обработки платежа: {e}") 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": elif payment.status == "pending":
await callback_query.message.answer( await callback_query.message.answer(
@ -206,42 +187,13 @@ async def check_payment_status(callback_query: types.CallbackQuery):
@router.message(Command("mypayments")) @router.message(Command("mypayments"))
async def cmd_my_payments(message: Message): async def cmd_my_payments(message: Message):
user_id = message.from_user.id await message.answer(
"<b>История платежей</b>\n\n"
async with AsyncSessionLocal() as session: "История платежей хранится в системе оплаты ЮKassa.\n"
try: "Для проверки статуса подписки используйте команду /stats.\n\n"
result = await session.execute( "Для оформления новой подписки используйте команду /buy",
select(PaymentModel).filter_by(user_id=user_id).order_by(PaymentModel.created_at.desc()).limit(10) parse_mode="HTML"
) )
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")) @router.message(Command("testcards"))

View File

@ -1,18 +1,22 @@
from aiogram import Router from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.filters import Command from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
import aiohttp import aiohttp
from tg_bot.config.settings import settings
from tg_bot.infrastructure.telegram.states.collection_states import (
CollectionAccessStates,
CollectionEditStates
)
router = Router() router = Router()
BACKEND_URL = "http://localhost:8001/api/v1"
async def get_user_collections(telegram_id: str): async def get_user_collections(telegram_id: str):
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{BACKEND_URL}/collections/", f"{settings.BACKEND_URL}/collections/",
headers={"X-Telegram-ID": telegram_id} headers={"X-Telegram-ID": telegram_id}
) as response: ) as response:
if response.status == 200: if response.status == 200:
@ -25,16 +29,29 @@ async def get_user_collections(telegram_id: str):
async def get_collection_documents(collection_id: str, telegram_id: str): async def get_collection_documents(collection_id: str, telegram_id: str):
try: try:
collection_id = str(collection_id).strip()
url = f"{settings.BACKEND_URL}/documents/collection/{collection_id}"
print(f"DEBUG get_collection_documents: URL={url}, collection_id={collection_id}, telegram_id={telegram_id}")
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{BACKEND_URL}/documents/collection/{collection_id}", url,
headers={"X-Telegram-ID": telegram_id} headers={"X-Telegram-ID": telegram_id}
) as response: ) as response:
if response.status == 200: if response.status == 200:
return await response.json() return await response.json()
return [] elif response.status == 422:
error_text = await response.text()
print(f"Validation error getting documents: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}")
return []
else:
error_text = await response.text()
print(f"Error getting documents: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}")
return []
except Exception as e: except Exception as e:
print(f"Error getting documents: {e}") print(f"Exception getting documents: {e}, collection_id: {collection_id}, type: {type(collection_id)}")
import traceback
traceback.print_exc()
return [] return []
@ -42,7 +59,7 @@ async def search_in_collection(collection_id: str, query: str, telegram_id: str)
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{BACKEND_URL}/documents/collection/{collection_id}", f"{settings.BACKEND_URL}/documents/collection/{collection_id}",
params={"search": query}, params={"search": query},
headers={"X-Telegram-ID": telegram_id} headers={"X-Telegram-ID": telegram_id}
) as response: ) as response:
@ -54,6 +71,91 @@ async def search_in_collection(collection_id: str, query: str, telegram_id: str)
return [] return []
async def get_collection_info(collection_id: str, telegram_id: str):
"""Получить информацию о коллекции"""
try:
collection_id = str(collection_id).strip()
url = f"{settings.BACKEND_URL}/collections/{collection_id}"
print(f"DEBUG get_collection_info: URL={url}, collection_id={collection_id}, telegram_id={telegram_id}")
async with aiohttp.ClientSession() as session:
async with session.get(
url,
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
return await response.json()
elif response.status == 422:
error_text = await response.text()
print(f"Validation error getting collection info: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}")
return None
else:
error_text = await response.text()
print(f"Error getting collection info: {response.status} - {error_text}, collection_id: {collection_id}, URL: {url}")
return None
except Exception as e:
print(f"Exception getting collection info: {e}, collection_id: {collection_id}, type: {type(collection_id)}")
import traceback
traceback.print_exc()
return None
async def get_collection_access_list(collection_id: str, telegram_id: str):
"""Получить список пользователей с доступом к коллекции"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{settings.BACKEND_URL}/collections/{collection_id}/access",
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 access list: {e}")
return []
async def grant_collection_access(collection_id: str, telegram_id: str, owner_telegram_id: str):
"""Предоставить доступ к коллекции"""
try:
url = f"{settings.BACKEND_URL}/collections/{collection_id}/access/telegram/{telegram_id}"
print(f"DEBUG grant_collection_access: URL={url}, target_telegram_id={telegram_id}, owner_telegram_id={owner_telegram_id}")
async with aiohttp.ClientSession() as session:
async with session.post(
url,
headers={"X-Telegram-ID": owner_telegram_id}
) as response:
if response.status == 201:
result = await response.json()
print(f"DEBUG: Access granted successfully: {result}")
return result
else:
error_text = await response.text()
print(f"ERROR granting access: status={response.status}, error={error_text}, target_telegram_id={telegram_id}")
return None
except Exception as e:
print(f"Exception granting access: {e}, target_telegram_id={telegram_id}")
import traceback
traceback.print_exc()
return None
async def revoke_collection_access(collection_id: str, telegram_id: str, owner_telegram_id: str):
"""Отозвать доступ к коллекции"""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{settings.BACKEND_URL}/collections/{collection_id}/access/telegram/{telegram_id}",
headers={"X-Telegram-ID": owner_telegram_id}
) as response:
return response.status == 204
except Exception as e:
print(f"Error revoking access: {e}")
return False
@router.message(Command("mycollections")) @router.message(Command("mycollections"))
async def cmd_mycollections(message: Message): async def cmd_mycollections(message: Message):
telegram_id = str(message.from_user.id) telegram_id = str(message.from_user.id)
@ -148,26 +250,133 @@ async def cmd_search(message: Message):
await message.answer(response, parse_mode="HTML") await message.answer(response, parse_mode="HTML")
@router.callback_query(lambda c: c.data.startswith("collection:")) @router.callback_query(lambda c: c.data.startswith("collection:") and not c.data.startswith("collection:documents:") and not c.data.startswith("collection:edit:") and not c.data.startswith("collection:access:") and not c.data.startswith("collection:view_access:"))
async def show_collection_documents(callback: CallbackQuery): async def show_collection_menu(callback: CallbackQuery):
collection_id = callback.data.split(":")[1] """Показать меню коллекции с опциями в зависимости от прав"""
parts = callback.data.split(":", 1)
if len(parts) < 2:
await callback.message.answer(
"<b>Ошибка</b>\n\nНеверный формат данных.",
parse_mode="HTML"
)
await callback.answer()
return
collection_id = parts[1]
telegram_id = str(callback.from_user.id) telegram_id = str(callback.from_user.id)
await callback.answer("Загружаю документы...") print(f"DEBUG: collection_id from callback (menu): {collection_id}, callback_data: {callback.data}")
documents = await get_collection_documents(collection_id, telegram_id) await callback.answer("Загружаю информацию...")
if not documents: collection_info = await get_collection_info(collection_id, telegram_id)
if not collection_info:
await callback.message.answer( await callback.message.answer(
f"<b>Коллекция пуста</b>\n\n" "<b>Ошибка</b>\n\nНе удалось загрузить информацию о коллекции.",
f"В этой коллекции пока нет документов.\n"
f"Обратитесь к администратору для добавления документов.",
parse_mode="HTML" parse_mode="HTML"
) )
return return
owner_id = collection_info.get("owner_id")
collection_name = collection_info.get("name", "Коллекция")
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{settings.BACKEND_URL}/users/telegram/{telegram_id}"
) as response:
if response.status == 200:
user_info = await response.json()
current_user_id = user_info.get("user_id")
is_owner = str(owner_id) == str(current_user_id)
else:
is_owner = False
except:
is_owner = False
keyboard_buttons = []
collection_id_str = str(collection_id)
if is_owner:
keyboard_buttons = [
[InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")],
[InlineKeyboardButton(text="Редактировать коллекцию", callback_data=f"collection:edit:{collection_id_str}")],
[InlineKeyboardButton(text="Управление доступом", callback_data=f"collection:access:{collection_id_str}")],
[InlineKeyboardButton(text="Загрузить документ", callback_data=f"document:upload:{collection_id_str}")],
[InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")]
]
else:
keyboard_buttons = [
[InlineKeyboardButton(text="Просмотр документов", callback_data=f"collection:documents:{collection_id_str}")],
[InlineKeyboardButton(text="Просмотр доступа", callback_data=f"collection:view_access:{collection_id_str}")],
[InlineKeyboardButton(text="Назад к коллекциям", callback_data="collections:list")]
]
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
role_text = "<b>Владелец</b>" if is_owner else "<b>Доступ</b>"
response = f"<b>{collection_name}</b>\n\n"
response += f"{role_text}\n\n"
response += f"ID: <code>{collection_id}</code>\n\n"
response += "Выберите действие:"
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
@router.callback_query(lambda c: c.data.startswith("collection:documents:"))
async def show_collection_documents(callback: CallbackQuery):
"""Показать документы коллекции"""
try:
parts = callback.data.split(":", 2)
if len(parts) < 3:
raise ValueError("Неверный формат callback_data")
collection_id = parts[2]
telegram_id = str(callback.from_user.id)
print(f"DEBUG: collection_id from callback: {collection_id}, callback_data: {callback.data}")
await callback.answer("Загружаю документы...")
collection_info = await get_collection_info(collection_id, telegram_id)
if not collection_info:
await callback.message.answer(
"<b>Ошибка</b>\n\nНе удалось загрузить информацию о коллекции. Проверьте, что у вас есть доступ к этой коллекции.",
parse_mode="HTML"
)
return
documents = await get_collection_documents(collection_id, telegram_id)
if not documents:
await callback.message.answer(
f"<b>Коллекция пуста</b>\n\n"
f"В этой коллекции пока нет документов.",
parse_mode="HTML"
)
return
except IndexError:
await callback.message.answer(
"<b>Ошибка</b>\n\nНеверный формат данных.",
parse_mode="HTML"
)
await callback.answer()
return
except Exception as e:
print(f"Error in show_collection_documents: {e}")
await callback.message.answer(
f"<b>Ошибка</b>\n\nПроизошла ошибка при загрузке документов: {str(e)}",
parse_mode="HTML"
)
await callback.answer()
return
response = f"<b>Документы в коллекции:</b>\n\n" response = f"<b>Документы в коллекции:</b>\n\n"
keyboard_buttons = []
for i, doc in enumerate(documents[:10], 1): for i, doc in enumerate(documents[:10], 1):
doc_id = doc.get("document_id")
title = doc.get("title", "Без названия") title = doc.get("title", "Без названия")
content_preview = doc.get("content", "")[:100] content_preview = doc.get("content", "")[:100]
response += f"{i}. <b>{title}</b>\n" response += f"{i}. <b>{title}</b>\n"
@ -175,9 +384,361 @@ async def show_collection_documents(callback: CallbackQuery):
response += f" <i>{content_preview}...</i>\n" response += f" <i>{content_preview}...</i>\n"
response += "\n" response += "\n"
keyboard_buttons.append([
InlineKeyboardButton(
text=f"{title[:30]}",
callback_data=f"document:view:{doc_id}"
)
])
if len(documents) > 10: if len(documents) > 10:
response += f"\n<i>Показано 10 из {len(documents)} документов</i>" response += f"\n<i>Показано 10 из {len(documents)} документов</i>"
await callback.message.answer(response, parse_mode="HTML")
collection_id_for_back = str(collection_info.get("collection_id", collection_id))
keyboard_buttons.append([
InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id_for_back}")
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
@router.callback_query(lambda c: c.data.startswith("collection:access:"))
async def show_access_management(callback: CallbackQuery):
"""Показать меню управления доступом (только для владельца)"""
collection_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await callback.answer("Загружаю список доступа...")
access_list = await get_collection_access_list(collection_id, telegram_id)
response = "<b>Управление доступом</b>\n\n"
response += "<b>Пользователи с доступом:</b>\n\n"
keyboard_buttons = []
if access_list:
for i, access in enumerate(access_list[:10], 1):
user = access.get("user", {})
user_telegram_id = user.get("telegram_id", "N/A")
role = user.get("role", "user")
response += f"{i}. <code>{user_telegram_id}</code> ({role})\n"
keyboard_buttons.append([
InlineKeyboardButton(
text=f" Удалить {user_telegram_id}",
callback_data=f"access:remove:{collection_id}:{user_telegram_id}"
)
])
else:
response += "<i>Нет пользователей с доступом</i>\n\n"
keyboard_buttons.extend([
[InlineKeyboardButton(text="Добавить доступ", callback_data=f"access:add:{collection_id}")],
[InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}")]
])
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
@router.callback_query(lambda c: c.data.startswith("collection:view_access:"))
async def show_access_list(callback: CallbackQuery):
"""Показать список пользователей с доступом (read-only для пользователей с доступом)"""
collection_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await callback.answer("Загружаю список доступа...")
access_list = await get_collection_access_list(collection_id, telegram_id)
response = "<b>Пользователи с доступом</b>\n\n"
if access_list:
for i, access in enumerate(access_list[:20], 1):
user = access.get("user", {})
user_telegram_id = user.get("telegram_id", "N/A")
role = user.get("role", "user")
response += f"{i}. <code>{user_telegram_id}</code> ({role})\n"
else:
response += "<i>Нет пользователей с доступом</i>\n"
keyboard = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="Назад", callback_data=f"collection:{collection_id}")
]])
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
@router.callback_query(lambda c: c.data.startswith("access:add:"))
async def add_access_prompt(callback: CallbackQuery, state: FSMContext):
"""Запросить пересылку сообщения для добавления доступа"""
collection_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await state.update_data(collection_id=collection_id)
await state.set_state(CollectionAccessStates.waiting_for_username)
await callback.message.answer(
"<b>Добавить доступ</b>\n\n"
"Перешлите любое сообщение от пользователя, которому нужно предоставить доступ.\n\n"
"<i>Просто перешлите сообщение от нужного пользователя.</i>",
parse_mode="HTML"
)
await callback.answer()
@router.message(StateFilter(CollectionAccessStates.waiting_for_username))
async def process_add_access(message: Message, state: FSMContext):
"""Обработать добавление доступа через пересылку сообщения"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
collection_id = data.get("collection_id")
if not collection_id:
await message.answer("Ошибка: не указана коллекция")
await state.clear()
return
target_telegram_id = None
if message.forward_from:
target_telegram_id = str(message.forward_from.id)
elif message.forward_from_chat:
await message.answer(
"<b>Ошибка</b>\n\n"
"Пожалуйста, перешлите сообщение от пользователя, а не из группы или канала.",
parse_mode="HTML"
)
await state.clear()
return
elif message.forward_date:
await message.answer(
"<b>Информация о пересылке скрыта</b>\n\n"
"Пользователь скрыл информацию о пересылке в настройках приватности Telegram.\n\n"
"Попросите пользователя временно разрешить пересылку сообщений.",
parse_mode="HTML"
)
await state.clear()
return
else:
await message.answer(
"<b>Ошибка</b>\n\n"
"Пожалуйста, перешлите сообщение от пользователя, которому нужно предоставить доступ.\n\n"
"<i>Просто перешлите любое сообщение от нужного пользователя.</i>",
parse_mode="HTML"
)
await state.clear()
return
if not target_telegram_id:
await message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось определить Telegram ID пользователя.",
parse_mode="HTML"
)
await state.clear()
return
print(f"DEBUG: Attempting to grant access: collection_id={collection_id}, target_telegram_id={target_telegram_id}, owner_telegram_id={telegram_id}")
result = await grant_collection_access(collection_id, target_telegram_id, telegram_id)
if result:
user_info = ""
if message.forward_from:
user_name = message.forward_from.first_name or ""
user_username = f"@{message.forward_from.username}" if message.forward_from.username else ""
user_info = f"{user_name} {user_username}".strip() or target_telegram_id
else:
user_info = target_telegram_id
await message.answer(
f"<b>Доступ предоставлен</b>\n\n"
f"Пользователю <code>{target_telegram_id}</code> предоставлен доступ к коллекции.\n\n"
f"Пользователь: {user_info}\n\n"
f"<i>Примечание: Если пользователь еще не взаимодействовал с ботом, он был автоматически создан в системе.</i>",
parse_mode="HTML"
)
else:
await message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось предоставить доступ. Возможно:\n"
"• Доступ уже предоставлен\n"
"• Произошла ошибка на сервере\n"
"• Вы не являетесь владельцем коллекции\n\n"
"Проверьте логи сервера для получения подробной информации.",
parse_mode="HTML"
)
await state.clear()
@router.callback_query(lambda c: c.data.startswith("access:remove:"))
async def remove_access(callback: CallbackQuery):
"""Удалить доступ пользователя"""
parts = callback.data.split(":")
collection_id = parts[2]
target_telegram_id = parts[3]
owner_telegram_id = str(callback.from_user.id)
await callback.answer("Удаляю доступ...")
result = await revoke_collection_access(collection_id, target_telegram_id, owner_telegram_id)
if result:
await callback.message.answer(
f"<b>Доступ отозван</b>\n\n"
f"Доступ пользователя <code>{target_telegram_id}</code> отозван.",
parse_mode="HTML"
)
else:
await callback.message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось отозвать доступ.",
parse_mode="HTML"
)
@router.callback_query(lambda c: c.data.startswith("collection:edit:"))
async def edit_collection_prompt(callback: CallbackQuery, state: FSMContext):
"""Запросить данные для редактирования коллекции"""
collection_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
collection_info = await get_collection_info(collection_id, telegram_id)
if not collection_info:
await callback.message.answer(
"<b>Ошибка</b>\n\nНе удалось загрузить информацию о коллекции.",
parse_mode="HTML"
)
await callback.answer()
return
await state.update_data(collection_id=collection_id)
await state.set_state(CollectionEditStates.waiting_for_name)
await callback.message.answer(
"<b>Редактирование коллекции</b>\n\n"
"Отправьте новое название коллекции или /skip чтобы оставить текущее.\n\n"
f"Текущее название: <b>{collection_info.get('name', 'Без названия')}</b>",
parse_mode="HTML"
)
await callback.answer()
@router.message(StateFilter(CollectionEditStates.waiting_for_name))
async def process_edit_collection_name(message: Message, state: FSMContext):
"""Обработать новое название коллекции"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
collection_id = data.get("collection_id")
if message.text and message.text.strip() == "/skip":
new_name = None
else:
new_name = message.text.strip() if message.text else None
await state.update_data(name=new_name)
await state.set_state(CollectionEditStates.waiting_for_description)
collection_info = await get_collection_info(collection_id, telegram_id)
current_description = collection_info.get("description", "") if collection_info else ""
await message.answer(
"<b>Описание коллекции</b>\n\n"
"Отправьте новое описание коллекции или /skip чтобы оставить текущее.\n\n"
f"Текущее описание: <i>{current_description[:100] if current_description else 'Нет описания'}...</i>",
parse_mode="HTML"
)
@router.message(StateFilter(CollectionEditStates.waiting_for_description))
async def process_edit_collection_description(message: Message, state: FSMContext):
"""Обработать новое описание коллекции"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
collection_id = data.get("collection_id")
name = data.get("name")
if message.text and message.text.strip() == "/skip":
new_description = None
else:
new_description = message.text.strip() if message.text else None
try:
update_data = {}
if name:
update_data["name"] = name
if new_description:
update_data["description"] = new_description
async with aiohttp.ClientSession() as session:
async with session.put(
f"{settings.BACKEND_URL}/collections/{collection_id}",
json=update_data,
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
await message.answer(
"<b>Коллекция обновлена</b>\n\n"
"Изменения сохранены.",
parse_mode="HTML"
)
else:
error_text = await response.text()
await message.answer(
f"<b>Ошибка</b>\n\n"
f"Не удалось обновить коллекцию: {error_text}",
parse_mode="HTML"
)
except Exception as e:
await message.answer(
f"<b>Ошибка</b>\n\n"
f"Произошла ошибка: {str(e)}",
parse_mode="HTML"
)
await state.clear()
@router.callback_query(lambda c: c.data == "collections:list")
async def back_to_collections(callback: CallbackQuery):
"""Вернуться к списку коллекций"""
telegram_id = str(callback.from_user.id)
collections = await get_user_collections(telegram_id)
if not collections:
await callback.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 callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)

View File

@ -0,0 +1,381 @@
"""
Обработчики для работы с документами
"""
from aiogram import Router, F
from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, CallbackQuery
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
import aiohttp
from tg_bot.config.settings import settings
from tg_bot.infrastructure.telegram.states.collection_states import (
DocumentEditStates,
DocumentUploadStates
)
router = Router()
async def get_document_info(document_id: str, telegram_id: str):
"""Получить информацию о документе"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{settings.BACKEND_URL}/documents/{document_id}",
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
return await response.json()
return None
except Exception as e:
print(f"Error getting document info: {e}")
return None
async def delete_document(document_id: str, telegram_id: str):
"""Удалить документ"""
try:
async with aiohttp.ClientSession() as session:
async with session.delete(
f"{settings.BACKEND_URL}/documents/{document_id}",
headers={"X-Telegram-ID": telegram_id}
) as response:
return response.status == 204
except Exception as e:
print(f"Error deleting document: {e}")
return False
async def update_document(document_id: str, telegram_id: str, title: str = None, content: str = None):
"""Обновить документ"""
try:
update_data = {}
if title:
update_data["title"] = title
if content:
update_data["content"] = content
async with aiohttp.ClientSession() as session:
async with session.put(
f"{settings.BACKEND_URL}/documents/{document_id}",
json=update_data,
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 200:
return await response.json()
return None
except Exception as e:
print(f"Error updating document: {e}")
return None
async def upload_document_to_collection(collection_id: str, file_data: bytes, filename: str, telegram_id: str):
"""Загрузить документ в коллекцию"""
try:
async with aiohttp.ClientSession() as session:
form_data = aiohttp.FormData()
form_data.add_field('file', file_data, filename=filename, content_type='application/octet-stream')
async with session.post(
f"{settings.BACKEND_URL}/documents/upload?collection_id={collection_id}",
data=form_data,
headers={"X-Telegram-ID": telegram_id}
) as response:
if response.status == 201:
return await response.json()
else:
error_text = await response.text()
print(f"Upload error: {response.status} - {error_text}")
return None
except Exception as e:
print(f"Error uploading document: {e}")
return None
@router.callback_query(lambda c: c.data.startswith("document:view:"))
async def view_document(callback: CallbackQuery):
"""Просмотр документа"""
document_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await callback.answer("Загружаю документ...")
document = await get_document_info(document_id, telegram_id)
if not document:
await callback.message.answer(
"<b>Ошибка</b>\n\nНе удалось загрузить документ.",
parse_mode="HTML"
)
return
title = document.get("title", "Без названия")
content = document.get("content", "")
collection_id = document.get("collection_id")
content_preview = content[:2000] if len(content) > 2000 else content
has_more = len(content) > 2000
response = f"<b>{title}</b>\n\n"
response += f"<i>{content_preview}</i>"
if has_more:
response += "\n\n<i>...</i>"
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{settings.BACKEND_URL}/collections/{collection_id}",
headers={"X-Telegram-ID": telegram_id}
) as response_collection:
if response_collection.status == 200:
collection_info = await response_collection.json()
owner_id = collection_info.get("owner_id")
async with session.get(
f"{settings.BACKEND_URL}/users/telegram/{telegram_id}"
) as response_user:
if response_user.status == 200:
user_info = await response_user.json()
current_user_id = user_info.get("user_id")
is_owner = str(owner_id) == str(current_user_id)
keyboard_buttons = []
if is_owner:
keyboard_buttons = [
[InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")],
[InlineKeyboardButton(text="Удалить", callback_data=f"document:delete:{document_id}")],
[InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")]
]
else:
keyboard_buttons = [
[InlineKeyboardButton(text="Редактировать", callback_data=f"document:edit:{document_id}")],
[InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")]
]
keyboard = InlineKeyboardMarkup(inline_keyboard=keyboard_buttons)
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
return
except:
pass
keyboard = InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="Назад", callback_data=f"collection:documents:{collection_id}")
]])
await callback.message.answer(response, parse_mode="HTML", reply_markup=keyboard)
@router.callback_query(lambda c: c.data.startswith("document:edit:"))
async def edit_document_prompt(callback: CallbackQuery, state: FSMContext):
"""Запросить данные для редактирования документа"""
document_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
document = await get_document_info(document_id, telegram_id)
if not document:
await callback.message.answer(
"<b>Ошибка</b>\n\nНе удалось загрузить документ.",
parse_mode="HTML"
)
await callback.answer()
return
await state.update_data(document_id=document_id)
await state.set_state(DocumentEditStates.waiting_for_title)
await callback.message.answer(
"<b>Редактирование документа</b>\n\n"
"Отправьте новое название документа или /skip чтобы оставить текущее.\n\n"
f"Текущее название: <b>{document.get('title', 'Без названия')}</b>",
parse_mode="HTML"
)
await callback.answer()
@router.message(StateFilter(DocumentEditStates.waiting_for_title))
async def process_edit_title(message: Message, state: FSMContext):
"""Обработать новое название документа"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
document_id = data.get("document_id")
if message.text and message.text.strip() == "/skip":
new_title = None
else:
new_title = message.text.strip() if message.text else None
await state.update_data(title=new_title)
await state.set_state(DocumentEditStates.waiting_for_content)
await message.answer(
"<b>Содержимое документа</b>\n\n"
"Отправьте новое содержимое документа или /skip чтобы оставить текущее.",
parse_mode="HTML"
)
@router.message(StateFilter(DocumentEditStates.waiting_for_content))
async def process_edit_content(message: Message, state: FSMContext):
"""Обработать новое содержимое документа"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
document_id = data.get("document_id")
title = data.get("title")
if message.text and message.text.strip() == "/skip":
new_content = None
else:
new_content = message.text.strip() if message.text else None
result = await update_document(document_id, telegram_id, title=title, content=new_content)
if result:
await message.answer(
"<b>Документ обновлен</b>\n\n"
"Изменения сохранены.",
parse_mode="HTML"
)
else:
await message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось обновить документ.",
parse_mode="HTML"
)
await state.clear()
@router.callback_query(lambda c: c.data.startswith("document:delete:"))
async def delete_document_confirm(callback: CallbackQuery):
"""Подтверждение удаления документа"""
document_id = callback.data.split(":")[2]
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Да, удалить", callback_data=f"document:delete_confirm:{document_id}")],
[InlineKeyboardButton(text="Отмена", callback_data=f"document:view:{document_id}")]
])
await callback.message.answer(
"<b>Подтверждение удаления</b>\n\n"
"Вы уверены, что хотите удалить этот документ?",
parse_mode="HTML",
reply_markup=keyboard
)
await callback.answer()
@router.callback_query(lambda c: c.data.startswith("document:delete_confirm:"))
async def delete_document_execute(callback: CallbackQuery):
"""Выполнить удаление документа"""
document_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await callback.answer("Удаляю документ...")
# Получаем информацию о документе для возврата к коллекции
document = await get_document_info(document_id, telegram_id)
collection_id = document.get("collection_id") if document else None
result = await delete_document(document_id, telegram_id)
if result:
await callback.message.answer(
"<b>Документ удален</b>",
parse_mode="HTML"
)
else:
await callback.message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось удалить документ.",
parse_mode="HTML"
)
@router.callback_query(lambda c: c.data.startswith("document:upload:"))
async def upload_document_prompt(callback: CallbackQuery, state: FSMContext):
"""Запросить файл для загрузки"""
collection_id = callback.data.split(":")[2]
telegram_id = str(callback.from_user.id)
await state.update_data(collection_id=collection_id)
await state.set_state(DocumentUploadStates.waiting_for_file)
await callback.message.answer(
"<b>Загрузка документа</b>\n\n"
"Отправьте файл (PDF, PNG, JPG, JPEG, TIFF, BMP).\n\n"
"Поддерживаемые форматы:\n"
"• PDF\n"
"• Изображения: PNG, JPG, JPEG, TIFF, BMP",
parse_mode="HTML"
)
await callback.answer()
@router.message(StateFilter(DocumentUploadStates.waiting_for_file), F.document | F.photo)
async def process_upload_document(message: Message, state: FSMContext):
"""Обработать загрузку документа"""
telegram_id = str(message.from_user.id)
data = await state.get_data()
collection_id = data.get("collection_id")
if not collection_id:
await message.answer("Ошибка: не указана коллекция")
await state.clear()
return
file_id = None
filename = None
if message.document:
file_id = message.document.file_id
filename = message.document.file_name or "document.pdf"
supported_extensions = ['.pdf', '.png', '.jpg', '.jpeg', '.tiff', '.bmp']
file_ext = filename.lower().rsplit('.', 1)[-1] if '.' in filename else ''
if f'.{file_ext}' not in supported_extensions:
await message.answer(
"<b>Ошибка</b>\n\n"
f"Неподдерживаемый формат файла: {file_ext}\n\n"
"Поддерживаются: PDF, PNG, JPG, JPEG, TIFF, BMP",
parse_mode="HTML"
)
await state.clear()
return
elif message.photo:
file_id = message.photo[-1].file_id
filename = "photo.jpg"
else:
await message.answer(
"<b>Ошибка</b>\n\n"
"Пожалуйста, отправьте файл (PDF или изображение).",
parse_mode="HTML"
)
await state.clear()
return
try:
file = await message.bot.get_file(file_id)
file_data = await message.bot.download_file(file.file_path)
file_bytes = file_data.read()
result = await upload_document_to_collection(collection_id, file_bytes, filename, telegram_id)
if result:
await message.answer(
f"<b>Документ загружен</b>\n\n"
f"Название: <b>{result.get('title', filename)}</b>",
parse_mode="HTML"
)
else:
await message.answer(
"<b>Ошибка</b>\n\n"
"Не удалось загрузить документ.",
parse_mode="HTML"
)
except Exception as e:
print(f"Error uploading document: {e}")
await message.answer(
"<b>Ошибка</b>\n\n"
f"Произошла ошибка при загрузке: {str(e)}",
parse_mode="HTML"
)
await state.clear()

View File

@ -3,14 +3,12 @@ from aiogram.types import Message
from datetime import datetime from datetime import datetime
import aiohttp import aiohttp
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.infrastructure.database.database import AsyncSessionLocal from tg_bot.domain.services.user_service import UserService, User
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 from tg_bot.application.services.rag_service import RAGService
router = Router() router = Router()
BACKEND_URL = "http://localhost:8001/api/v1"
rag_service = RAGService() rag_service = RAGService()
user_service = UserService()
@router.message() @router.message()
async def handle_question(message: Message): async def handle_question(message: Message):
@ -19,58 +17,37 @@ async def handle_question(message: Message):
if question_text.startswith('/'): if question_text.startswith('/'):
return return
async with AsyncSessionLocal() as session: try:
try: user = await user_service.get_user_by_telegram_id(user_id)
user_service = UserService(session)
user = await user_service.get_user_by_telegram_id(user_id)
if not user: if not user:
user = await user_service.get_or_create_user( user = await user_service.get_or_create_user(
user_id, user_id,
message.from_user.username or "", message.from_user.username or "",
message.from_user.first_name or "", message.from_user.first_name or "",
message.from_user.last_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"
) )
if user.is_premium:
await process_premium_question(message, user, question_text)
async def ensure_user_in_backend(telegram_id: str, telegram_user): elif user.questions_used < settings.FREE_QUESTIONS_LIMIT:
try: await process_free_question(message, user, question_text)
async with aiohttp.ClientSession() as session:
async with session.get( else:
f"{BACKEND_URL}/users/telegram/{telegram_id}" await handle_limit_exceeded(message, user)
) 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: except Exception as e:
print(f"Error creating user in backend: {e}") print(f"Error processing question: {e}")
await message.answer(
"Произошла ошибка. Попробуйте позже.",
parse_mode="HTML"
)
async def process_premium_question(message: Message, user: UserModel, question_text: str, user_service: UserService): async def process_premium_question(message: Message, user: User, question_text: str):
await user_service.update_user_questions(user.telegram_id) 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") 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") await message.answer(response, parse_mode="HTML")
async def process_free_question(message: Message, user: UserModel, question_text: str, user_service: UserService): async def process_free_question(message: Message, user: User, question_text: str):
await user_service.update_user_questions(user.telegram_id) await user_service.update_user_questions(int(user.telegram_id))
user = await user_service.get_user_by_telegram_id(user.telegram_id) user = await user_service.get_user_by_telegram_id(int(user.telegram_id))
remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used remaining = settings.FREE_QUESTIONS_LIMIT - user.questions_used
await message.bot.send_chat_action(message.chat.id, "typing") 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): async def save_conversation_to_backend(telegram_id: str, question: str, answer: str, sources: list):
try: try:
from tg_bot.config.settings import settings
backend_url = settings.BACKEND_URL
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
async with session.get( async with session.get(
f"{BACKEND_URL}/users/telegram/{telegram_id}" f"{backend_url}/users/telegram/{telegram_id}"
) as user_response: ) as user_response:
if user_response.status != 200: if user_response.status != 200:
return return
@ -211,7 +190,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
user_uuid = user_data.get("user_id") user_uuid = user_data.get("user_id")
async with session.get( async with session.get(
f"{BACKEND_URL}/collections/", f"{backend_url}/collections/",
headers={"X-Telegram-ID": telegram_id} headers={"X-Telegram-ID": telegram_id}
) as collections_response: ) as collections_response:
collections = [] collections = []
@ -223,7 +202,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
collection_id = collections[0].get("collection_id") collection_id = collections[0].get("collection_id")
else: else:
async with session.post( async with session.post(
f"{BACKEND_URL}/collections", f"{backend_url}/collections",
json={ json={
"name": "Основная коллекция", "name": "Основная коллекция",
"description": "Коллекция по умолчанию", "description": "Коллекция по умолчанию",
@ -239,7 +218,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
return return
async with session.post( async with session.post(
f"{BACKEND_URL}/conversations", f"{backend_url}/conversations",
json={"collection_id": str(collection_id)}, json={"collection_id": str(collection_id)},
headers={"X-Telegram-ID": telegram_id} headers={"X-Telegram-ID": telegram_id}
) as conversation_response: ) as conversation_response:
@ -252,7 +231,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
return return
await session.post( await session.post(
f"{BACKEND_URL}/messages", f"{backend_url}/messages",
json={ json={
"conversation_id": str(conversation_id), "conversation_id": str(conversation_id),
"content": question, "content": question,
@ -262,7 +241,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
) )
await session.post( await session.post(
f"{BACKEND_URL}/messages", f"{backend_url}/messages",
json={ json={
"conversation_id": str(conversation_id), "conversation_id": str(conversation_id),
"content": answer, "content": answer,
@ -276,7 +255,7 @@ async def save_conversation_to_backend(telegram_id: str, question: str, answer:
print(f"Error saving conversation: {e}") 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 = ( response = (
f"<b>Лимит бесплатных вопросов исчерпан!</b>\n\n" f"<b>Лимит бесплатных вопросов исчерпан!</b>\n\n"

View File

@ -4,10 +4,10 @@ from aiogram.types import Message
from datetime import datetime from datetime import datetime
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.infrastructure.database.database import AsyncSessionLocal
from tg_bot.domain.services.user_service import UserService from tg_bot.domain.services.user_service import UserService
router = Router() router = Router()
user_service = UserService()
@router.message(Command("start")) @router.message(Command("start"))
async def cmd_start(message: Message): async def cmd_start(message: Message):
@ -16,22 +16,19 @@ async def cmd_start(message: Message):
username = message.from_user.username or "" username = message.from_user.username or ""
first_name = message.from_user.first_name or "" first_name = message.from_user.first_name or ""
last_name = message.from_user.last_name or "" last_name = message.from_user.last_name or ""
async with AsyncSessionLocal() as session: try:
try: existing_user = await user_service.get_user_by_telegram_id(user_id)
user_service = UserService(session) user = await user_service.get_or_create_user(
existing_user = await user_service.get_user_by_telegram_id(user_id) user_id,
user = await user_service.get_or_create_user( username,
user_id, first_name,
username, last_name
first_name, )
last_name if not existing_user:
) print(f"Новый пользователь: {user_id}")
if not existing_user:
print(f"Новый пользователь: {user_id}")
except Exception as e: except Exception as e:
print(f"Ошибка сохранения пользователя: {e}") print(f"Ошибка сохранения пользователя: {e}")
await session.rollback()
welcome_text = ( welcome_text = (
f"<b>Привет, {first_name}!</b>\n\n" f"<b>Привет, {first_name}!</b>\n\n"
f"Я <b>VibeLawyerBot</b> - ваш помощник в юридических вопросах.\n\n" f"Я <b>VibeLawyerBot</b> - ваш помощник в юридических вопросах.\n\n"

View File

@ -4,58 +4,56 @@ from aiogram.filters import Command
from aiogram.types import Message from aiogram.types import Message
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.infrastructure.database.database import AsyncSessionLocal
from tg_bot.domain.services.user_service import UserService from tg_bot.domain.services.user_service import UserService
router = Router() router = Router()
user_service = UserService()
@router.message(Command("stats")) @router.message(Command("stats"))
async def cmd_stats(message: Message): async def cmd_stats(message: Message):
user_id = message.from_user.id user_id = message.from_user.id
async with AsyncSessionLocal() as session: try:
try: user = await user_service.get_user_by_telegram_id(user_id)
user_service = UserService(session)
user = await user_service.get_user_by_telegram_id(user_id)
if user: if user:
stats_text = ( stats_text = (
f"<b>Ваша статистика</b>\n\n" f"<b>Ваша статистика</b>\n\n"
f"<b>Основное:</b>\n" f"<b>Основное:</b>\n"
f"• ID: <code>{user_id}</code>\n" f"• ID: <code>{user_id}</code>\n"
f"• Premium: {'Да' if user.is_premium else 'Нет'}\n" f"• Premium: {'Да' if user.is_premium else 'Нет'}\n"
f"• Вопросов использовано: {user.questions_used}/{settings.FREE_QUESTIONS_LIMIT}\n\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"
) )
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"
)

View File

@ -0,0 +1,27 @@
"""
FSM состояния для работы с коллекциями
"""
from aiogram.fsm.state import State, StatesGroup
class CollectionAccessStates(StatesGroup):
"""Состояния для управления доступом к коллекции"""
waiting_for_username = State()
class CollectionEditStates(StatesGroup):
"""Состояния для редактирования коллекции"""
waiting_for_name = State()
waiting_for_description = State()
class DocumentEditStates(StatesGroup):
"""Состояния для редактирования документа"""
waiting_for_title = State()
waiting_for_content = State()
class DocumentUploadStates(StatesGroup):
"""Состояния для загрузки документа"""
waiting_for_file = State()

View File

@ -19,9 +19,6 @@ async def handle_yookassa_webhook(request: Request):
try: try:
from tg_bot.config.settings import settings from tg_bot.config.settings import settings
from tg_bot.domain.services.user_service import UserService 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 from aiogram import Bot
if event_type == "payment.succeeded": if event_type == "payment.succeeded":
@ -29,38 +26,34 @@ async def handle_yookassa_webhook(request: Request):
user_id = payment.get("metadata", {}).get("user_id") user_id = payment.get("metadata", {}).get("user_id")
if user_id: if user_id:
async with AsyncSessionLocal() as session: user_service = UserService()
user_service = UserService(session) success = await user_service.activate_premium(int(user_id))
success = await user_service.activate_premium(int(user_id)) if success:
if success: print(f"Premium activated for user {user_id}")
print(f"Premium activated for user {user_id}")
result = await session.execute( user = await user_service.get_user_by_telegram_id(int(user_id))
select(UserModel).filter_by(telegram_id=str(user_id))
)
user = result.scalar_one_or_none()
if user and settings.TELEGRAM_BOT_TOKEN: if user and settings.TELEGRAM_BOT_TOKEN:
try: try:
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
premium_until = user.premium_until or datetime.now() + timedelta(days=30) premium_until = user.premium_until or datetime.now() + timedelta(days=30)
notification = ( notification = (
f"<b>Оплата подтверждена!</b>\n\n" f"<b>Оплата подтверждена!</b>\n\n"
f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}" f"Premium активирован до {premium_until.strftime('%d.%m.%Y')}"
) )
await bot.send_message( await bot.send_message(
chat_id=int(user_id), chat_id=int(user_id),
text=notification, text=notification,
parse_mode="HTML" parse_mode="HTML"
) )
print(f"Notification sent to user {user_id}") print(f"Notification sent to user {user_id}")
await bot.session.close() await bot.session.close()
except Exception as e: except Exception as e:
print(f"Error sending notification: {e}") print(f"Error sending notification: {e}")
else: else:
print(f"User {user_id} not found") print(f"User {user_id} not found or failed to activate premium")
except ImportError as e: except ImportError as e:
print(f"Import error: {e}") print(f"Import error: {e}")

View File

@ -2,8 +2,6 @@ pydantic>=2.5.0
pydantic-settings>=2.1.0 pydantic-settings>=2.1.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
aiogram>=3.10.0 aiogram>=3.10.0
sqlalchemy>=2.0.0
aiosqlite>=0.19.0
httpx>=0.25.2 httpx>=0.25.2
yookassa>=2.4.0 yookassa>=2.4.0
aiohttp>=3.9.1 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())