Compare commits
11 Commits
e882601b85
...
feature/ch
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6724952a | |||
| 6edcebe51f | |||
| 035ad464f7 | |||
| 698c945cef | |||
| 84adcce57b | |||
| fe2ac75aa8 | |||
| 09bef4e1b9 | |||
| c3c8f74c91 | |||
| 9e07b768f5 | |||
| 9a06d460e5 | |||
| 9dbf90aca9 |
10
main.py
10
main.py
@@ -23,6 +23,7 @@ from src.handlers.chat_handlers import router as chat_router
|
||||
from src.handlers.admin_chat_handlers import router as admin_chat_router
|
||||
from src.handlers.account_handlers import account_router
|
||||
from src.handlers.message_management import message_admin_router
|
||||
from src.handlers.p2p_chat import router as p2p_chat_router
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(
|
||||
@@ -116,10 +117,13 @@ async def main():
|
||||
dp.include_router(admin_account_router) # Админские команды счетов
|
||||
dp.include_router(admin_chat_router) # Админские команды чата
|
||||
dp.include_router(redraw_router) # Повторные розыгрыши
|
||||
dp.include_router(account_router) # Пользовательские счета
|
||||
dp.include_router(p2p_chat_router) # P2P чат между пользователями
|
||||
|
||||
# 3. Chat router ПОСЛЕДНИМ (ловит все необработанные сообщения)
|
||||
dp.include_router(chat_router) # Пользовательский чат (последним - ловит все сообщения)
|
||||
# 3. Chat router для broadcast (ловит все необработанные сообщения)
|
||||
dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
|
||||
|
||||
# 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов)
|
||||
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
|
||||
|
||||
# Запускаем polling
|
||||
try:
|
||||
|
||||
53
migrations/versions/008_add_p2p_messages.py
Normal file
53
migrations/versions/008_add_p2p_messages.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""add p2p messages table
|
||||
|
||||
Revision ID: 008
|
||||
Revises: 007
|
||||
Create Date: 2025-11-17
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers
|
||||
revision = '008'
|
||||
down_revision = '007'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Создаём таблицу P2P сообщений
|
||||
op.create_table(
|
||||
'p2p_messages',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('sender_id', sa.Integer(), nullable=False),
|
||||
sa.Column('recipient_id', sa.Integer(), nullable=False),
|
||||
sa.Column('message_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=True),
|
||||
sa.Column('file_id', sa.String(length=255), nullable=True),
|
||||
sa.Column('sender_message_id', sa.Integer(), nullable=False),
|
||||
sa.Column('recipient_message_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('read_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('reply_to_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['sender_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['reply_to_id'], ['p2p_messages.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Создаём индексы для быстрого поиска
|
||||
op.create_index('ix_p2p_messages_sender_id', 'p2p_messages', ['sender_id'])
|
||||
op.create_index('ix_p2p_messages_recipient_id', 'p2p_messages', ['recipient_id'])
|
||||
op.create_index('ix_p2p_messages_is_read', 'p2p_messages', ['is_read'])
|
||||
op.create_index('ix_p2p_messages_created_at', 'p2p_messages', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_p2p_messages_created_at', 'p2p_messages')
|
||||
op.drop_index('ix_p2p_messages_is_read', 'p2p_messages')
|
||||
op.drop_index('ix_p2p_messages_recipient_id', 'p2p_messages')
|
||||
op.drop_index('ix_p2p_messages_sender_id', 'p2p_messages')
|
||||
op.drop_table('p2p_messages')
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Сервисы для системы чата"""
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, update, delete
|
||||
from sqlalchemy import select, and_, or_, update, delete, text
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timezone
|
||||
@@ -185,6 +185,52 @@ class ChatMessageService:
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_message_by_telegram_id(
|
||||
session: AsyncSession,
|
||||
telegram_message_id: int,
|
||||
user_id: Optional[int] = None
|
||||
) -> Optional[ChatMessage]:
|
||||
"""
|
||||
Получить сообщение по telegram_message_id
|
||||
Ищет как по оригинальному telegram_message_id, так и в forwarded_message_ids
|
||||
"""
|
||||
# Сначала ищем по оригинальному telegram_message_id
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.telegram_message_id == telegram_message_id
|
||||
)
|
||||
|
||||
if user_id:
|
||||
query = query.where(ChatMessage.user_id == user_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
message = result.scalar_one_or_none()
|
||||
|
||||
# Если нашли - возвращаем
|
||||
if message:
|
||||
return message
|
||||
|
||||
# Если не нашли - ищем в forwarded_message_ids
|
||||
# Загружаем все недавние сообщения и ищем в них
|
||||
query = select(ChatMessage).where(
|
||||
ChatMessage.forwarded_message_ids.isnot(None)
|
||||
).order_by(ChatMessage.created_at.desc()).limit(100)
|
||||
|
||||
result = await session.execute(query)
|
||||
messages = result.scalars().all()
|
||||
|
||||
# Ищем сообщение, где telegram_message_id есть в forwarded_message_ids
|
||||
for msg in messages:
|
||||
if msg.forwarded_message_ids:
|
||||
for user_tid, fwd_msg_id in msg.forwarded_message_ids.items():
|
||||
if fwd_msg_id == telegram_message_id:
|
||||
return msg
|
||||
|
||||
return None
|
||||
|
||||
result = await session.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_messages(
|
||||
session: AsyncSession,
|
||||
|
||||
@@ -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()
|
||||
@@ -105,6 +105,10 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
|
||||
@router.message(F.text)
|
||||
async def handle_text_message(message: Message):
|
||||
"""Обработчик текстовых сообщений"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[CHAT] handle_text_message вызван: user={message.from_user.id}, text={message.text[:50] if message.text else 'None'}")
|
||||
|
||||
# Проверяем является ли это командой
|
||||
if message.text and message.text.startswith('/'):
|
||||
# Список команд, которые НЕ нужно пересылать
|
||||
@@ -171,11 +175,13 @@ async def handle_text_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
f"✅ Сообщение разослано!\n"
|
||||
f"📤 Доставлено: {success}\n"
|
||||
f"❌ Не доставлено: {fail}"
|
||||
)
|
||||
# Показываем статистику доставки только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(
|
||||
f"✅ Сообщение разослано!\n"
|
||||
f"📤 Доставлено: {success}\n"
|
||||
f"❌ Не доставлено: {fail}"
|
||||
)
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
# Режим пересылки в канал
|
||||
@@ -237,7 +243,9 @@ async def handle_photo_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Фото разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
@@ -289,7 +297,9 @@ async def handle_video_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Видео разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
@@ -341,7 +351,9 @@ async def handle_document_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Документ разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
@@ -393,7 +405,9 @@ async def handle_animation_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Анимация разослана: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
@@ -444,7 +458,9 @@ async def handle_sticker_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Стикер разослан: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
@@ -494,7 +510,9 @@ async def handle_voice_message(message: Message):
|
||||
forwarded_ids=forwarded_ids
|
||||
)
|
||||
|
||||
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
|
||||
# Показываем статистику только админам
|
||||
if is_admin(message.from_user.id):
|
||||
await message.answer(f"✅ Голосовое сообщение разослано: {success} получателей")
|
||||
|
||||
elif settings.mode == 'forward':
|
||||
if settings.forward_chat_id:
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
Хэндлеры для управления сообщениями администратором
|
||||
"""
|
||||
import logging
|
||||
from aiogram import Router, F
|
||||
from aiogram import Router, F, Bot
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.filters import Command
|
||||
|
||||
from ..core.config import ADMIN_IDS
|
||||
from ..core.database import async_session_maker
|
||||
from ..core.chat_services import ChatMessageService
|
||||
from ..core.services import UserService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,7 +62,117 @@ async def delete_message_callback(callback: CallbackQuery):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
await callback.answer("✅ Сообщение удалено")
|
||||
logger.info(f"Администратор {callback.from_user.id} удалил сообщение {callback.message.message_id} кнопкой")
|
||||
logger.info(f"Администратор {callback.from_user.id} удалил сообщение через кнопку")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении сообщения: {e}")
|
||||
logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
|
||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
# Функция-фильтр для проверки триггерных слов
|
||||
def is_delete_trigger(message: Message) -> bool:
|
||||
"""Проверяет, является ли сообщение триггером для удаления"""
|
||||
if not message.text:
|
||||
return False
|
||||
|
||||
text_lower = message.text.lower().strip()
|
||||
triggers = ["удалить", "delete", "del", "🗑️", "🗑", "❌"]
|
||||
return any(trigger in text_lower for trigger in triggers)
|
||||
|
||||
|
||||
@message_admin_router.message(F.reply_to_message, is_delete_trigger)
|
||||
async def quick_delete_replied_message(message: Message):
|
||||
"""
|
||||
Быстрое удаление сообщения по reply с триггерными словами или emoji
|
||||
Работает для админов при ответе на любое сообщение
|
||||
|
||||
Триггеры:
|
||||
- "удалить", "delete", "del"
|
||||
- 🗑️ (мусорная корзина)
|
||||
- ❌ (крестик)
|
||||
|
||||
Удаляет сообщение у всех получателей broadcast рассылки
|
||||
"""
|
||||
if not is_admin(message.from_user.id):
|
||||
return # Не админ - пропускаем
|
||||
|
||||
try:
|
||||
replied_msg = message.reply_to_message
|
||||
deleted_count = 0
|
||||
|
||||
# Пытаемся найти сообщение в БД по telegram_message_id
|
||||
async with async_session_maker() as session:
|
||||
# Получаем admin user для deleted_by
|
||||
admin_user = await UserService.get_user_by_telegram_id(
|
||||
session,
|
||||
message.from_user.id
|
||||
)
|
||||
|
||||
if not admin_user:
|
||||
logger.error(f"Админ {message.from_user.id} не найден в БД")
|
||||
await message.answer("❌ Ошибка: пользователь не найден")
|
||||
return
|
||||
|
||||
chat_message = await ChatMessageService.get_message_by_telegram_id(
|
||||
session,
|
||||
telegram_message_id=replied_msg.message_id
|
||||
)
|
||||
|
||||
# Если нашли broadcast сообщение - удаляем у всех получателей
|
||||
if chat_message and chat_message.forwarded_message_ids:
|
||||
bot = message.bot
|
||||
|
||||
for user_telegram_id, forwarded_msg_id in chat_message.forwarded_message_ids.items():
|
||||
try:
|
||||
await bot.delete_message(
|
||||
chat_id=int(user_telegram_id),
|
||||
message_id=forwarded_msg_id
|
||||
)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить сообщение у {user_telegram_id}: {e}")
|
||||
|
||||
# Помечаем как удалённое в БД (используем admin_user.id, а не telegram_id)
|
||||
await ChatMessageService.delete_message(
|
||||
session,
|
||||
message_id=chat_message.id,
|
||||
deleted_by=admin_user.id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Администратор {message.from_user.id} удалил broadcast сообщение "
|
||||
f"{replied_msg.message_id} у {deleted_count} получателей"
|
||||
)
|
||||
|
||||
# Удаляем исходное сообщение (на которое ответили)
|
||||
try:
|
||||
await replied_msg.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить исходное сообщение: {e}")
|
||||
|
||||
# Удаляем команду админа
|
||||
try:
|
||||
await message.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось удалить команду админа: {e}")
|
||||
|
||||
# Если было broadcast удаление - показываем статистику
|
||||
if deleted_count > 0:
|
||||
try:
|
||||
status_msg = await message.answer(
|
||||
f"✅ Сообщение удалено у {deleted_count} получателей",
|
||||
reply_to_message_id=None
|
||||
)
|
||||
# Удаляем статус через 3 секунды
|
||||
import asyncio
|
||||
await asyncio.sleep(3)
|
||||
await status_msg.delete()
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось показать/удалить статус: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при быстром удалении сообщения: {e}")
|
||||
try:
|
||||
# Пытаемся удалить хотя бы команду админа
|
||||
await message.delete()
|
||||
except:
|
||||
pass
|
||||
|
||||
343
src/handlers/p2p_chat.py
Normal file
343
src/handlers/p2p_chat.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""Обработчики P2P чата между пользователями"""
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import Command, StateFilter
|
||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional
|
||||
|
||||
from src.core.p2p_services import P2PMessageService
|
||||
from src.core.services import UserService
|
||||
from src.core.models import User
|
||||
from src.core.database import async_session_maker
|
||||
from src.core.config import ADMIN_IDS
|
||||
|
||||
|
||||
router = Router(name='p2p_chat_router')
|
||||
|
||||
|
||||
class P2PChatStates(StatesGroup):
|
||||
"""Состояния для P2P чата"""
|
||||
waiting_for_recipient = State() # Ожидание выбора получателя
|
||||
chatting = State() # В процессе переписки с пользователем
|
||||
|
||||
|
||||
def is_admin(user_id: int) -> bool:
|
||||
"""Проверка прав администратора"""
|
||||
return user_id in ADMIN_IDS
|
||||
|
||||
|
||||
@router.message(Command("chat"))
|
||||
async def show_chat_menu(message: Message, state: FSMContext):
|
||||
"""
|
||||
Главное меню чата
|
||||
/chat - показать меню с опциями общения
|
||||
"""
|
||||
# Очищаем состояние при входе в меню (выход из диалога)
|
||||
await state.clear()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
|
||||
if not user:
|
||||
await message.answer("❌ Вы не зарегистрированы. Используйте /start")
|
||||
return
|
||||
|
||||
# Получаем количество непрочитанных сообщений
|
||||
unread_count = await P2PMessageService.get_unread_count(session, user.id)
|
||||
|
||||
# Получаем последние диалоги
|
||||
recent = await P2PMessageService.get_recent_conversations(session, user.id, limit=5)
|
||||
|
||||
text = "💬 <b>Чат</b>\n\n"
|
||||
|
||||
if unread_count > 0:
|
||||
text += f"📨 У вас <b>{unread_count}</b> непрочитанных сообщений\n\n"
|
||||
|
||||
text += "Выберите действие:"
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(
|
||||
text="✉️ Написать пользователю",
|
||||
callback_data="p2p:select_user"
|
||||
)],
|
||||
[InlineKeyboardButton(
|
||||
text="📋 Мои диалоги",
|
||||
callback_data="p2p:my_conversations"
|
||||
)]
|
||||
]
|
||||
|
||||
if is_admin(message.from_user.id):
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="📢 Написать всем (broadcast)",
|
||||
callback_data="p2p:broadcast"
|
||||
)])
|
||||
|
||||
await message.answer(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "p2p:select_user")
|
||||
async def select_recipient(callback: CallbackQuery, state: FSMContext):
|
||||
"""Выбор получателя для P2P сообщения"""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
# Получаем всех зарегистрированных пользователей кроме себя
|
||||
users = await UserService.get_all_users(session)
|
||||
users = [u for u in users if u.telegram_id != callback.from_user.id and u.is_registered]
|
||||
|
||||
if not users:
|
||||
await callback.message.edit_text("❌ Нет доступных пользователей для общения")
|
||||
return
|
||||
|
||||
# Создаём кнопки с пользователями (по 1 на строку)
|
||||
buttons = []
|
||||
for user in users[:20]: # Ограничение 20 пользователей на странице
|
||||
display_name = f"@{user.username}" if user.username else user.first_name
|
||||
if user.club_card_number:
|
||||
display_name += f" (карта: {user.club_card_number})"
|
||||
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=display_name,
|
||||
callback_data=f"p2p:user:{user.id}"
|
||||
)])
|
||||
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="« Назад",
|
||||
callback_data="p2p:back_to_menu"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
"👥 <b>Выберите пользователя:</b>\n\n"
|
||||
"Кликните на пользователя, чтобы начать диалог",
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("p2p:user:"))
|
||||
async def start_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
"""Начать диалог с выбранным пользователем"""
|
||||
await callback.answer()
|
||||
|
||||
user_id = int(callback.data.split(":")[2])
|
||||
|
||||
async with async_session_maker() as session:
|
||||
recipient = await session.get(User, user_id)
|
||||
|
||||
if not recipient:
|
||||
await callback.message.edit_text("❌ Пользователь не найден")
|
||||
return
|
||||
|
||||
sender = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
# Получаем последние 10 сообщений из диалога
|
||||
messages = await P2PMessageService.get_conversation(
|
||||
session,
|
||||
sender.id,
|
||||
recipient.id,
|
||||
limit=10
|
||||
)
|
||||
|
||||
# Сохраняем ID получателя в состоянии
|
||||
await state.update_data(recipient_id=recipient.id, recipient_telegram_id=recipient.telegram_id)
|
||||
await state.set_state(P2PChatStates.chatting)
|
||||
|
||||
recipient_name = f"@{recipient.username}" if recipient.username else recipient.first_name
|
||||
|
||||
text = f"💬 <b>Диалог с {recipient_name}</b>\n\n"
|
||||
|
||||
if messages:
|
||||
text += "📝 <b>Последние сообщения:</b>\n\n"
|
||||
for msg in reversed(messages[-5:]): # Последние 5 сообщений
|
||||
sender_name = "Вы" if msg.sender_id == sender.id else recipient_name
|
||||
msg_text = msg.text[:50] + "..." if msg.text and len(msg.text) > 50 else (msg.text or f"[{msg.message_type}]")
|
||||
text += f"• {sender_name}: {msg_text}\n"
|
||||
text += "\n"
|
||||
|
||||
text += "✍️ Отправьте сообщение (текст, фото, видео...)\n\n"
|
||||
text += "⚠️ <b>Важно:</b> В режиме диалога все сообщения отправляются только собеседнику.\n"
|
||||
text += "Для выхода в общий чат используйте кнопку ниже или команду /chat"
|
||||
|
||||
buttons = [[InlineKeyboardButton(
|
||||
text="« Завершить диалог",
|
||||
callback_data="p2p:end_conversation"
|
||||
)]]
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "p2p:my_conversations")
|
||||
async def show_conversations(callback: CallbackQuery):
|
||||
"""Показать список диалогов"""
|
||||
await callback.answer()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
user = await UserService.get_user_by_telegram_id(session, callback.from_user.id)
|
||||
|
||||
conversations = await P2PMessageService.get_recent_conversations(session, user.id, limit=10)
|
||||
|
||||
if not conversations:
|
||||
await callback.message.edit_text(
|
||||
"📭 У вас пока нет диалогов\n\n"
|
||||
"Используйте /chat чтобы написать кому-нибудь"
|
||||
)
|
||||
return
|
||||
|
||||
text = "📋 <b>Ваши диалоги:</b>\n\n"
|
||||
|
||||
buttons = []
|
||||
for peer, last_msg, unread in conversations:
|
||||
peer_name = f"@{peer.username}" if peer.username else peer.first_name
|
||||
|
||||
# Иконка в зависимости от непрочитанных
|
||||
icon = "🔴" if unread > 0 else "💬"
|
||||
|
||||
# Превью последнего сообщения
|
||||
preview = last_msg.text[:30] + "..." if last_msg.text and len(last_msg.text) > 30 else (last_msg.text or f"[{last_msg.message_type}]")
|
||||
|
||||
button_text = f"{icon} {peer_name}"
|
||||
if unread > 0:
|
||||
button_text += f" ({unread})"
|
||||
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"p2p:user:{peer.id}"
|
||||
)])
|
||||
|
||||
text += f"{icon} <b>{peer_name}</b>\n"
|
||||
text += f" {preview}\n"
|
||||
if unread > 0:
|
||||
text += f" 📨 Непрочитанных: {unread}\n"
|
||||
text += "\n"
|
||||
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text="« Назад",
|
||||
callback_data="p2p:back_to_menu"
|
||||
)])
|
||||
|
||||
await callback.message.edit_text(
|
||||
text,
|
||||
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "p2p:end_conversation")
|
||||
async def end_conversation(callback: CallbackQuery, state: FSMContext):
|
||||
"""Завершить текущий диалог"""
|
||||
await callback.answer("Диалог завершён")
|
||||
await state.clear()
|
||||
|
||||
await callback.message.edit_text(
|
||||
"✅ Диалог завершён\n\n"
|
||||
"Используйте /chat чтобы открыть меню чата"
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "p2p:back_to_menu")
|
||||
async def back_to_menu(callback: CallbackQuery, state: FSMContext):
|
||||
"""Вернуться в главное меню"""
|
||||
await callback.answer()
|
||||
|
||||
# Имитируем команду /chat
|
||||
fake_message = callback.message
|
||||
fake_message.from_user = callback.from_user
|
||||
|
||||
await show_chat_menu(fake_message, state)
|
||||
|
||||
|
||||
# Обработчик сообщений в состоянии chatting
|
||||
@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document)
|
||||
async def handle_p2p_message(message: Message, state: FSMContext):
|
||||
"""Обработка P2P сообщения от пользователя"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[P2P] handle_p2p_message вызван: user={message.from_user.id}, в состоянии P2P chatting")
|
||||
|
||||
data = await state.get_data()
|
||||
recipient_id = data.get("recipient_id")
|
||||
recipient_telegram_id = data.get("recipient_telegram_id")
|
||||
|
||||
if not recipient_id or not recipient_telegram_id:
|
||||
await message.answer("❌ Ошибка: получатель не найден. Начните диалог заново с /chat")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
async with async_session_maker() as session:
|
||||
sender = await UserService.get_user_by_telegram_id(session, message.from_user.id)
|
||||
sender_name = f"@{sender.username}" if sender.username else sender.first_name
|
||||
|
||||
# Определяем тип сообщения
|
||||
message_type = "text"
|
||||
text = message.text
|
||||
file_id = None
|
||||
|
||||
if message.photo:
|
||||
message_type = "photo"
|
||||
file_id = message.photo[-1].file_id
|
||||
text = message.caption
|
||||
elif message.video:
|
||||
message_type = "video"
|
||||
file_id = message.video.file_id
|
||||
text = message.caption
|
||||
elif message.document:
|
||||
message_type = "document"
|
||||
file_id = message.document.file_id
|
||||
text = message.caption
|
||||
|
||||
# Отправляем сообщение получателю
|
||||
try:
|
||||
if message_type == "text":
|
||||
sent = await message.bot.send_message(
|
||||
recipient_telegram_id,
|
||||
f"💬 <b>Сообщение от {sender_name}:</b>\n\n{text}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "photo":
|
||||
sent = await message.bot.send_photo(
|
||||
recipient_telegram_id,
|
||||
photo=file_id,
|
||||
caption=f"💬 <b>Фото от {sender_name}</b>\n\n{text or ''}" ,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "video":
|
||||
sent = await message.bot.send_video(
|
||||
recipient_telegram_id,
|
||||
video=file_id,
|
||||
caption=f"💬 <b>Видео от {sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
elif message_type == "document":
|
||||
sent = await message.bot.send_document(
|
||||
recipient_telegram_id,
|
||||
document=file_id,
|
||||
caption=f"💬 <b>Документ от {sender_name}</b>\n\n{text or ''}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
# Сохраняем в БД
|
||||
await P2PMessageService.send_message(
|
||||
session,
|
||||
sender_id=sender.id,
|
||||
recipient_id=recipient_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file_id=file_id,
|
||||
sender_message_id=message.message_id,
|
||||
recipient_message_id=sent.message_id
|
||||
)
|
||||
|
||||
await message.answer("✅ Сообщение доставлено")
|
||||
|
||||
except Exception as e:
|
||||
await message.answer(f"❌ Не удалось доставить сообщение: {e}")
|
||||
Reference in New Issue
Block a user