feat: добавлена система чата с модерацией
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Some checks reported errors
continuous-integration/drone/push Build encountered an error
Реализована полнофункциональная система чата с двумя режимами работы: ## Режимы работы: - Broadcast: рассылка сообщений всем пользователям - Forward: пересылка сообщений в указанную группу/канал ## Функционал: - Поддержка всех типов сообщений: text, photo, video, document, animation, sticker, voice - Система банов: личные баны пользователей и глобальный бан чата - Модерация: удаление сообщений с отслеживанием в БД - История сообщений с сохранением ID пересланных сообщений ## Структура БД (миграция 005): - chat_settings: настройки чата (режим, ID канала, глобальный бан) - banned_users: история банов с причинами и информацией о модераторе - chat_messages: история сообщений с типами, файлами и картой доставки (JSONB) ## Сервисы: - ChatSettingsService: управление настройками чата - BanService: управление банами пользователей - ChatMessageService: работа с историей сообщений - ChatPermissionService: проверка прав на отправку сообщений ## Обработчики: - chat_handlers.py: обработка сообщений пользователей (7 типов контента) - admin_chat_handlers.py: админские команды управления чатом ## Админские команды: - /chat_mode - переключение режима (broadcast/forward) - /set_forward <chat_id> - установка ID канала для пересылки - /ban <user_id> [причина] - бан пользователя - /unban <user_id> - разбан пользователя - /banlist - список забаненных - /global_ban - включение/выключение глобального бана - /delete_msg - удаление сообщения (ответ на сообщение) - /chat_stats - статистика чата ## Документация: - docs/CHAT_SYSTEM.md: полное описание системы с примерами использования Изменено файлов: 7 (2 modified, 5 new) - main.py: подключены chat_router и admin_chat_router - src/core/models.py: добавлены модели ChatSettings, BannedUser, ChatMessage - migrations/versions/005_add_chat_system.py: миграция создания таблиц - src/core/chat_services.py: сервисный слой для чата (267 строк) - src/handlers/chat_handlers.py: обработчики сообщений (447 строк) - src/handlers/admin_chat_handlers.py: админские команды (369 строк) - docs/CHAT_SYSTEM.md: документация (390 строк)
This commit is contained in:
270
src/core/chat_services.py
Normal file
270
src/core/chat_services.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""Сервисы для системы чата"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, update, delete
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .models import ChatSettings, BannedUser, ChatMessage, User
|
||||
|
||||
|
||||
class ChatSettingsService:
|
||||
"""Сервис управления настройками чата"""
|
||||
|
||||
@staticmethod
|
||||
async def get_settings(session: AsyncSession) -> Optional[ChatSettings]:
|
||||
"""Получить текущие настройки чата"""
|
||||
result = await session.execute(
|
||||
select(ChatSettings).where(ChatSettings.id == 1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_or_create_settings(session: AsyncSession) -> ChatSettings:
|
||||
"""Получить или создать настройки чата"""
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
if not settings:
|
||||
settings = ChatSettings(id=1, mode='broadcast', global_ban=False)
|
||||
session.add(settings)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_mode(session: AsyncSession, mode: str) -> ChatSettings:
|
||||
"""Установить режим работы чата (broadcast/forward)"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.mode = mode
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_forward_chat(session: AsyncSession, chat_id: str) -> ChatSettings:
|
||||
"""Установить ID группы/канала для пересылки"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.forward_chat_id = chat_id
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
@staticmethod
|
||||
async def set_global_ban(session: AsyncSession, enabled: bool) -> ChatSettings:
|
||||
"""Включить/выключить глобальный бан чата"""
|
||||
settings = await ChatSettingsService.get_or_create_settings(session)
|
||||
settings.global_ban = enabled
|
||||
settings.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
class BanService:
|
||||
"""Сервис управления банами пользователей"""
|
||||
|
||||
@staticmethod
|
||||
async def is_banned(session: AsyncSession, telegram_id: int) -> bool:
|
||||
"""Проверить забанен ли пользователь"""
|
||||
result = await session.execute(
|
||||
select(BannedUser).where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def ban_user(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
telegram_id: int,
|
||||
banned_by: int,
|
||||
reason: Optional[str] = None
|
||||
) -> BannedUser:
|
||||
"""Забанить пользователя"""
|
||||
# Проверяем есть ли уже активный бан
|
||||
existing_ban = await session.execute(
|
||||
select(BannedUser).where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = existing_ban.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
# Обновляем причину
|
||||
existing.reason = reason
|
||||
existing.banned_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
|
||||
# Создаем новый бан
|
||||
ban = BannedUser(
|
||||
user_id=user_id,
|
||||
telegram_id=telegram_id,
|
||||
banned_by=banned_by,
|
||||
reason=reason
|
||||
)
|
||||
session.add(ban)
|
||||
await session.commit()
|
||||
await session.refresh(ban)
|
||||
return ban
|
||||
|
||||
@staticmethod
|
||||
async def unban_user(session: AsyncSession, telegram_id: int) -> bool:
|
||||
"""Разбанить пользователя"""
|
||||
result = await session.execute(
|
||||
update(BannedUser)
|
||||
.where(
|
||||
and_(
|
||||
BannedUser.telegram_id == telegram_id,
|
||||
BannedUser.is_active == True
|
||||
)
|
||||
)
|
||||
.values(is_active=False)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_banned_users(session: AsyncSession, active_only: bool = True) -> List[BannedUser]:
|
||||
"""Получить список забаненных пользователей"""
|
||||
query = select(BannedUser).options(
|
||||
selectinload(BannedUser.user),
|
||||
selectinload(BannedUser.admin)
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.where(BannedUser.is_active == True)
|
||||
|
||||
result = await session.execute(query.order_by(BannedUser.banned_at.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ChatMessageService:
|
||||
"""Сервис работы с сообщениями чата"""
|
||||
|
||||
@staticmethod
|
||||
async def save_message(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
telegram_message_id: int,
|
||||
message_type: str,
|
||||
text: Optional[str] = None,
|
||||
file_id: Optional[str] = None,
|
||||
forwarded_ids: Optional[Dict[str, int]] = None
|
||||
) -> ChatMessage:
|
||||
"""Сохранить сообщение в историю"""
|
||||
message = ChatMessage(
|
||||
user_id=user_id,
|
||||
telegram_message_id=telegram_message_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
forwarded_message_ids=forwarded_ids
|
||||
)
|
||||
session.add(message)
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_message(session: AsyncSession, message_id: int) -> Optional[ChatMessage]:
|
||||
"""Получить сообщение по ID"""
|
||||
result = await session.execute(
|
||||
select(ChatMessage)
|
||||
.options(selectinload(ChatMessage.sender))
|
||||
.where(ChatMessage.id == message_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_messages(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
limit: int = 50,
|
||||
include_deleted: bool = False
|
||||
) -> List[ChatMessage]:
|
||||
"""Получить сообщения пользователя"""
|
||||
query = select(ChatMessage).where(ChatMessage.user_id == user_id)
|
||||
|
||||
if not include_deleted:
|
||||
query = query.where(ChatMessage.is_deleted == False)
|
||||
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def delete_message(
|
||||
session: AsyncSession,
|
||||
message_id: int,
|
||||
deleted_by: int
|
||||
) -> bool:
|
||||
"""Пометить сообщение как удаленное"""
|
||||
result = await session.execute(
|
||||
update(ChatMessage)
|
||||
.where(ChatMessage.id == message_id)
|
||||
.values(
|
||||
is_deleted=True,
|
||||
deleted_by=deleted_by,
|
||||
deleted_at=datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_messages(
|
||||
session: AsyncSession,
|
||||
limit: int = 100,
|
||||
include_deleted: bool = False
|
||||
) -> List[ChatMessage]:
|
||||
"""Получить последние сообщения чата"""
|
||||
query = select(ChatMessage).options(selectinload(ChatMessage.sender))
|
||||
|
||||
if not include_deleted:
|
||||
query = query.where(ChatMessage.is_deleted == False)
|
||||
|
||||
query = query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ChatPermissionService:
|
||||
"""Сервис проверки прав на отправку сообщений"""
|
||||
|
||||
@staticmethod
|
||||
async def can_send_message(
|
||||
session: AsyncSession,
|
||||
telegram_id: int,
|
||||
is_admin: bool = False
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Проверить может ли пользователь отправлять сообщения
|
||||
Возвращает (разрешено, причина_отказа)
|
||||
"""
|
||||
# Админы всегда могут отправлять
|
||||
if is_admin:
|
||||
return True, None
|
||||
|
||||
# Проверяем глобальный бан
|
||||
settings = await ChatSettingsService.get_settings(session)
|
||||
if settings and settings.global_ban:
|
||||
return False, "Чат временно закрыт администратором"
|
||||
|
||||
# Проверяем личный бан
|
||||
is_banned = await BanService.is_banned(session, telegram_id)
|
||||
if is_banned:
|
||||
return False, "Вы заблокированы и не можете отправлять сообщения"
|
||||
|
||||
return True, None
|
||||
@@ -156,4 +156,63 @@ class Winner(Base):
|
||||
def __repr__(self):
|
||||
if self.account_number:
|
||||
return f"<Winner(lottery_id={self.lottery_id}, account={self.account_number}, place={self.place})>"
|
||||
return f"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>"
|
||||
return f"<Winner(lottery_id={self.lottery_id}, user_id={self.user_id}, place={self.place})>"
|
||||
|
||||
|
||||
class ChatSettings(Base):
|
||||
"""Настройки системы чата"""
|
||||
__tablename__ = "chat_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
mode = Column(String(20), nullable=False, default='broadcast') # broadcast или forward
|
||||
forward_chat_id = Column(String(50), nullable=True) # ID группы/канала для пересылки
|
||||
global_ban = Column(Boolean, default=False) # Глобальный бан чата
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ChatSettings(mode={self.mode}, global_ban={self.global_ban})>"
|
||||
|
||||
|
||||
class BannedUser(Base):
|
||||
"""Забаненные пользователи (не могут отправлять сообщения)"""
|
||||
__tablename__ = "banned_users"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
telegram_id = Column(Integer, nullable=False, index=True)
|
||||
banned_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
reason = Column(Text, nullable=True)
|
||||
banned_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
is_active = Column(Boolean, default=True, index=True) # Активен ли бан
|
||||
|
||||
# Связи
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
admin = relationship("User", foreign_keys=[banned_by])
|
||||
|
||||
def __repr__(self):
|
||||
return f"<BannedUser(telegram_id={self.telegram_id}, is_active={self.is_active})>"
|
||||
|
||||
|
||||
class ChatMessage(Base):
|
||||
"""История сообщений чата (для модерации)"""
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
telegram_message_id = Column(Integer, nullable=False)
|
||||
message_type = Column(String(20), nullable=False) # text, photo, video, document, animation, sticker, voice, etc.
|
||||
text = Column(Text, nullable=True) # Текст сообщения
|
||||
file_id = Column(String(255), nullable=True) # ID файла в Telegram
|
||||
forwarded_message_ids = Column(JSON, nullable=True) # Список telegram_message_id пересланных сообщений {"user_telegram_id": message_id}
|
||||
is_deleted = Column(Boolean, default=False, index=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True)
|
||||
|
||||
# Связи
|
||||
sender = relationship("User", foreign_keys=[user_id])
|
||||
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})>"
|
||||
Reference in New Issue
Block a user