Compare commits

...

11 Commits

Author SHA1 Message Date
bf6724952a chat system refactor
Some checks reported errors
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/pr Build encountered an error
message deletion
2025-11-17 15:04:41 +09:00
6edcebe51f fix: улучшенная обработка ошибок при удалении сообщений
- Каждое удаление теперь в отдельном try-except
- Если сообщение не найдено - продолжаем удаление остальных
- Не показываем пользователю ошибки 'message not found'
- Всегда удаляем команду админа даже при ошибках
2025-11-17 14:59:41 +09:00
035ad464f7 fix: использовать user.id вместо telegram_id для deleted_by
- Исправлена ошибка ForeignKeyViolationError при удалении
- Теперь получаем admin_user из БД и используем его id
- deleted_by корректно ссылается на users.id
2025-11-17 14:58:13 +09:00
698c945cef fix: поиск broadcast сообщения по forwarded_message_id
- Теперь можно удалять broadcast сообщение, отвечая на его копию (не только на оригинал)
- Метод get_message_by_telegram_id ищет в forwarded_message_ids
- Админ может ответить на любую копию сообщения для удаления у всех
2025-11-17 14:56:18 +09:00
84adcce57b feat: массовое удаление broadcast сообщений у всех получателей
- Quick delete теперь удаляет сообщения у всех получателей broadcast
- Добавлен метод get_message_by_telegram_id в ChatMessageService
- При удалении проходит по всем forwarded_message_ids и удаляет у каждого
- Показывает статистику удаления админу (автоматически исчезает через 3 сек)
- Помечает сообщение как удалённое в БД
2025-11-17 11:54:15 +09:00
fe2ac75aa8 fix: исправлена блокировка broadcast и отключена статистика для обычных пользователей
- Исправлен порядок роутеров: account_router перемещён после chat_router
- Добавлен фильтр is_delete_trigger для quick_delete (перехватывал все сообщения)
- Статистика доставки теперь показывается только админам
- Обычные пользователи больше не видят 'Сообщение разослано' после отправки
2025-11-17 11:44:12 +09:00
09bef4e1b9 fix: исправлена блокировка broadcast чата из-за P2P состояния
- Добавлен автоматический выход из P2P состояния при команде /chat
- Теперь пользователь может свободно переключаться между P2P и broadcast
- Добавлено предупреждение в P2P диалоге о том, что сообщения идут только собеседнику
- Инструкция как выйти: кнопка 'Завершить диалог' или команда /chat
- Это решает проблему когда текст не рассылался всем из-за активного P2P состояния
2025-11-17 11:27:51 +09:00
c3c8f74c91 feat: быстрое удаление сообщений для админов
- Откат изменений глобального чата (возврат к broadcast/forward режимам)
- Новый хэндлер quick_delete_replied_message для быстрого удаления
- Админ отвечает на сообщение со словами: удалить, delete, del
- Или отправляет emoji: 🗑️, 
- Удаляются оба сообщения (целевое и команда)
- Работает для любых сообщений, не только бота
- Логирование всех удалений
2025-11-17 11:21:56 +09:00
9e07b768f5 Revert "feat: глобальный чат по умолчанию"
This reverts commit 9a06d460e5.
2025-11-17 11:21:00 +09:00
9a06d460e5 feat: глобальный чат по умолчанию
- Все сообщения (текст, фото, видео, документы, стикеры, голосовые) автоматически рассылаются всем пользователям
- Исключение: команды (начинаются с /) не рассылаются
- Исключение для админов: паттерны счетов обрабатываются в account_handlers
- Упрощена логика - убран режим forward, всегда broadcast
- Показ статистики доставки: успешно/неуспешно
- Проверка прав на отправку сообщений (баны)
2025-11-17 11:19:00 +09:00
9dbf90aca9 feat: добавлен P2P чат между пользователями
- Новая модель P2PMessage для хранения личных сообщений
- Миграция 008_add_p2p_messages.py
- Сервис P2PMessageService для работы с P2P сообщениями
- Команда /chat с меню чата
- Выбор пользователя из списка
- Отправка текста, фото, видео, документов
- История последних диалогов
- Счетчик непрочитанных сообщений
- FSM состояния для управления диалогами
2025-11-17 11:11:33 +09:00
9 changed files with 886 additions and 20 deletions

View File

@@ -1 +1 @@
966528 1060744

10
main.py
View File

@@ -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.admin_chat_handlers import router as admin_chat_router
from src.handlers.account_handlers import account_router from src.handlers.account_handlers import account_router
from src.handlers.message_management import message_admin_router from src.handlers.message_management import message_admin_router
from src.handlers.p2p_chat import router as p2p_chat_router
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@@ -116,10 +117,13 @@ async def main():
dp.include_router(admin_account_router) # Админские команды счетов dp.include_router(admin_account_router) # Админские команды счетов
dp.include_router(admin_chat_router) # Админские команды чата dp.include_router(admin_chat_router) # Админские команды чата
dp.include_router(redraw_router) # Повторные розыгрыши dp.include_router(redraw_router) # Повторные розыгрыши
dp.include_router(account_router) # Пользовательские счета dp.include_router(p2p_chat_router) # P2P чат между пользователями
# 3. Chat router ПОСЛЕДНИМ (ловит все необработанные сообщения) # 3. Chat router для broadcast (ловит все необработанные сообщения)
dp.include_router(chat_router) # Пользовательский чат (последним - ловит все сообщения) dp.include_router(chat_router) # Пользовательский чат (broadcast всем)
# 4. Account router ПОСЛЕДНИМ (обнаружение счетов для админов)
dp.include_router(account_router) # Пользовательские счета + обнаружение для админов
# Запускаем polling # Запускаем polling
try: try:

View 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')

View File

@@ -1,6 +1,6 @@
"""Сервисы для системы чата""" """Сервисы для системы чата"""
from sqlalchemy.ext.asyncio import AsyncSession 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 sqlalchemy.orm import selectinload
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -185,6 +185,52 @@ class ChatMessageService:
) )
return result.scalar_one_or_none() 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 @staticmethod
async def get_user_messages( async def get_user_messages(
session: AsyncSession, session: AsyncSession,

View File

@@ -215,4 +215,30 @@ class ChatMessage(Base):
moderator = relationship("User", foreign_keys=[deleted_by]) moderator = relationship("User", foreign_keys=[deleted_by])
def __repr__(self): 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
View 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()

View File

@@ -105,6 +105,10 @@ async def forward_to_channel(message: Message, channel_id: str) -> tuple[bool, O
@router.message(F.text) @router.message(F.text)
async def handle_text_message(message: Message): 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('/'): if message.text and message.text.startswith('/'):
# Список команд, которые НЕ нужно пересылать # Список команд, которые НЕ нужно пересылать
@@ -171,11 +175,13 @@ async def handle_text_message(message: Message):
forwarded_ids=forwarded_ids forwarded_ids=forwarded_ids
) )
await message.answer( # Показываем статистику доставки только админам
f"✅ Сообщение разослано!\n" if is_admin(message.from_user.id):
f"📤 Доставлено: {success}\n" await message.answer(
f"Не доставлено: {fail}" f"✅ Сообщение разослано!\n"
) f"📤 Доставлено: {success}\n"
f"Не доставлено: {fail}"
)
elif settings.mode == 'forward': elif settings.mode == 'forward':
# Режим пересылки в канал # Режим пересылки в канал
@@ -237,7 +243,9 @@ async def handle_photo_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:
@@ -289,7 +297,9 @@ async def handle_video_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:
@@ -341,7 +351,9 @@ async def handle_document_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:
@@ -393,7 +405,9 @@ async def handle_animation_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:
@@ -444,7 +458,9 @@ async def handle_sticker_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:
@@ -494,7 +510,9 @@ async def handle_voice_message(message: Message):
forwarded_ids=forwarded_ids 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': elif settings.mode == 'forward':
if settings.forward_chat_id: if settings.forward_chat_id:

View File

@@ -2,11 +2,14 @@
Хэндлеры для управления сообщениями администратором Хэндлеры для управления сообщениями администратором
""" """
import logging import logging
from aiogram import Router, F from aiogram import Router, F, Bot
from aiogram.types import Message, CallbackQuery from aiogram.types import Message, CallbackQuery
from aiogram.filters import Command from aiogram.filters import Command
from ..core.config import ADMIN_IDS 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__) logger = logging.getLogger(__name__)
@@ -59,7 +62,117 @@ async def delete_message_callback(callback: CallbackQuery):
try: try:
await callback.message.delete() await callback.message.delete()
await callback.answer("✅ Сообщение удалено") 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: except Exception as e:
logger.error(f"Ошибка при удалении сообщения: {e}") logger.error(f"Ошибка при удалении сообщения через кнопку: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True) 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
View 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}")