diff --git a/.bot.pid b/.bot.pid index 9c4707a..f75cdb9 100644 --- a/.bot.pid +++ b/.bot.pid @@ -1 +1 @@ -966528 +969266 diff --git a/main.py b/main.py index 62867df..6ff516b 100644 --- a/main.py +++ b/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,6 +117,7 @@ async def main(): dp.include_router(admin_account_router) # Админские команды счетов dp.include_router(admin_chat_router) # Админские команды чата dp.include_router(redraw_router) # Повторные розыгрыши + dp.include_router(p2p_chat_router) # P2P чат между пользователями dp.include_router(account_router) # Пользовательские счета # 3. Chat router ПОСЛЕДНИМ (ловит все необработанные сообщения) diff --git a/migrations/versions/008_add_p2p_messages.py b/migrations/versions/008_add_p2p_messages.py new file mode 100644 index 0000000..fb403f6 --- /dev/null +++ b/migrations/versions/008_add_p2p_messages.py @@ -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') diff --git a/src/core/models.py b/src/core/models.py index 6dfc85f..70c6b69 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -215,4 +215,30 @@ class ChatMessage(Base): moderator = relationship("User", foreign_keys=[deleted_by]) def __repr__(self): - return f"" \ No newline at end of file + return f"" + + +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"" \ No newline at end of file diff --git a/src/core/p2p_services.py b/src/core/p2p_services.py new file mode 100644 index 0000000..cd40b88 --- /dev/null +++ b/src/core/p2p_services.py @@ -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() diff --git a/src/handlers/p2p_chat.py b/src/handlers/p2p_chat.py new file mode 100644 index 0000000..23d10bb --- /dev/null +++ b/src/handlers/p2p_chat.py @@ -0,0 +1,335 @@ +"""Обработчики 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 - показать меню с опциями общения + """ + 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 = "💬 Чат\n\n" + + if unread_count > 0: + text += f"📨 У вас {unread_count} непрочитанных сообщений\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( + "👥 Выберите пользователя:\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"💬 Диалог с {recipient_name}\n\n" + + if messages: + text += "📝 Последние сообщения:\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" + text += "Для выхода нажмите кнопку ниже" + + 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 = "📋 Ваши диалоги:\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} {peer_name}\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): + """Вернуться в главное меню""" + await callback.answer() + + # Имитируем команду /chat + fake_message = callback.message + fake_message.from_user = callback.from_user + + await show_chat_menu(fake_message, None) + + +# Обработчик сообщений в состоянии chatting +@router.message(StateFilter(P2PChatStates.chatting), F.text | F.photo | F.video | F.document) +async def handle_p2p_message(message: Message, state: FSMContext): + """Обработка P2P сообщения от пользователя""" + 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"💬 Сообщение от {sender_name}:\n\n{text}", + parse_mode="HTML" + ) + elif message_type == "photo": + sent = await message.bot.send_photo( + recipient_telegram_id, + photo=file_id, + caption=f"💬 Фото от {sender_name}\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"💬 Видео от {sender_name}\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"💬 Документ от {sender_name}\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}")