feat: добавлен P2P чат между пользователями
- Новая модель P2PMessage для хранения личных сообщений - Миграция 008_add_p2p_messages.py - Сервис P2PMessageService для работы с P2P сообщениями - Команда /chat с меню чата - Выбор пользователя из списка - Отправка текста, фото, видео, документов - История последних диалогов - Счетчик непрочитанных сообщений - FSM состояния для управления диалогами
This commit is contained in:
@@ -215,4 +215,30 @@ class ChatMessage(Base):
|
||||
moderator = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
|
||||
return f"<ChatMessage(id={self.id}, user_id={self.user_id}, type={self.message_type})>"
|
||||
|
||||
|
||||
class P2PMessage(Base):
|
||||
"""P2P сообщения между пользователями"""
|
||||
__tablename__ = "p2p_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
sender_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
recipient_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
message_type = Column(String(20), nullable=False) # text, photo, video, etc.
|
||||
text = Column(Text, nullable=True)
|
||||
file_id = Column(String(255), nullable=True)
|
||||
sender_message_id = Column(Integer, nullable=False) # ID сообщения у отправителя
|
||||
recipient_message_id = Column(Integer, nullable=True) # ID сообщения у получателя
|
||||
is_read = Column(Boolean, default=False, index=True)
|
||||
read_at = Column(DateTime(timezone=True), nullable=True)
|
||||
reply_to_id = Column(Integer, ForeignKey("p2p_messages.id"), nullable=True) # Ответ на сообщение
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
# Связи
|
||||
sender = relationship("User", foreign_keys=[sender_id], backref="sent_p2p_messages")
|
||||
recipient = relationship("User", foreign_keys=[recipient_id], backref="received_p2p_messages")
|
||||
reply_to = relationship("P2PMessage", remote_side=[id], backref="replies")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<P2PMessage(id={self.id}, from={self.sender_id}, to={self.recipient_id})>"
|
||||
263
src/core/p2p_services.py
Normal file
263
src/core/p2p_services.py
Normal file
@@ -0,0 +1,263 @@
|
||||
"""Сервисы для работы с P2P сообщениями"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, desc, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional, Tuple
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import P2PMessage, User
|
||||
|
||||
|
||||
class P2PMessageService:
|
||||
"""Сервис для работы с P2P сообщениями"""
|
||||
|
||||
@staticmethod
|
||||
async def send_message(
|
||||
session: AsyncSession,
|
||||
sender_id: int,
|
||||
recipient_id: int,
|
||||
message_type: str,
|
||||
sender_message_id: int,
|
||||
recipient_message_id: Optional[int] = None,
|
||||
text: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
reply_to_id: Optional[int] = None
|
||||
) -> P2PMessage:
|
||||
"""
|
||||
Сохранить отправленное P2P сообщение
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
sender_id: ID отправителя
|
||||
recipient_id: ID получателя
|
||||
message_type: Тип сообщения (text, photo, etc.)
|
||||
sender_message_id: ID сообщения у отправителя
|
||||
recipient_message_id: ID сообщения у получателя
|
||||
text: Текст сообщения
|
||||
file_id: ID файла
|
||||
reply_to_id: ID сообщения, на которое отвечают
|
||||
|
||||
Returns:
|
||||
P2PMessage
|
||||
"""
|
||||
message = P2PMessage(
|
||||
sender_id=sender_id,
|
||||
recipient_id=recipient_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
sender_message_id=sender_message_id,
|
||||
recipient_message_id=recipient_message_id,
|
||||
reply_to_id=reply_to_id,
|
||||
created_at=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def mark_as_read(session: AsyncSession, message_id: int) -> bool:
|
||||
"""
|
||||
Отметить сообщение как прочитанное
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
message_id: ID сообщения
|
||||
|
||||
Returns:
|
||||
bool: True если успешно
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(P2PMessage).where(P2PMessage.id == message_id)
|
||||
)
|
||||
message = result.scalar_one_or_none()
|
||||
|
||||
if message and not message.is_read:
|
||||
message.is_read = True
|
||||
message.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def get_conversation(
|
||||
session: AsyncSession,
|
||||
user1_id: int,
|
||||
user2_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[P2PMessage]:
|
||||
"""
|
||||
Получить переписку между двумя пользователями
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
user1_id: ID первого пользователя
|
||||
user2_id: ID второго пользователя
|
||||
limit: Максимальное количество сообщений
|
||||
offset: Смещение для пагинации
|
||||
|
||||
Returns:
|
||||
List[P2PMessage]: Список сообщений (от новых к старым)
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(P2PMessage)
|
||||
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
|
||||
.where(
|
||||
or_(
|
||||
and_(P2PMessage.sender_id == user1_id, P2PMessage.recipient_id == user2_id),
|
||||
and_(P2PMessage.sender_id == user2_id, P2PMessage.recipient_id == user1_id)
|
||||
)
|
||||
)
|
||||
.order_by(desc(P2PMessage.created_at))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
return list(result.scalars().all())
|
||||
|
||||
@staticmethod
|
||||
async def get_unread_count(session: AsyncSession, user_id: int) -> int:
|
||||
"""
|
||||
Получить количество непрочитанных сообщений пользователя
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
user_id: ID пользователя
|
||||
|
||||
Returns:
|
||||
int: Количество непрочитанных сообщений
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(func.count(P2PMessage.id))
|
||||
.where(
|
||||
and_(
|
||||
P2PMessage.recipient_id == user_id,
|
||||
P2PMessage.is_read == False
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar() or 0
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_conversations(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 10
|
||||
) -> List[Tuple[User, P2PMessage, int]]:
|
||||
"""
|
||||
Получить список последних диалогов пользователя
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
user_id: ID пользователя
|
||||
limit: Максимальное количество диалогов
|
||||
|
||||
Returns:
|
||||
List[Tuple[User, P2PMessage, int]]: Список (собеседник, последнее_сообщение, непрочитанных)
|
||||
"""
|
||||
# Получаем все ID собеседников
|
||||
result = await session.execute(
|
||||
select(P2PMessage.sender_id, P2PMessage.recipient_id)
|
||||
.where(
|
||||
or_(
|
||||
P2PMessage.sender_id == user_id,
|
||||
P2PMessage.recipient_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Собираем уникальных собеседников
|
||||
peers = set()
|
||||
for sender_id, recipient_id in result.all():
|
||||
peer_id = recipient_id if sender_id == user_id else sender_id
|
||||
peers.add(peer_id)
|
||||
|
||||
# Для каждого собеседника получаем последнее сообщение и количество непрочитанных
|
||||
conversations = []
|
||||
|
||||
for peer_id in peers:
|
||||
# Последнее сообщение
|
||||
last_msg_result = await session.execute(
|
||||
select(P2PMessage)
|
||||
.where(
|
||||
or_(
|
||||
and_(P2PMessage.sender_id == user_id, P2PMessage.recipient_id == peer_id),
|
||||
and_(P2PMessage.sender_id == peer_id, P2PMessage.recipient_id == user_id)
|
||||
)
|
||||
)
|
||||
.order_by(desc(P2PMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
last_message = last_msg_result.scalar_one_or_none()
|
||||
|
||||
if not last_message:
|
||||
continue
|
||||
|
||||
# Количество непрочитанных от этого собеседника
|
||||
unread_result = await session.execute(
|
||||
select(func.count(P2PMessage.id))
|
||||
.where(
|
||||
and_(
|
||||
P2PMessage.sender_id == peer_id,
|
||||
P2PMessage.recipient_id == user_id,
|
||||
P2PMessage.is_read == False
|
||||
)
|
||||
)
|
||||
)
|
||||
unread_count = unread_result.scalar() or 0
|
||||
|
||||
# Получаем пользователя-собеседника
|
||||
peer_result = await session.execute(
|
||||
select(User).where(User.id == peer_id)
|
||||
)
|
||||
peer = peer_result.scalar_one_or_none()
|
||||
|
||||
if peer:
|
||||
conversations.append((peer, last_message, unread_count))
|
||||
|
||||
# Сортируем по времени последнего сообщения
|
||||
conversations.sort(key=lambda x: x[1].created_at, reverse=True)
|
||||
|
||||
return conversations[:limit]
|
||||
|
||||
@staticmethod
|
||||
async def find_original_message(
|
||||
session: AsyncSession,
|
||||
telegram_message_id: int,
|
||||
user_id: int
|
||||
) -> Optional[P2PMessage]:
|
||||
"""
|
||||
Найти оригинальное P2P сообщение по telegram_message_id
|
||||
|
||||
Args:
|
||||
session: Сессия БД
|
||||
telegram_message_id: ID сообщения в Telegram
|
||||
user_id: ID пользователя (для проверки прав)
|
||||
|
||||
Returns:
|
||||
Optional[P2PMessage]: Найденное сообщение или None
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(P2PMessage)
|
||||
.options(selectinload(P2PMessage.sender), selectinload(P2PMessage.recipient))
|
||||
.where(
|
||||
or_(
|
||||
and_(
|
||||
P2PMessage.sender_message_id == telegram_message_id,
|
||||
P2PMessage.sender_id == user_id
|
||||
),
|
||||
and_(
|
||||
P2PMessage.recipient_message_id == telegram_message_id,
|
||||
P2PMessage.recipient_id == user_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return result.scalar_one_or_none()
|
||||
Reference in New Issue
Block a user